@adeildo/pi-ask-permission 4.1.0 → 5.1.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.
- package/CONTRIBUTING.md +1 -1
- package/README.md +114 -193
- package/assets/preview.png +0 -0
- package/package.json +3 -2
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +40 -6
- package/src/core/config/patterns.ts +0 -6
- package/src/core/config/schema.ts +13 -0
- package/src/core/config/settings.ts +318 -226
- package/src/core/config/store.ts +11 -4
- package/src/core/decide.ts +131 -19
- package/src/core/folders.ts +159 -0
- package/src/core/judge/backends/factory.ts +2 -2
- package/src/core/judge/backends/pi-model.ts +2 -1
- package/src/core/judge/compose.ts +0 -1
- package/src/core/judge/config.ts +34 -11
- package/src/core/judge/decode.ts +0 -3
- package/src/core/judge/gate.ts +9 -4
- package/src/core/judge/pipeline.ts +7 -4
- package/src/core/judge/policy.ts +1 -1
- package/src/core/judge/report.ts +5 -4
- package/src/core/judge/request.ts +19 -4
- package/src/core/judge/types.ts +11 -0
- package/src/core/mcp.ts +130 -0
- package/src/core/mode.ts +47 -25
- package/src/core/readonly-bash.ts +167 -4
- package/src/core/tools.ts +7 -3
- package/src/core/workspace.ts +107 -16
- package/src/index.ts +19 -3
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +83 -46
- package/src/pi/intent.ts +34 -0
- package/src/pi/mode.ts +63 -16
- package/src/pi/screen.ts +293 -0
- package/src/pi/session-entries.ts +46 -8
- package/src/pi/session.ts +52 -11
- package/src/ui/decision-options.ts +56 -12
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +194 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/clipboard.ts +0 -51
- package/src/ui/dialog.ts +0 -395
- package/src/ui/paste.ts +0 -27
- package/src/ui/picker.ts +0 -90
- package/src/ui/settings/judge.ts +0 -299
- package/src/ui/settings/screen.ts +0 -268
- package/src/ui/settings/status.ts +0 -53
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The permission ask, drawn by the questions feature so both dialogs are the same one. The call
|
|
2
|
+
// becomes a question, and the answer becomes the decision the gate already knows how to apply.
|
|
3
|
+
import { type AskAnswer, type AskOption, type AskQuestion, askQuestions } from "@adeildo/pi-kit";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { SCOPE_LABEL, SCOPES, type Scope } from "#core/always-yes.ts";
|
|
7
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
8
|
+
import type { Call } from "#core/decide.ts";
|
|
9
|
+
import { describeHints } from "#core/mcp.ts";
|
|
10
|
+
import { openLabel } from "#ui/decision-options.ts";
|
|
11
|
+
|
|
12
|
+
const YES = "yes, run it";
|
|
13
|
+
const ALWAYS = "always yes";
|
|
14
|
+
const DENY = "no";
|
|
15
|
+
|
|
16
|
+
export interface AskExtras {
|
|
17
|
+
diff?: string;
|
|
18
|
+
reason?: string;
|
|
19
|
+
offer?: FolderOffer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Undefined when the questions feature is not there to draw, or failed: the caller falls back. */
|
|
23
|
+
export async function askThroughQuestions(
|
|
24
|
+
events: ExtensionAPI["events"],
|
|
25
|
+
call: Call,
|
|
26
|
+
extras: AskExtras,
|
|
27
|
+
): Promise<DialogAnswer | undefined> {
|
|
28
|
+
const first = await askQuestions(events, [firstQuestion(call, extras)]);
|
|
29
|
+
if (first.error !== undefined) return undefined;
|
|
30
|
+
if (first.cancelled) return { decision: "deny" };
|
|
31
|
+
|
|
32
|
+
const answer = first.answers[0];
|
|
33
|
+
const note = noteOf(answer);
|
|
34
|
+
// A typed answer instead of a pick: the user is telling the model what to do instead.
|
|
35
|
+
if (answer === undefined || answer.picked.length === 0) return { decision: "deny", note };
|
|
36
|
+
|
|
37
|
+
const picked = answer.picked[0] ?? "";
|
|
38
|
+
if (picked === DENY) return { decision: "deny", note };
|
|
39
|
+
if (picked === ALWAYS) return remember(events, call, note);
|
|
40
|
+
|
|
41
|
+
const folder = folderOf(extras.offer, picked);
|
|
42
|
+
if (folder === undefined) return { decision: "allow", note };
|
|
43
|
+
const scope = await askScope(events, "Open the folder for how long?");
|
|
44
|
+
return { decision: "allow", note, open: { path: folder, access: extras.offer!.access, scope } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function firstQuestion(call: Call, extras: AskExtras): AskQuestion {
|
|
48
|
+
const options: AskOption[] = [
|
|
49
|
+
{ label: YES, description: "Run this call now, and ask again next time." },
|
|
50
|
+
...(levelsOf(call).length > 0
|
|
51
|
+
? [
|
|
52
|
+
{
|
|
53
|
+
label: ALWAYS,
|
|
54
|
+
description: "Run this call, and stop asking for the calls you pick next.",
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
58
|
+
...offerOptions(extras.offer),
|
|
59
|
+
{
|
|
60
|
+
label: DENY,
|
|
61
|
+
description: "Block it. A note on this answer becomes the reason the model reads.",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Every option offers the same preview, whichever row the cursor is on.
|
|
66
|
+
if (extras.diff !== undefined) for (const option of options) option.preview = extras.diff;
|
|
67
|
+
|
|
68
|
+
// The reason for a no is a note on it. A row of its own for words would say the same thing twice.
|
|
69
|
+
return {
|
|
70
|
+
header: "permission",
|
|
71
|
+
question: questionText(call, extras.reason),
|
|
72
|
+
options,
|
|
73
|
+
typed: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function offerOptions(offer: FolderOffer | undefined): { label: string; description: string }[] {
|
|
78
|
+
if (offer === undefined) return [];
|
|
79
|
+
const reads = offer.access === "read";
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
label: openLabel(offer, offer.folders[offer.suggested] ?? ""),
|
|
83
|
+
description: reads
|
|
84
|
+
? "Run it, and let later calls read there without asking."
|
|
85
|
+
: "Run it, and treat that folder as part of the workspace.",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A heredoc or a long payload would fill the screen; a few lines say what the call is. */
|
|
91
|
+
const SUMMARY_LINES = 3;
|
|
92
|
+
|
|
93
|
+
function clipSummary(summary: string): string[] {
|
|
94
|
+
const lines = summary.split("\n");
|
|
95
|
+
if (lines.length <= SUMMARY_LINES) return lines;
|
|
96
|
+
const more = lines.length - SUMMARY_LINES;
|
|
97
|
+
return [...lines.slice(0, SUMMARY_LINES), `\u2026 ${more} more ${more === 1 ? "line" : "lines"}`];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function questionText(call: Call, reason: string | undefined): string {
|
|
101
|
+
const lines = [
|
|
102
|
+
`${whereOf(call)} wants to run:`,
|
|
103
|
+
...clipSummary(call.target.summary).map((line) => ` ${line}`),
|
|
104
|
+
];
|
|
105
|
+
if (reason !== undefined) lines.push("", `\u25b2 ${reason}`);
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The tool name, plus what it does not say: the server behind an MCP tool and what it declares, and
|
|
110
|
+
// a call a script issued rather than the model.
|
|
111
|
+
function whereOf(call: Call): string {
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
if (call.mcp !== undefined)
|
|
114
|
+
parts.push(`${call.mcp.server}:${call.mcp.tool} (${describeHints(call.hints)})`);
|
|
115
|
+
else parts.push(call.toolName);
|
|
116
|
+
if (call.nested) parts.push("(from a codemode script)");
|
|
117
|
+
return parts.join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function levelsOf(call: Call): string[] {
|
|
121
|
+
return [...call.target.levels].toReversed();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The always-yes path: which calls to remember, then for how long. */
|
|
125
|
+
async function remember(
|
|
126
|
+
events: ExtensionAPI["events"],
|
|
127
|
+
call: Call,
|
|
128
|
+
note: string | undefined,
|
|
129
|
+
): Promise<DialogAnswer | undefined> {
|
|
130
|
+
const levels = levelsOf(call);
|
|
131
|
+
if (levels.length === 0) return { decision: "allow", note };
|
|
132
|
+
|
|
133
|
+
const picked = await askQuestions(events, [
|
|
134
|
+
{
|
|
135
|
+
header: "remember",
|
|
136
|
+
typed: false,
|
|
137
|
+
question: `Always say yes to which calls, from ${call.toolName}?`,
|
|
138
|
+
options: levels.map((level, index) => ({
|
|
139
|
+
label: level,
|
|
140
|
+
description:
|
|
141
|
+
index === 0
|
|
142
|
+
? "Only this exact call."
|
|
143
|
+
: "This call, and anything else that starts with it.",
|
|
144
|
+
})),
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
if (picked.error !== undefined || picked.cancelled) return { decision: "allow", note };
|
|
148
|
+
|
|
149
|
+
const level = picked.answers[0]?.picked[0];
|
|
150
|
+
if (level === undefined || level === "") return { decision: "allow", note };
|
|
151
|
+
const scope = await askScope(events, "Remember it for how long?");
|
|
152
|
+
return { decision: "allow", note, remember: level, scope };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function askScope(events: ExtensionAPI["events"], question: string): Promise<Scope> {
|
|
156
|
+
const result = await askQuestions(events, [
|
|
157
|
+
{
|
|
158
|
+
header: "where",
|
|
159
|
+
typed: false,
|
|
160
|
+
question,
|
|
161
|
+
options: SCOPES.map((scope) => ({
|
|
162
|
+
label: SCOPE_LABEL[scope],
|
|
163
|
+
description:
|
|
164
|
+
scope === "session"
|
|
165
|
+
? "Gone when this session ends."
|
|
166
|
+
: scope === "project"
|
|
167
|
+
? "Saved for this project, in its own file."
|
|
168
|
+
: "Saved for every project.",
|
|
169
|
+
})),
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
const picked = result.answers[0]?.picked[0];
|
|
173
|
+
const scope = SCOPES.find((candidate) => SCOPE_LABEL[candidate] === picked);
|
|
174
|
+
return scope ?? "session";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function folderOf(offer: FolderOffer | undefined, picked: string): string | undefined {
|
|
178
|
+
if (offer === undefined) return undefined;
|
|
179
|
+
return offer.folders.find((folder) => openLabel(offer, folder) === picked);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every note the user left, the picked option's first. */
|
|
183
|
+
function noteOf(answer: AskAnswer | undefined): string | undefined {
|
|
184
|
+
if (answer === undefined) return undefined;
|
|
185
|
+
const parts: string[] = [];
|
|
186
|
+
const picked = answer.notes.find((note) => answer.picked.includes(note.option));
|
|
187
|
+
if (picked !== undefined) parts.push(picked.note);
|
|
188
|
+
for (const note of answer.notes)
|
|
189
|
+
if (!answer.picked.includes(note.option)) parts.push(`on "${note.option}": ${note.note}`);
|
|
190
|
+
const typed = answer.typed?.trim();
|
|
191
|
+
if (typed !== undefined && typed !== "") parts.push(typed);
|
|
192
|
+
|
|
193
|
+
return parts.length === 0 ? undefined : parts.join("\n");
|
|
194
|
+
}
|
package/src/ui/selector.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
4
|
-
import type { DialogAnswer } from "#core/answer.ts";
|
|
4
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
5
5
|
import type { CallDescriptor } from "#core/tools.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { fallbackChoices } from "#ui/decision-options.ts";
|
|
7
7
|
|
|
8
8
|
export async function askViaSelector(
|
|
9
9
|
ctx: ExtensionContext,
|
|
10
10
|
toolName: string,
|
|
11
11
|
target: CallDescriptor,
|
|
12
|
+
offer?: FolderOffer,
|
|
12
13
|
): Promise<DialogAnswer> {
|
|
13
|
-
const
|
|
14
|
+
const choices = fallbackChoices(offer);
|
|
15
|
+
const labels = choices.map((option) => `${option.key}. ${option.label}`);
|
|
14
16
|
const choice = await ctx.ui.select(`Allow ${toolName}?\n${target.summary}`, labels);
|
|
15
|
-
const option = choice ?
|
|
17
|
+
const option = choice ? choices[labels.indexOf(choice)] : undefined;
|
|
16
18
|
if (!option) return { decision: "deny" };
|
|
17
19
|
|
|
20
|
+
const folder = offer?.folders[offer.suggested];
|
|
21
|
+
const open =
|
|
22
|
+
option.open && offer && folder !== undefined
|
|
23
|
+
? { path: folder, access: offer.access, scope: "session" as const }
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
18
26
|
let remember: string | undefined;
|
|
19
27
|
let scope: Scope | undefined;
|
|
20
28
|
if (option.always) {
|
|
@@ -38,5 +46,5 @@ export async function askViaSelector(
|
|
|
38
46
|
note = (await ctx.ui.input(prompt))?.trim() || undefined;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
return { decision: option.decision, note, remember, scope };
|
|
49
|
+
return { decision: option.decision, note, remember, scope, open };
|
|
42
50
|
}
|
package/src/ui/clipboard.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
interface ClipboardImage {
|
|
7
|
-
bytes: Uint8Array;
|
|
8
|
-
mimeType: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface ClipboardReader {
|
|
12
|
-
readClipboardImage: () => Promise<ClipboardImage | null>;
|
|
13
|
-
readClipboardText: () => Promise<string | null>;
|
|
14
|
-
extensionForImageMimeType: (mimeType: string) => string | null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let loaded: Promise<ClipboardReader | null> | undefined;
|
|
18
|
-
|
|
19
|
-
function loadClipboard(): Promise<ClipboardReader | null> {
|
|
20
|
-
loaded ??= (async () => {
|
|
21
|
-
try {
|
|
22
|
-
const root = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
23
|
-
const images = await import(new URL("./utils/clipboard-image.js", root).href);
|
|
24
|
-
const text = await import(new URL("./utils/clipboard.js", root).href);
|
|
25
|
-
return {
|
|
26
|
-
readClipboardImage: images.readClipboardImage,
|
|
27
|
-
extensionForImageMimeType: images.extensionForImageMimeType,
|
|
28
|
-
readClipboardText: text.readClipboardText,
|
|
29
|
-
};
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
})();
|
|
34
|
-
|
|
35
|
-
return loaded;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function readClipboard(): Promise<string | null> {
|
|
39
|
-
const clipboard = await loadClipboard();
|
|
40
|
-
if (!clipboard) return null;
|
|
41
|
-
|
|
42
|
-
const image = await clipboard.readClipboardImage();
|
|
43
|
-
if (image) {
|
|
44
|
-
const extension = clipboard.extensionForImageMimeType(image.mimeType) ?? "png";
|
|
45
|
-
const path = join(tmpdir(), `pi-ask-permission-${randomUUID()}.${extension}`);
|
|
46
|
-
writeFileSync(path, Buffer.from(image.bytes));
|
|
47
|
-
return path;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return clipboard.readClipboardText();
|
|
51
|
-
}
|
package/src/ui/dialog.ts
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
import { renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
type Component,
|
|
4
|
-
type Focusable,
|
|
5
|
-
Input,
|
|
6
|
-
Key,
|
|
7
|
-
type KeybindingsManager,
|
|
8
|
-
matchesKey,
|
|
9
|
-
truncateToWidth,
|
|
10
|
-
visibleWidth,
|
|
11
|
-
wrapTextWithAnsi,
|
|
12
|
-
} from "@earendil-works/pi-tui";
|
|
13
|
-
|
|
14
|
-
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
15
|
-
import type { DialogAnswer } from "#core/answer.ts";
|
|
16
|
-
import type { CallDescriptor } from "#core/tools.ts";
|
|
17
|
-
import { readClipboard } from "#ui/clipboard.ts";
|
|
18
|
-
import { CHOICES, type Choice } from "#ui/decision-options.ts";
|
|
19
|
-
import {
|
|
20
|
-
cleanPaste,
|
|
21
|
-
expandPastes,
|
|
22
|
-
PASTE_END,
|
|
23
|
-
PASTE_START,
|
|
24
|
-
pasteMarker,
|
|
25
|
-
shouldCollapse,
|
|
26
|
-
} from "#ui/paste.ts";
|
|
27
|
-
|
|
28
|
-
type Phase = "menu" | "levels";
|
|
29
|
-
|
|
30
|
-
const TITLE_PREFIX = "\u256d\u2500 ";
|
|
31
|
-
const TITLE_SUFFIX = "\u256e";
|
|
32
|
-
// The 1 is the space between the title and its dashes.
|
|
33
|
-
const TITLE_CHROME_WIDTH = visibleWidth(TITLE_PREFIX) + 1 + visibleWidth(TITLE_SUFFIX);
|
|
34
|
-
|
|
35
|
-
const SUMMARY_ROWS = 3;
|
|
36
|
-
const DIFF_ROWS = 16;
|
|
37
|
-
|
|
38
|
-
interface AskDialogOptions {
|
|
39
|
-
theme: Theme;
|
|
40
|
-
toolName: string;
|
|
41
|
-
target: CallDescriptor;
|
|
42
|
-
diff?: string;
|
|
43
|
-
keybindings: KeybindingsManager;
|
|
44
|
-
requestRender: () => void;
|
|
45
|
-
complete: (answer: DialogAnswer) => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class AskDialog implements Component, Focusable {
|
|
49
|
-
private readonly theme: Theme;
|
|
50
|
-
private readonly toolName: string;
|
|
51
|
-
private readonly target: CallDescriptor;
|
|
52
|
-
private readonly diff: string | undefined;
|
|
53
|
-
private readonly requestRender: () => void;
|
|
54
|
-
private readonly complete: (answer: DialogAnswer) => void;
|
|
55
|
-
private readonly keybindings: KeybindingsManager;
|
|
56
|
-
|
|
57
|
-
private readonly noteInputs: Input[];
|
|
58
|
-
|
|
59
|
-
private readonly pastes = new Map<number, string>();
|
|
60
|
-
private pasteId = 0;
|
|
61
|
-
private pasteBuffer = "";
|
|
62
|
-
|
|
63
|
-
private phase: Phase = "menu";
|
|
64
|
-
private selected = 0;
|
|
65
|
-
private levelIndex = 0;
|
|
66
|
-
private scopeIndex = 0;
|
|
67
|
-
private pending: Choice | null = null;
|
|
68
|
-
private noteIndex: number | null = null;
|
|
69
|
-
|
|
70
|
-
private pendingNote: string | undefined;
|
|
71
|
-
|
|
72
|
-
private get activeNoteInput(): Input | undefined {
|
|
73
|
-
return this.noteIndex === null ? undefined : this.noteInputs[this.noteIndex];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private focusedFlag = false;
|
|
77
|
-
get focused(): boolean {
|
|
78
|
-
return this.focusedFlag;
|
|
79
|
-
}
|
|
80
|
-
set focused(value: boolean) {
|
|
81
|
-
this.focusedFlag = value;
|
|
82
|
-
for (const input of this.noteInputs) input.focused = value;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
constructor(options: AskDialogOptions) {
|
|
86
|
-
this.theme = options.theme;
|
|
87
|
-
this.toolName = options.toolName;
|
|
88
|
-
this.target = options.target;
|
|
89
|
-
this.diff = options.diff;
|
|
90
|
-
this.requestRender = options.requestRender;
|
|
91
|
-
this.complete = options.complete;
|
|
92
|
-
this.keybindings = options.keybindings;
|
|
93
|
-
|
|
94
|
-
this.noteInputs = [...CHOICES.keys()].map((index) => {
|
|
95
|
-
const input = new Input({ prompt: "", placeholder: "" });
|
|
96
|
-
input.onSubmit = () => this.choose(index);
|
|
97
|
-
input.onEscape = () => {
|
|
98
|
-
this.noteIndex = null;
|
|
99
|
-
};
|
|
100
|
-
return input;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
handleInput(data: string): void {
|
|
105
|
-
try {
|
|
106
|
-
this.dispatch(data);
|
|
107
|
-
} finally {
|
|
108
|
-
this.requestRender();
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
invalidate(): void {
|
|
113
|
-
for (const input of this.noteInputs) input.invalidate();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
render(width: number): string[] {
|
|
117
|
-
const inner = Math.max(1, width - 4);
|
|
118
|
-
const lines: string[] = [...this.summaryLines(inner), ...this.diffLines()];
|
|
119
|
-
lines.push("");
|
|
120
|
-
|
|
121
|
-
if (this.phase === "levels") {
|
|
122
|
-
lines.push(...this.levelLines());
|
|
123
|
-
} else {
|
|
124
|
-
for (const [index, option] of CHOICES.entries()) {
|
|
125
|
-
lines.push(this.renderOption(option, index, inner));
|
|
126
|
-
}
|
|
127
|
-
lines.push("");
|
|
128
|
-
lines.push(
|
|
129
|
-
this.theme.fg(
|
|
130
|
-
"dim",
|
|
131
|
-
this.noteIndex === null
|
|
132
|
-
? "\u2191\u2193 or 1-3 pick enter confirm tab note esc deny"
|
|
133
|
-
: "\u2191\u2193 pick enter confirm esc back",
|
|
134
|
-
),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return this.frame(lines, width, inner, `permission \u00b7 ${this.toolName}`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private dispatch(data: string): void {
|
|
142
|
-
if (this.phase === "menu" && this.noteIndex !== null) {
|
|
143
|
-
if (matchesKey(data, Key.up)) this.moveNote(-1);
|
|
144
|
-
else if (matchesKey(data, Key.down)) this.moveNote(1);
|
|
145
|
-
else if (matchesKey(data, Key.tab)) this.noteIndex = null;
|
|
146
|
-
else this.handleNoteInput(data);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (this.phase === "levels") {
|
|
151
|
-
if (matchesKey(data, Key.up)) this.levelIndex = Math.max(0, this.levelIndex - 1);
|
|
152
|
-
else if (matchesKey(data, Key.down))
|
|
153
|
-
this.levelIndex = Math.min(this.target.levels.length - 1, this.levelIndex + 1);
|
|
154
|
-
else if (matchesKey(data, Key.tab)) this.scopeIndex = (this.scopeIndex + 1) % SCOPES.length;
|
|
155
|
-
else if (matchesKey(data, Key.enter)) this.confirmLevel();
|
|
156
|
-
else if (matchesKey(data, Key.escape)) this.phase = "menu";
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (matchesKey(data, Key.up)) {
|
|
161
|
-
this.moveSelection(-1);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (matchesKey(data, Key.down)) {
|
|
165
|
-
this.moveSelection(1);
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (matchesKey(data, Key.tab)) {
|
|
169
|
-
this.noteIndex = this.selected;
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (matchesKey(data, Key.enter)) {
|
|
173
|
-
this.choose(this.selected);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (matchesKey(data, Key.escape)) {
|
|
177
|
-
this.complete({ decision: "deny" });
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const picked = CHOICES.findIndex((option) => option.key === data);
|
|
182
|
-
if (picked >= 0) this.selected = picked;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private moveSelection(delta: number): void {
|
|
186
|
-
const count = CHOICES.length;
|
|
187
|
-
this.selected = (this.selected + delta + count) % count;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private moveNote(delta: number): void {
|
|
191
|
-
if (this.noteIndex === null) return;
|
|
192
|
-
this.moveSelection(delta);
|
|
193
|
-
this.noteIndex = this.selected;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
private choose(index: number): void {
|
|
197
|
-
const option = CHOICES[index];
|
|
198
|
-
if (!option) return;
|
|
199
|
-
this.selected = index;
|
|
200
|
-
|
|
201
|
-
if (option.always) {
|
|
202
|
-
this.pending = option;
|
|
203
|
-
this.pendingNote = this.draftNote();
|
|
204
|
-
this.noteIndex = null;
|
|
205
|
-
this.levelIndex = this.target.levels.length - 1;
|
|
206
|
-
this.scopeIndex = 0;
|
|
207
|
-
this.phase = "levels";
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
this.complete({
|
|
212
|
-
decision: option.decision,
|
|
213
|
-
note: this.draftNote(),
|
|
214
|
-
remember: option.always ? this.target.levels[0] : undefined,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private confirmLevel(): void {
|
|
219
|
-
const level = this.target.levels[this.levelIndex];
|
|
220
|
-
if (!this.pending || level === undefined) return;
|
|
221
|
-
|
|
222
|
-
this.complete({
|
|
223
|
-
decision: this.pending.decision,
|
|
224
|
-
note: this.pendingNote,
|
|
225
|
-
remember: level,
|
|
226
|
-
scope: this.currentScope,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private get currentScope(): Scope {
|
|
231
|
-
return SCOPES[this.scopeIndex] ?? "session";
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private draftNote(): string | undefined {
|
|
235
|
-
const input = this.activeNoteInput;
|
|
236
|
-
if (!input) return undefined;
|
|
237
|
-
|
|
238
|
-
const note = expandPastes(input.getValue(), this.pastes).trim();
|
|
239
|
-
return note || undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private handleNoteInput(data: string): void {
|
|
243
|
-
if (this.pasteBuffer !== "" || data.includes(PASTE_START)) {
|
|
244
|
-
this.bufferPaste(data);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
|
249
|
-
this.pasteClipboard();
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
this.activeNoteInput?.handleInput(data);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private bufferPaste(data: string): void {
|
|
257
|
-
const start = data.indexOf(PASTE_START);
|
|
258
|
-
const text = this.pasteBuffer + (start === -1 ? data : data.slice(start + PASTE_START.length));
|
|
259
|
-
const end = text.indexOf(PASTE_END);
|
|
260
|
-
|
|
261
|
-
if (end === -1) {
|
|
262
|
-
this.pasteBuffer = text;
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
this.pasteBuffer = "";
|
|
267
|
-
const index = this.noteIndex;
|
|
268
|
-
if (index !== null) this.insertPaste(index, text.slice(0, end));
|
|
269
|
-
|
|
270
|
-
const rest = text.slice(end + PASTE_END.length);
|
|
271
|
-
if (rest) this.handleNoteInput(rest);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private pasteClipboard(): void {
|
|
275
|
-
const index = this.noteIndex;
|
|
276
|
-
if (index === null) return;
|
|
277
|
-
|
|
278
|
-
void readClipboard().then((text) => {
|
|
279
|
-
if (!text) return;
|
|
280
|
-
this.insertPaste(index, text);
|
|
281
|
-
this.requestRender();
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
private insertPaste(index: number, pastedText: string): void {
|
|
286
|
-
const input = this.noteInputs[index];
|
|
287
|
-
if (!input) return;
|
|
288
|
-
|
|
289
|
-
const text = cleanPaste(pastedText);
|
|
290
|
-
if (shouldCollapse(text)) {
|
|
291
|
-
this.pasteId++;
|
|
292
|
-
this.pastes.set(this.pasteId, text);
|
|
293
|
-
input.handleInput(pasteMarker(this.pasteId, text));
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const spacer = /^[/~.]/.test(text) && /\w$/.test(input.getValue()) ? " " : "";
|
|
298
|
-
input.handleInput(spacer + text);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private levelLines(): string[] {
|
|
302
|
-
const lines = [this.theme.fg("muted", "always yes for...")];
|
|
303
|
-
|
|
304
|
-
for (const [index, level] of this.target.levels.entries()) {
|
|
305
|
-
const active = index === this.levelIndex;
|
|
306
|
-
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
307
|
-
lines.push(marker + this.theme.fg(active ? "accent" : "text", level));
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (this.pendingNote) lines.push(this.theme.fg("muted", `note: ${this.pendingNote}`));
|
|
311
|
-
lines.push("");
|
|
312
|
-
lines.push(
|
|
313
|
-
this.theme.fg("muted", "scope: ") +
|
|
314
|
-
this.theme.fg("accent", SCOPE_LABEL[this.currentScope]) +
|
|
315
|
-
this.theme.fg("dim", " (tab to change)"),
|
|
316
|
-
);
|
|
317
|
-
lines.push("");
|
|
318
|
-
lines.push(this.theme.fg("dim", "\u2191\u2193 depth tab scope enter confirm esc back"));
|
|
319
|
-
return lines;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private renderOption(option: Choice, index: number, inner: number): string {
|
|
323
|
-
const active = index === this.selected;
|
|
324
|
-
const editing = this.noteIndex === index;
|
|
325
|
-
const input = this.noteInputs[index];
|
|
326
|
-
const draft = input?.getValue().trim() ?? "";
|
|
327
|
-
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
328
|
-
const key = this.theme.fg(active ? "accent" : "dim", option.key);
|
|
329
|
-
const prefix = `${marker}${key} `;
|
|
330
|
-
|
|
331
|
-
if (!editing && !draft) {
|
|
332
|
-
return `${prefix}${this.theme.fg(active ? option.tone : "text", option.label)}`;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const room = Math.max(1, inner - visibleWidth(prefix));
|
|
336
|
-
const label = truncateToWidth(`${option.label}, `, room);
|
|
337
|
-
const noteRoom = Math.max(1, room - visibleWidth(label));
|
|
338
|
-
const note = editing
|
|
339
|
-
? (input?.render(noteRoom)[0] ?? "")
|
|
340
|
-
: this.theme.fg("dim", truncateToWidth(draft, noteRoom));
|
|
341
|
-
const head = this.theme.fg(active ? option.tone : "text", label);
|
|
342
|
-
return truncateToWidth(`${prefix}${head}${note}`, inner);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
private diffLines(): string[] {
|
|
346
|
-
if (!this.diff) return [];
|
|
347
|
-
|
|
348
|
-
const lines = renderDiff(this.diff).split("\n");
|
|
349
|
-
const shown = lines.slice(0, DIFF_ROWS);
|
|
350
|
-
const hidden = lines.length - shown.length;
|
|
351
|
-
if (hidden > 0) shown.push(this.theme.fg("dim", `\u2026 ${hidden} more lines`));
|
|
352
|
-
return ["", ...shown];
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private summaryLines(inner: number): string[] {
|
|
356
|
-
const wrapped = wrapTextWithAnsi(this.target.summary || "(no input)", inner);
|
|
357
|
-
const shown = wrapped.slice(0, SUMMARY_ROWS);
|
|
358
|
-
const elideLast = wrapped.length > shown.length;
|
|
359
|
-
|
|
360
|
-
return shown.map((line, index) => {
|
|
361
|
-
const text =
|
|
362
|
-
elideLast && index === shown.length - 1
|
|
363
|
-
? `${truncateToWidth(line, Math.max(0, inner - 3))}...`
|
|
364
|
-
: line;
|
|
365
|
-
return this.theme.fg("muted", text);
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
private frame(lines: string[], width: number, inner: number, title: string): string[] {
|
|
370
|
-
const out: string[] = [this.topBorder(width, title)];
|
|
371
|
-
|
|
372
|
-
for (const line of lines) {
|
|
373
|
-
const clipped = truncateToWidth(line, inner);
|
|
374
|
-
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
375
|
-
out.push(
|
|
376
|
-
`${this.theme.fg("border", "\u2502")} ${clipped}${pad} ${this.theme.fg("border", "\u2502")}`,
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
out.push(this.theme.fg("border", `\u2570${"\u2500".repeat(Math.max(0, width - 2))}\u256f`));
|
|
381
|
-
return out;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
private topBorder(width: number, title: string): string {
|
|
385
|
-
const room = Math.max(0, width - TITLE_CHROME_WIDTH);
|
|
386
|
-
const label = this.theme.fg("accent", truncateToWidth(title, Math.max(0, room - 3), "..."));
|
|
387
|
-
const dashes = Math.max(0, room - visibleWidth(label));
|
|
388
|
-
|
|
389
|
-
return (
|
|
390
|
-
this.theme.fg("border", TITLE_PREFIX) +
|
|
391
|
-
label +
|
|
392
|
-
this.theme.fg("border", ` ${"\u2500".repeat(dashes)}${TITLE_SUFFIX}`)
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
}
|
package/src/ui/paste.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
export const PASTE_START = "\x1b[200~";
|
|
2
|
-
export const PASTE_END = "\x1b[201~";
|
|
3
|
-
|
|
4
|
-
const MARKER = /\[paste #(\d+)(?:\s[^\]]*)?\]/g;
|
|
5
|
-
const MAX_CHARS = 1000;
|
|
6
|
-
|
|
7
|
-
export function cleanPaste(text: string): string {
|
|
8
|
-
return text
|
|
9
|
-
.replace(/\r\n?/g, "\n")
|
|
10
|
-
.replace(/\t/g, " ")
|
|
11
|
-
.split("")
|
|
12
|
-
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
13
|
-
.join("");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function shouldCollapse(text: string): boolean {
|
|
17
|
-
return text.includes("\n") || text.length > MAX_CHARS;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function pasteMarker(id: number, text: string): string {
|
|
21
|
-
const lines = text.split("\n").length;
|
|
22
|
-
return lines > 1 ? `[paste #${id} +${lines} lines]` : `[paste #${id} ${text.length} chars]`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function expandPastes(text: string, pastes: Map<number, string>): string {
|
|
26
|
-
return text.replace(MARKER, (match, id: string) => pastes.get(Number(id)) ?? match);
|
|
27
|
-
}
|