@hank-warren/pi-auto-permissions 0.10.0 → 0.11.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/CHANGELOG.md +33 -0
- package/README.md +15 -1
- package/config-writer.ts +107 -0
- package/config.ts +43 -11
- package/index.ts +128 -3
- package/package.json +4 -2
- package/review.ts +9 -1
- package/settings-menu.ts +438 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @hank-warren/pi-auto-permissions
|
|
2
2
|
|
|
3
|
+
## 0.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3fbf632: Stop evidence truncation from re-appending the whole record. With a per-record cap of 1-3 the head share rounded up to the entire budget, leaving a zero-length tail — and `slice(-0)` is `slice(0)`, so the "truncated" record was longer than its input and carried an elision marker for text the reviewer could still read. Caps of 4 and above, including every shipped default, are unaffected.
|
|
8
|
+
- Updated dependencies [3fbf632]
|
|
9
|
+
- @hank-warren/pi-permission-selector@1.3.0
|
|
10
|
+
|
|
11
|
+
## 0.11.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- f93481e: add `/auto-permissions`, a settings menu for the reviewer configuration
|
|
16
|
+
|
|
17
|
+
The reviewer model, thinking level and timeout — plus the `enabled` flag — were
|
|
18
|
+
hand-edited JSON. They are the settings that change most often, and switching a
|
|
19
|
+
reviewer meant opening `~/.pi/agent/pi-auto-permissions/config.json` and getting
|
|
20
|
+
a provider id exactly right by memory.
|
|
21
|
+
|
|
22
|
+
`/auto-permissions` puts those four on a settings list, with the model picked
|
|
23
|
+
from the models you are actually signed in to (a configured but unavailable
|
|
24
|
+
reviewer is pinned first so opening the menu can never silently drop it). A
|
|
25
|
+
fifth read-only row reports where the active system prompt comes from — the
|
|
26
|
+
resolved `systemPromptFile` path, or the built-in or inline prompt.
|
|
27
|
+
|
|
28
|
+
The menu is a narrow, merging writer: it patches only `enabled` and `reviewer`
|
|
29
|
+
into whatever is on disk at save time, preserving rules, prompts, evidence
|
|
30
|
+
settings, log paths, unknown keys and the file's own indentation, so a
|
|
31
|
+
concurrent hand-edit survives. A config that fails validation is reported and
|
|
32
|
+
never rewritten. No restart is involved — the config is already re-read on every
|
|
33
|
+
guarded command, and the reviewer fingerprint already covers the model and
|
|
34
|
+
reasoning effort, so a save takes effect on the next command in every session.
|
|
35
|
+
|
|
3
36
|
## 0.10.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ By default, the guardian uses Pi's active model with low reasoning effort and a
|
|
|
119
119
|
Giving the guardian its own account keeps reviews from competing with your interactive session for a subscription's rate limits. Pi keys OAuth credentials by provider id, so a second login needs a second provider id — which is what [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) exists to create. This package no longer registers one itself.
|
|
120
120
|
|
|
121
121
|
1. `pi install npm:@hank-warren/pi-multi-login`
|
|
122
|
-
2. Run `/multi-login
|
|
122
|
+
2. Run `/multi-login` and add a login with base `openai-codex` and suffix `auto-permissions`; then run `/login`, pick the new provider, and complete OAuth with the account reserved for guardian reviews.
|
|
123
123
|
3. Select the dedicated provider in the Auto Permissions config:
|
|
124
124
|
|
|
125
125
|
```json
|
|
@@ -139,6 +139,20 @@ If you already used the login this package registered in earlier versions, nothi
|
|
|
139
139
|
|
|
140
140
|
Other reviewer providers continue to work by setting their normal provider and model ids.
|
|
141
141
|
|
|
142
|
+
### `/auto-permissions`
|
|
143
|
+
|
|
144
|
+
The reviewer settings that change most often are editable from a settings menu instead of the config file:
|
|
145
|
+
|
|
146
|
+
| Row | What it edits |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| Enabled | `enabled` — off lets every command run without guardian review |
|
|
149
|
+
| Reviewer model | `reviewer.provider` / `reviewer.model`, picked from the models you are signed in to |
|
|
150
|
+
| Thinking level | `reviewer.reasoningEffort` |
|
|
151
|
+
| Review timeout | `reviewer.timeoutMs`, entered as `30s` or `45000ms` |
|
|
152
|
+
| System prompt | read-only: the resolved path of the active `systemPromptFile`, or whether the built-in or an inline prompt is in use |
|
|
153
|
+
|
|
154
|
+
Saves are applied immediately — the config is re-read on every guarded command, so there is nothing to restart, in this session or any other. The menu is a narrow writer: it merges only the keys above into whatever is on disk, so rules, prompts, evidence settings and log paths stay exactly as you wrote them and remain file-only. A config that fails validation is never rewritten; the menu reports the error and refuses to open.
|
|
155
|
+
|
|
142
156
|
### Guardian prompt
|
|
143
157
|
|
|
144
158
|
The bundled prompt evaluates authorization, command risk, and compliance with user constraints. Replace it inline with `systemPrompt`, or load a file:
|
package/config-writer.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow, merging writer for the auto-permissions config file.
|
|
3
|
+
*
|
|
4
|
+
* The config is hand-maintained: rules, the system prompt, evidence knobs and
|
|
5
|
+
* log paths all live in the same JSON object the `/auto-permissions` menu
|
|
6
|
+
* edits. So this writer never serializes a whole config snapshot -- it re-reads
|
|
7
|
+
* the file, patches only the keys one edit touched, and writes the result back
|
|
8
|
+
* atomically. Anything it does not know about (including keys a future version
|
|
9
|
+
* adds, and edits another session made a second ago) survives untouched.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { pid } from "node:process";
|
|
14
|
+
import { autoPermissionsConfigPath, type ReasoningEffort } from "./config.js";
|
|
15
|
+
|
|
16
|
+
export { autoPermissionsConfigPath };
|
|
17
|
+
|
|
18
|
+
/** The subset of the config the settings UI owns. */
|
|
19
|
+
export interface ReviewerBlock {
|
|
20
|
+
provider: string;
|
|
21
|
+
model: string;
|
|
22
|
+
reasoningEffort: ReasoningEffort;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConfigPatch {
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
reviewer?: ReviewerBlock;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_INDENT = " ";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Keep the file looking like the human left it. Only the first indented line
|
|
35
|
+
* matters: JSON.stringify takes a single indent string for the whole document.
|
|
36
|
+
*/
|
|
37
|
+
export function detectIndent(text: string): string {
|
|
38
|
+
const match = /\n([ \t]+)\S/.exec(text);
|
|
39
|
+
if (!match) return DEFAULT_INDENT;
|
|
40
|
+
const indent = match[1] ?? DEFAULT_INDENT;
|
|
41
|
+
return indent.startsWith("\t") ? "\t" : indent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readText(path: string): string {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(path, "utf8");
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseObject(text: string): Record<string, unknown> {
|
|
54
|
+
if (!text.trim()) return {};
|
|
55
|
+
const parsed: unknown = JSON.parse(text);
|
|
56
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
57
|
+
throw new Error("auto permissions config must be a JSON object");
|
|
58
|
+
}
|
|
59
|
+
return parsed as Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply `patch` to the object currently on disk and rewrite the file.
|
|
64
|
+
*
|
|
65
|
+
* Sparse `enabled`: the loader reads a missing key as enabled, so turning the
|
|
66
|
+
* guardian back on deletes the key rather than writing `true`. That keeps a
|
|
67
|
+
* default flip reaching hosts that never touched the setting, matching how
|
|
68
|
+
* pi-statusline serializes its settings.
|
|
69
|
+
*
|
|
70
|
+
* Throws on an unparsable or non-object file rather than clobbering it; the
|
|
71
|
+
* caller surfaces that as a notification.
|
|
72
|
+
*/
|
|
73
|
+
export function patchAutoPermissionsConfig(
|
|
74
|
+
path: string = autoPermissionsConfigPath(),
|
|
75
|
+
patch: ConfigPatch = {},
|
|
76
|
+
): void {
|
|
77
|
+
const existing = readText(path);
|
|
78
|
+
const merged = parseObject(existing);
|
|
79
|
+
|
|
80
|
+
if (patch.enabled !== undefined) {
|
|
81
|
+
if (patch.enabled) delete merged.enabled;
|
|
82
|
+
else merged.enabled = false;
|
|
83
|
+
}
|
|
84
|
+
if (patch.reviewer !== undefined) {
|
|
85
|
+
merged.reviewer = {
|
|
86
|
+
provider: patch.reviewer.provider,
|
|
87
|
+
model: patch.reviewer.model,
|
|
88
|
+
reasoningEffort: patch.reviewer.reasoningEffort,
|
|
89
|
+
timeoutMs: patch.reviewer.timeoutMs,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
|
|
94
|
+
const temporary = `${path}.${pid}.tmp`;
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
try {
|
|
97
|
+
writeFileSync(temporary, payload, { mode: 0o600 });
|
|
98
|
+
renameSync(temporary, path);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(temporary);
|
|
102
|
+
} catch {
|
|
103
|
+
// The temp file may never have been created; nothing to clean up.
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/config.ts
CHANGED
|
@@ -7,9 +7,25 @@ import { AUTO_PERMISSIONS_SYSTEM_PROMPT } from "./review.js";
|
|
|
7
7
|
|
|
8
8
|
export const CONFIG_FILENAME = "pi-auto-permissions/config.json";
|
|
9
9
|
|
|
10
|
-
const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
10
|
+
export const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
11
11
|
export type ReasoningEffort = typeof REASONING_EFFORTS[number];
|
|
12
12
|
|
|
13
|
+
/** Loader defaults for an incomplete `reviewer` block, shared with the settings UI. */
|
|
14
|
+
export const DEFAULT_REVIEWER_REASONING_EFFORT: ReasoningEffort = "low";
|
|
15
|
+
export const DEFAULT_REVIEWER_TIMEOUT_MS = 30_000;
|
|
16
|
+
export const MIN_REVIEWER_TIMEOUT_MS = 1_000;
|
|
17
|
+
export const MAX_REVIEWER_TIMEOUT_MS = 300_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where the active reviewer system prompt came from. The settings UI shows this
|
|
21
|
+
* (read-only) so the resolved `systemPromptFile` path is visible without having
|
|
22
|
+
* to re-derive the loader's relative-path resolution by hand.
|
|
23
|
+
*/
|
|
24
|
+
export type SystemPromptSource =
|
|
25
|
+
| { kind: "builtin" }
|
|
26
|
+
| { kind: "inline" }
|
|
27
|
+
| { kind: "file"; path: string };
|
|
28
|
+
|
|
13
29
|
export interface AutoPermissionsConfig {
|
|
14
30
|
enabled: boolean;
|
|
15
31
|
reviewer?: {
|
|
@@ -19,6 +35,7 @@ export interface AutoPermissionsConfig {
|
|
|
19
35
|
timeoutMs: number;
|
|
20
36
|
};
|
|
21
37
|
systemPrompt: string;
|
|
38
|
+
systemPromptSource: SystemPromptSource;
|
|
22
39
|
reviewEvidence: {
|
|
23
40
|
projectInstructions: boolean;
|
|
24
41
|
userAnswerTools: string[];
|
|
@@ -54,7 +71,12 @@ interface RuleInput {
|
|
|
54
71
|
message?: unknown;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The single config location, honouring the `PI_AUTO_PERMISSIONS_CONFIG`
|
|
76
|
+
* override. Exported so the settings writer can never target a different file
|
|
77
|
+
* than the loader reads.
|
|
78
|
+
*/
|
|
79
|
+
export function autoPermissionsConfigPath(): string {
|
|
58
80
|
return process.env.PI_AUTO_PERMISSIONS_CONFIG
|
|
59
81
|
? resolve(process.env.PI_AUTO_PERMISSIONS_CONFIG)
|
|
60
82
|
: join(getAgentDir(), CONFIG_FILENAME);
|
|
@@ -102,18 +124,21 @@ function compileRule(value: unknown, index: number): Gate {
|
|
|
102
124
|
};
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
function resolvePrompt(
|
|
127
|
+
function resolvePrompt(
|
|
128
|
+
raw: Record<string, unknown>,
|
|
129
|
+
path: string,
|
|
130
|
+
): { prompt: string; source: SystemPromptSource } {
|
|
106
131
|
const inline = optionalString(raw.systemPrompt, "systemPrompt");
|
|
107
132
|
const file = optionalString(raw.systemPromptFile, "systemPromptFile");
|
|
108
133
|
if (inline && file) throw new Error("set only one of systemPrompt and systemPromptFile");
|
|
109
|
-
if (inline) return inline;
|
|
110
|
-
if (!file) return AUTO_PERMISSIONS_SYSTEM_PROMPT;
|
|
134
|
+
if (inline) return { prompt: inline, source: { kind: "inline" } };
|
|
135
|
+
if (!file) return { prompt: AUTO_PERMISSIONS_SYSTEM_PROMPT, source: { kind: "builtin" } };
|
|
111
136
|
|
|
112
137
|
const expanded = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
|
|
113
138
|
const resolved = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
|
|
114
139
|
const prompt = readFileSync(resolved, "utf8").trim();
|
|
115
140
|
if (!prompt) throw new Error("systemPromptFile is empty");
|
|
116
|
-
return prompt;
|
|
141
|
+
return { prompt, source: { kind: "file", path: resolved } };
|
|
117
142
|
}
|
|
118
143
|
|
|
119
144
|
const EVIDENCE_PRUNING_DEFAULTS = {
|
|
@@ -228,27 +253,34 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
|
|
|
228
253
|
const provider = optionalString(reviewer.provider, "reviewer.provider");
|
|
229
254
|
const model = optionalString(reviewer.model, "reviewer.model");
|
|
230
255
|
if (!provider || !model) throw new Error("reviewer requires both provider and model");
|
|
231
|
-
const reasoningEffort = (optionalString(reviewer.reasoningEffort, "reviewer.reasoningEffort")
|
|
256
|
+
const reasoningEffort = (optionalString(reviewer.reasoningEffort, "reviewer.reasoningEffort")
|
|
257
|
+
?? DEFAULT_REVIEWER_REASONING_EFFORT) as ReasoningEffort;
|
|
232
258
|
if (!REASONING_EFFORTS.includes(reasoningEffort)) {
|
|
233
259
|
throw new Error("reviewer.reasoningEffort is invalid");
|
|
234
260
|
}
|
|
235
|
-
const timeoutMs = reviewer.timeoutMs === undefined ?
|
|
236
|
-
if (
|
|
261
|
+
const timeoutMs = reviewer.timeoutMs === undefined ? DEFAULT_REVIEWER_TIMEOUT_MS : reviewer.timeoutMs;
|
|
262
|
+
if (
|
|
263
|
+
!Number.isInteger(timeoutMs)
|
|
264
|
+
|| Number(timeoutMs) < MIN_REVIEWER_TIMEOUT_MS
|
|
265
|
+
|| Number(timeoutMs) > MAX_REVIEWER_TIMEOUT_MS
|
|
266
|
+
) {
|
|
237
267
|
throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
|
|
238
268
|
}
|
|
239
269
|
return { provider, model, reasoningEffort, timeoutMs: Number(timeoutMs) };
|
|
240
270
|
}
|
|
241
271
|
|
|
242
|
-
export function loadAutoPermissionsConfig(path =
|
|
272
|
+
export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
|
|
243
273
|
const raw = readObject(path);
|
|
244
274
|
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
|
|
245
275
|
if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
|
|
246
276
|
const customRules = (raw.rules ?? []).map(compileRule);
|
|
277
|
+
const prompt = resolvePrompt(raw, path);
|
|
247
278
|
|
|
248
279
|
return {
|
|
249
280
|
enabled: raw.enabled !== false,
|
|
250
281
|
reviewer: resolveReviewer(raw),
|
|
251
|
-
systemPrompt:
|
|
282
|
+
systemPrompt: prompt.prompt,
|
|
283
|
+
systemPromptSource: prompt.source,
|
|
252
284
|
reviewEvidence: resolveReviewEvidence(raw),
|
|
253
285
|
evaluationLog: resolveEvaluationLog(raw, path),
|
|
254
286
|
usageLog: resolveUsageLog(raw, path),
|
package/index.ts
CHANGED
|
@@ -3,13 +3,33 @@ import { resolveGuardianCompleteSimple } from "./guardian-transport.js";
|
|
|
3
3
|
import { isOpenAICodexModel } from "./openai-codex-transport.js";
|
|
4
4
|
import { detectSubagentContext } from "./subagent-context.js";
|
|
5
5
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
getSelectListTheme,
|
|
8
|
+
getSettingsListTheme,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
12
|
import { Type } from "typebox";
|
|
8
|
-
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { Container, SettingsList, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
14
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
10
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
16
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
autoPermissionsConfigPath,
|
|
19
|
+
loadAutoPermissionsConfig,
|
|
20
|
+
type AutoPermissionsConfig,
|
|
21
|
+
} from "./config.js";
|
|
22
|
+
import { patchAutoPermissionsConfig } from "./config-writer.js";
|
|
23
|
+
import {
|
|
24
|
+
applySettingChange,
|
|
25
|
+
buildSettingItems,
|
|
26
|
+
createModelSubmenu,
|
|
27
|
+
createTimeoutSubmenu,
|
|
28
|
+
type MenuModel,
|
|
29
|
+
type ReviewerSettings,
|
|
30
|
+
type SettingChange,
|
|
31
|
+
type SubmenuHost,
|
|
32
|
+
} from "./settings-menu.js";
|
|
13
33
|
import {
|
|
14
34
|
appendPromptEvaluation,
|
|
15
35
|
classifyPromptChoice,
|
|
@@ -1005,6 +1025,111 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
1005
1025
|
},
|
|
1006
1026
|
});
|
|
1007
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* `/auto-permissions`: edit the reviewer model, thinking level, timeout and
|
|
1030
|
+
* the enabled flag without hand-editing config.json.
|
|
1031
|
+
*
|
|
1032
|
+
* There is no live-apply step: every tool call re-reads the config through
|
|
1033
|
+
* currentConfig(), so a saved edit is in force immediately -- in this session
|
|
1034
|
+
* and in every other one. reviewerFingerprint() covers provider, model and
|
|
1035
|
+
* reasoning effort, so the cached reviewer lineage self-invalidates too.
|
|
1036
|
+
*/
|
|
1037
|
+
pi.registerCommand("auto-permissions", {
|
|
1038
|
+
description: "Configure Auto Permissions",
|
|
1039
|
+
handler: async (_args, ctx) => {
|
|
1040
|
+
if (ctx.mode !== "tui") {
|
|
1041
|
+
ctx.ui.notify("/auto-permissions requires interactive TUI mode", "warning");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const path = autoPermissionsConfigPath();
|
|
1046
|
+
let settings: ReviewerSettings;
|
|
1047
|
+
try {
|
|
1048
|
+
const config = loadAutoPermissionsConfig(path);
|
|
1049
|
+
settings = {
|
|
1050
|
+
enabled: config.enabled,
|
|
1051
|
+
reviewer: config.reviewer,
|
|
1052
|
+
systemPromptSource: config.systemPromptSource,
|
|
1053
|
+
};
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
// Editing a file we cannot validate is the one way this writer could
|
|
1056
|
+
// quietly make things worse, so refuse rather than open.
|
|
1057
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1058
|
+
ctx.ui.notify(`Auto Permissions config error: ${message} \u2014 fix ${path} first`, "warning");
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Persist one accepted edit, then report anything the save surfaced. */
|
|
1063
|
+
const commit = (change: SettingChange): void => {
|
|
1064
|
+
if (change.kind === "ignored") return;
|
|
1065
|
+
if (change.kind === "error") {
|
|
1066
|
+
ctx.ui.notify(change.message, "warning");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const previous = settings;
|
|
1070
|
+
settings = change.settings;
|
|
1071
|
+
try {
|
|
1072
|
+
patchAutoPermissionsConfig(path, { enabled: settings.enabled, reviewer: settings.reviewer });
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
settings = previous;
|
|
1075
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1076
|
+
ctx.ui.notify(`Could not save Auto Permissions settings: ${message}`, "warning");
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (change.kind === "warn") ctx.ui.notify(change.message, "warning");
|
|
1080
|
+
// A breakage elsewhere in the file would otherwise surface at the next
|
|
1081
|
+
// bash call, looking like this edit caused it.
|
|
1082
|
+
try {
|
|
1083
|
+
loadAutoPermissionsConfig(path);
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1086
|
+
ctx.ui.notify(`Auto Permissions config error: ${message}`, "warning");
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const reviewerModel = (): MenuModel | undefined => {
|
|
1091
|
+
if (!settings.reviewer) return undefined;
|
|
1092
|
+
return ctx.modelRegistry.find(settings.reviewer.provider, settings.reviewer.model) as MenuModel | undefined;
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
|
1096
|
+
const host: SubmenuHost = {
|
|
1097
|
+
getSettings: () => settings,
|
|
1098
|
+
commit,
|
|
1099
|
+
availableModels: () => ctx.modelRegistry.getAvailable() as MenuModel[],
|
|
1100
|
+
requestRender: () => tui.requestRender(),
|
|
1101
|
+
settingsTheme: getSettingsListTheme(),
|
|
1102
|
+
selectTheme: getSelectListTheme(),
|
|
1103
|
+
};
|
|
1104
|
+
const list = new SettingsList(
|
|
1105
|
+
buildSettingItems(settings, {
|
|
1106
|
+
reviewerModel: createModelSubmenu(host),
|
|
1107
|
+
timeout: createTimeoutSubmenu(host),
|
|
1108
|
+
}),
|
|
1109
|
+
10,
|
|
1110
|
+
host.settingsTheme,
|
|
1111
|
+
(id, value) => {
|
|
1112
|
+
commit(applySettingChange(settings, id, value, reviewerModel()));
|
|
1113
|
+
tui.requestRender();
|
|
1114
|
+
},
|
|
1115
|
+
() => done(undefined),
|
|
1116
|
+
{ enableSearch: true },
|
|
1117
|
+
);
|
|
1118
|
+
return {
|
|
1119
|
+
invalidate(): void {
|
|
1120
|
+
list.invalidate();
|
|
1121
|
+
},
|
|
1122
|
+
handleInput(data: string): void {
|
|
1123
|
+
list.handleInput(data);
|
|
1124
|
+
},
|
|
1125
|
+
render(width: number): string[] {
|
|
1126
|
+
return list.render(width);
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
});
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1008
1133
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1009
1134
|
sessionActive = false;
|
|
1010
1135
|
reviewerLifecycleController.abort();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"index.ts",
|
|
42
42
|
"approval-pulse.ts",
|
|
43
43
|
"config.ts",
|
|
44
|
+
"config-writer.ts",
|
|
45
|
+
"settings-menu.ts",
|
|
44
46
|
"guardian-transport.ts",
|
|
45
47
|
"openai-codex-transport.ts",
|
|
46
48
|
"subagent-context.ts",
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
"CHANGELOG.md"
|
|
56
58
|
],
|
|
57
59
|
"dependencies": {
|
|
58
|
-
"@hank-warren/pi-permission-selector": "^1.
|
|
60
|
+
"@hank-warren/pi-permission-selector": "^1.3.0"
|
|
59
61
|
},
|
|
60
62
|
"peerDependencies": {
|
|
61
63
|
"@earendil-works/pi-ai": "*",
|
package/review.ts
CHANGED
|
@@ -46,12 +46,20 @@ const TRUNCATION_HEAD_SHARE = 0.7;
|
|
|
46
46
|
* the final segment of command chains, so both are preserved; the marker
|
|
47
47
|
* tells the reviewer content is elided so it can never treat truncation as
|
|
48
48
|
* proof of absence.
|
|
49
|
+
*
|
|
50
|
+
* A cap small enough that the head share rounds up to the whole budget leaves
|
|
51
|
+
* no tail. That case must drop the tail segment entirely rather than emit
|
|
52
|
+
* `slice(-0)`, which is `slice(0)` and would splice the *entire* record back
|
|
53
|
+
* in behind a marker claiming it had been elided — output longer than the
|
|
54
|
+
* input, and a marker the reviewer is told to trust.
|
|
49
55
|
*/
|
|
50
56
|
export function truncateEvidenceText(text: string, maxChars: number): string {
|
|
51
57
|
if (maxChars <= 0 || text.length <= maxChars) return text;
|
|
52
58
|
const head = Math.ceil(maxChars * TRUNCATION_HEAD_SHARE);
|
|
53
59
|
const tail = maxChars - head;
|
|
54
|
-
|
|
60
|
+
const marker = `\n…[truncated ${text.length - maxChars} chars]…`;
|
|
61
|
+
if (tail <= 0) return `${text.slice(0, head)}${marker}`;
|
|
62
|
+
return `${text.slice(0, head)}${marker}\n${text.slice(-tail)}`;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
const COLLAPSED_TOOL_PATTERN = /^TOOL (\S+)/;
|
package/settings-menu.ts
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row model and pure edit logic behind `/auto-permissions`.
|
|
3
|
+
*
|
|
4
|
+
* Everything that decides *what* a row shows or what an edit means lives here,
|
|
5
|
+
* free of pi's runtime, so it is testable without a TUI. index.ts owns only the
|
|
6
|
+
* wiring: read config, render a SettingsList, persist through config-writer.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import {
|
|
10
|
+
type Component,
|
|
11
|
+
getKeybindings,
|
|
12
|
+
Input,
|
|
13
|
+
type SelectItem,
|
|
14
|
+
SelectList,
|
|
15
|
+
type SelectListTheme,
|
|
16
|
+
type SettingItem,
|
|
17
|
+
type SettingsListTheme,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
} from "@earendil-works/pi-tui";
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_REVIEWER_REASONING_EFFORT,
|
|
22
|
+
DEFAULT_REVIEWER_TIMEOUT_MS,
|
|
23
|
+
MAX_REVIEWER_TIMEOUT_MS,
|
|
24
|
+
MIN_REVIEWER_TIMEOUT_MS,
|
|
25
|
+
REASONING_EFFORTS,
|
|
26
|
+
type ReasoningEffort,
|
|
27
|
+
type SystemPromptSource,
|
|
28
|
+
} from "./config.js";
|
|
29
|
+
import type { ReviewerBlock } from "./config-writer.js";
|
|
30
|
+
|
|
31
|
+
export const ENABLED_ID = "enabled";
|
|
32
|
+
export const REVIEWER_MODEL_ID = "reviewerModel";
|
|
33
|
+
export const THINKING_LEVEL_ID = "reasoningEffort";
|
|
34
|
+
export const TIMEOUT_ID = "timeoutMs";
|
|
35
|
+
export const SYSTEM_PROMPT_ID = "systemPrompt";
|
|
36
|
+
|
|
37
|
+
export const ON = "on";
|
|
38
|
+
export const OFF = "off";
|
|
39
|
+
const TOGGLE_VALUES = [ON, OFF];
|
|
40
|
+
|
|
41
|
+
export const UNSET_MODEL = "(unset)";
|
|
42
|
+
const UNAVAILABLE_SUFFIX = "(configured, unavailable)";
|
|
43
|
+
const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
|
|
44
|
+
|
|
45
|
+
/** The slice of the config the menu edits, plus the read-only prompt source. */
|
|
46
|
+
export interface ReviewerSettings {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
reviewer?: ReviewerBlock;
|
|
49
|
+
systemPromptSource: SystemPromptSource;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
|
|
53
|
+
export interface MenuModel {
|
|
54
|
+
provider: string;
|
|
55
|
+
id: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
contextWindow?: number;
|
|
58
|
+
thinkingLevelMap?: Partial<Record<ReasoningEffort, string | null>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function modelKey(model: { provider: string; id: string }): string {
|
|
62
|
+
return `${model.provider}/${model.id}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inverse of {@link modelKey}; the provider itself never contains a slash. */
|
|
66
|
+
export function parseModelKey(value: string): { provider: string; model: string } | undefined {
|
|
67
|
+
const slash = value.indexOf("/");
|
|
68
|
+
if (slash <= 0 || slash === value.length - 1) return undefined;
|
|
69
|
+
return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function reviewerValue(settings: ReviewerSettings): string {
|
|
73
|
+
return settings.reviewer ? modelKey({ provider: settings.reviewer.provider, id: settings.reviewer.model }) : UNSET_MODEL;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function toggleValue(enabled: boolean): string {
|
|
77
|
+
return enabled ? ON : OFF;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function thinkingValue(settings: ReviewerSettings): ReasoningEffort {
|
|
81
|
+
return settings.reviewer?.reasoningEffort ?? DEFAULT_REVIEWER_REASONING_EFFORT;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function timeoutValue(settings: ReviewerSettings): number {
|
|
85
|
+
return settings.reviewer?.timeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `~`-collapse a path so a long prompt path still fits the value column. */
|
|
89
|
+
export function collapseHome(path: string, home: string = homedir()): string {
|
|
90
|
+
const base = home || homedir();
|
|
91
|
+
if (!base) return path;
|
|
92
|
+
if (path === base) return "~";
|
|
93
|
+
return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Value column for the read-only system-prompt row. */
|
|
97
|
+
export function systemPromptValue(source: SystemPromptSource, home: string = homedir()): string {
|
|
98
|
+
if (source.kind === "file") return collapseHome(source.path, home);
|
|
99
|
+
return source.kind === "inline" ? "(inline in config)" : "(built-in default)";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function systemPromptDescription(source: SystemPromptSource): string {
|
|
103
|
+
if (source.kind === "file") return "Active reviewer system prompt, from systemPromptFile. Edit the file to change it.";
|
|
104
|
+
if (source.kind === "inline") return "Active reviewer system prompt, from the systemPrompt key in config.json.";
|
|
105
|
+
return "No systemPrompt or systemPromptFile is set, so the packaged reviewer prompt is used.";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Human-readable timeout: seconds, or `m s` past a minute. */
|
|
109
|
+
export function formatTimeout(timeoutMs: number): string {
|
|
110
|
+
const seconds = timeoutMs / 1000;
|
|
111
|
+
if (seconds < 60) return `${Number(seconds.toFixed(1))}s`;
|
|
112
|
+
const minutes = Math.floor(seconds / 60);
|
|
113
|
+
const rest = Number((seconds - minutes * 60).toFixed(1));
|
|
114
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type TimeoutParse = { timeoutMs: number } | { error: string };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a timeout the user typed. A bare number means seconds, because that is
|
|
121
|
+
* what the row displays; `ms` is available when an exact millisecond value
|
|
122
|
+
* matters. The accepted window mirrors the loader's validation exactly.
|
|
123
|
+
*/
|
|
124
|
+
export function parseTimeoutInput(input: string): TimeoutParse {
|
|
125
|
+
const match = /^([0-9]+(?:\.[0-9]+)?)\s*(ms|s)?$/i.exec(input.trim());
|
|
126
|
+
if (!match) return { error: "Enter a timeout like 30s or 45000ms" };
|
|
127
|
+
const amount = Number(match[1]);
|
|
128
|
+
if (!Number.isFinite(amount)) return { error: "Enter a timeout like 30s or 45000ms" };
|
|
129
|
+
const timeoutMs = match[2]?.toLowerCase() === "ms" ? amount : amount * 1000;
|
|
130
|
+
if (!Number.isInteger(timeoutMs)) return { error: "Review timeout must be a whole number of milliseconds" };
|
|
131
|
+
if (timeoutMs < MIN_REVIEWER_TIMEOUT_MS || timeoutMs > MAX_REVIEWER_TIMEOUT_MS) {
|
|
132
|
+
return { error: "Review timeout must be between 1s and 300s" };
|
|
133
|
+
}
|
|
134
|
+
return { timeoutMs };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SettingSubmenus {
|
|
138
|
+
reviewerModel?: SettingItem["submenu"];
|
|
139
|
+
timeout?: SettingItem["submenu"];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Build the `/auto-permissions` rows for a settings snapshot. */
|
|
143
|
+
export function buildSettingItems(
|
|
144
|
+
settings: ReviewerSettings,
|
|
145
|
+
submenus: SettingSubmenus = {},
|
|
146
|
+
home: string = homedir(),
|
|
147
|
+
): SettingItem[] {
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
id: ENABLED_ID,
|
|
151
|
+
label: "Enabled",
|
|
152
|
+
description: "Review guarded commands. Off lets every command run without guardian review.",
|
|
153
|
+
currentValue: toggleValue(settings.enabled),
|
|
154
|
+
values: TOGGLE_VALUES,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: REVIEWER_MODEL_ID,
|
|
158
|
+
label: "Reviewer model",
|
|
159
|
+
description: "Model the guardian reviews guarded commands with.",
|
|
160
|
+
currentValue: reviewerValue(settings),
|
|
161
|
+
...(submenus.reviewerModel ? { submenu: submenus.reviewerModel } : {}),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: THINKING_LEVEL_ID,
|
|
165
|
+
label: "Thinking level",
|
|
166
|
+
description: "Reasoning effort requested from the reviewer model.",
|
|
167
|
+
currentValue: thinkingValue(settings),
|
|
168
|
+
values: [...REASONING_EFFORTS],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: TIMEOUT_ID,
|
|
172
|
+
label: "Review timeout",
|
|
173
|
+
description: "How long one review may take before the command is denied.",
|
|
174
|
+
currentValue: formatTimeout(timeoutValue(settings)),
|
|
175
|
+
...(submenus.timeout ? { submenu: submenus.timeout } : {}),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: SYSTEM_PROMPT_ID,
|
|
179
|
+
label: "System prompt",
|
|
180
|
+
description: systemPromptDescription(settings.systemPromptSource),
|
|
181
|
+
currentValue: systemPromptValue(settings.systemPromptSource, home),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type SettingChange =
|
|
187
|
+
| { kind: "settings"; settings: ReviewerSettings }
|
|
188
|
+
/** Saved, but the value is worth flagging (e.g. an unsupported thinking level). */
|
|
189
|
+
| { kind: "warn"; settings: ReviewerSettings; message: string }
|
|
190
|
+
| { kind: "error"; message: string }
|
|
191
|
+
| { kind: "ignored" };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Map one row's new display value onto the settings snapshot.
|
|
195
|
+
*
|
|
196
|
+
* `model` is the currently configured reviewer model, when the registry knows
|
|
197
|
+
* it; it is only consulted to warn about unsupported thinking levels.
|
|
198
|
+
*/
|
|
199
|
+
export function applySettingChange(
|
|
200
|
+
settings: ReviewerSettings,
|
|
201
|
+
id: string,
|
|
202
|
+
value: string,
|
|
203
|
+
model?: MenuModel,
|
|
204
|
+
): SettingChange {
|
|
205
|
+
if (id === ENABLED_ID) {
|
|
206
|
+
if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
|
|
207
|
+
const enabled = value === ON;
|
|
208
|
+
if (enabled === settings.enabled) return { kind: "ignored" };
|
|
209
|
+
return { kind: "settings", settings: { ...settings, enabled } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (id === THINKING_LEVEL_ID) {
|
|
213
|
+
if (!REASONING_EFFORTS.includes(value as ReasoningEffort)) {
|
|
214
|
+
return { kind: "error", message: `Unknown thinking level: ${value}` };
|
|
215
|
+
}
|
|
216
|
+
const reasoningEffort = value as ReasoningEffort;
|
|
217
|
+
// A reviewer block without provider and model is rejected by the loader, so
|
|
218
|
+
// there is nothing valid to write until a model is chosen.
|
|
219
|
+
if (!settings.reviewer) return { kind: "error", message: NO_REVIEWER_MESSAGE };
|
|
220
|
+
if (settings.reviewer.reasoningEffort === reasoningEffort) return { kind: "ignored" };
|
|
221
|
+
const next = { ...settings, reviewer: { ...settings.reviewer, reasoningEffort } };
|
|
222
|
+
if (model?.thinkingLevelMap && model.thinkingLevelMap[reasoningEffort] === null) {
|
|
223
|
+
return {
|
|
224
|
+
kind: "warn",
|
|
225
|
+
settings: next,
|
|
226
|
+
message: `${modelKey(model)} does not support thinking level "${reasoningEffort}"`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { kind: "settings", settings: next };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The model and timeout submenus commit their own edit and hand back display
|
|
233
|
+
// text, so there is nothing left to map here.
|
|
234
|
+
return { kind: "ignored" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Adopt a picked model, creating the reviewer block when there was none. */
|
|
238
|
+
export function applyModelSelection(settings: ReviewerSettings, value: string): SettingChange {
|
|
239
|
+
const parsed = parseModelKey(value);
|
|
240
|
+
if (!parsed) return { kind: "error", message: `Unknown model: ${value}` };
|
|
241
|
+
if (settings.reviewer?.provider === parsed.provider && settings.reviewer.model === parsed.model) {
|
|
242
|
+
return { kind: "ignored" };
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
kind: "settings",
|
|
246
|
+
settings: {
|
|
247
|
+
...settings,
|
|
248
|
+
reviewer: {
|
|
249
|
+
provider: parsed.provider,
|
|
250
|
+
model: parsed.model,
|
|
251
|
+
reasoningEffort: settings.reviewer?.reasoningEffort ?? DEFAULT_REVIEWER_REASONING_EFFORT,
|
|
252
|
+
timeoutMs: settings.reviewer?.timeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function applyTimeoutInput(settings: ReviewerSettings, input: string): SettingChange {
|
|
259
|
+
if (!settings.reviewer) return { kind: "error", message: NO_REVIEWER_MESSAGE };
|
|
260
|
+
const parsed = parseTimeoutInput(input);
|
|
261
|
+
if ("error" in parsed) return { kind: "error", message: parsed.error };
|
|
262
|
+
if (parsed.timeoutMs === settings.reviewer.timeoutMs) return { kind: "ignored" };
|
|
263
|
+
return { kind: "settings", settings: { ...settings, reviewer: { ...settings.reviewer, timeoutMs: parsed.timeoutMs } } };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function modelItem(model: MenuModel, unavailable = false): SelectItem {
|
|
267
|
+
const key = modelKey(model);
|
|
268
|
+
const details = [model.name, model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k ctx` : undefined]
|
|
269
|
+
.filter((part): part is string => Boolean(part))
|
|
270
|
+
.join(" · ");
|
|
271
|
+
return {
|
|
272
|
+
value: key,
|
|
273
|
+
label: unavailable ? `${key} ${UNAVAILABLE_SUFFIX}` : key,
|
|
274
|
+
description: unavailable ? `${details ? `${details} · ` : ""}provider not signed in or not registered` : details,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Available models, provider then id, with a configured-but-unavailable
|
|
280
|
+
* reviewer pinned first so opening the menu can never silently drop it.
|
|
281
|
+
*/
|
|
282
|
+
export function buildModelItems(available: readonly MenuModel[], settings: ReviewerSettings): SelectItem[] {
|
|
283
|
+
const sorted = [...available].sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
|
|
284
|
+
const items = sorted.map((model) => modelItem(model));
|
|
285
|
+
const reviewer = settings.reviewer;
|
|
286
|
+
if (reviewer && !sorted.some((model) => model.provider === reviewer.provider && model.id === reviewer.model)) {
|
|
287
|
+
items.unshift(modelItem({ provider: reviewer.provider, id: reviewer.model }, true));
|
|
288
|
+
}
|
|
289
|
+
return items;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Substring filter over the picker rows.
|
|
294
|
+
*
|
|
295
|
+
* `SelectList.setFilter` is a prefix match on `value` alone, which for
|
|
296
|
+
* `provider/model-id` rows means typing "gpt" matches nothing. Filtering here
|
|
297
|
+
* instead keeps "gpt", "luna" and "codex" all useful.
|
|
298
|
+
*/
|
|
299
|
+
export function filterModelItems(items: readonly SelectItem[], query: string): SelectItem[] {
|
|
300
|
+
const needle = query.trim().toLowerCase();
|
|
301
|
+
if (!needle) return [...items];
|
|
302
|
+
return items.filter((item) => `${item.label} ${item.description ?? ""}`.toLowerCase().includes(needle));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface SubmenuHost {
|
|
306
|
+
/** Read the live settings; the menu edits a single shared snapshot. */
|
|
307
|
+
getSettings(): ReviewerSettings;
|
|
308
|
+
/** Commit an edit: validates, persists, and repaints. */
|
|
309
|
+
commit(change: SettingChange): void;
|
|
310
|
+
availableModels(): MenuModel[];
|
|
311
|
+
requestRender(): void;
|
|
312
|
+
settingsTheme: SettingsListTheme;
|
|
313
|
+
selectTheme: SelectListTheme;
|
|
314
|
+
home?: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Single-line text prompt, mirroring the one behind `/statusline`'s path rows. */
|
|
318
|
+
export class PromptComponent implements Component {
|
|
319
|
+
private readonly input = new Input();
|
|
320
|
+
|
|
321
|
+
constructor(
|
|
322
|
+
private readonly title: string,
|
|
323
|
+
initialValue: string,
|
|
324
|
+
private readonly hint: (text: string) => string,
|
|
325
|
+
onSubmit: (value: string) => void,
|
|
326
|
+
onCancel: () => void,
|
|
327
|
+
) {
|
|
328
|
+
this.input.setValue(initialValue);
|
|
329
|
+
this.input.focused = true;
|
|
330
|
+
this.input.onSubmit = onSubmit;
|
|
331
|
+
this.input.onEscape = onCancel;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
invalidate(): void {
|
|
335
|
+
this.input.invalidate();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
render(width: number): string[] {
|
|
339
|
+
return [
|
|
340
|
+
truncateToWidth(this.hint(` ${this.title}`), width),
|
|
341
|
+
"",
|
|
342
|
+
...this.input.render(width),
|
|
343
|
+
"",
|
|
344
|
+
truncateToWidth(this.hint(" Enter to save · Esc to cancel"), width),
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
handleInput(data: string): void {
|
|
349
|
+
this.input.handleInput(data);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const LIST_KEYS = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
|
|
354
|
+
|
|
355
|
+
/** Searchable reviewer-model picker. */
|
|
356
|
+
class ModelSubmenu implements Component {
|
|
357
|
+
private readonly items: SelectItem[];
|
|
358
|
+
private list: SelectList;
|
|
359
|
+
private readonly search = new Input();
|
|
360
|
+
private query = "";
|
|
361
|
+
|
|
362
|
+
constructor(
|
|
363
|
+
private readonly host: SubmenuHost,
|
|
364
|
+
private readonly done: (value?: string) => void,
|
|
365
|
+
) {
|
|
366
|
+
const settings = host.getSettings();
|
|
367
|
+
this.items = buildModelItems(host.availableModels(), settings);
|
|
368
|
+
this.list = this.buildList(this.items, this.items.findIndex((item) => item.value === reviewerValue(settings)));
|
|
369
|
+
this.search.focused = true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private buildList(items: SelectItem[], selectedIndex: number): SelectList {
|
|
373
|
+
const list = new SelectList(items, 10, this.host.selectTheme);
|
|
374
|
+
if (selectedIndex > 0) list.setSelectedIndex(selectedIndex);
|
|
375
|
+
list.onCancel = () => this.done(undefined);
|
|
376
|
+
list.onSelect = (item) => {
|
|
377
|
+
const change = applyModelSelection(this.host.getSettings(), item.value);
|
|
378
|
+
this.host.commit(change);
|
|
379
|
+
this.done(change.kind === "error" ? undefined : item.value);
|
|
380
|
+
};
|
|
381
|
+
return list;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
invalidate(): void {
|
|
385
|
+
this.search.invalidate();
|
|
386
|
+
this.list.invalidate();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
render(width: number): string[] {
|
|
390
|
+
return [
|
|
391
|
+
truncateToWidth(this.host.settingsTheme.hint(" Reviewer model"), width),
|
|
392
|
+
"",
|
|
393
|
+
...this.search.render(width),
|
|
394
|
+
"",
|
|
395
|
+
...this.list.render(width),
|
|
396
|
+
"",
|
|
397
|
+
truncateToWidth(this.host.settingsTheme.hint(" Type to filter · Enter to select · Esc to go back"), width),
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
handleInput(data: string): void {
|
|
402
|
+
const kb = getKeybindings();
|
|
403
|
+
// Navigation, Enter and Esc belong to the list; everything else is filter text.
|
|
404
|
+
if (LIST_KEYS.some((key) => kb.matches(data, key))) {
|
|
405
|
+
this.list.handleInput(data);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.search.handleInput(data);
|
|
409
|
+
const query = this.search.getValue();
|
|
410
|
+
if (query === this.query) return;
|
|
411
|
+
this.query = query;
|
|
412
|
+
this.list = this.buildList(filterModelItems(this.items, query), 0);
|
|
413
|
+
this.host.requestRender();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function createModelSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
418
|
+
return (_currentValue, done) => new ModelSubmenu(host, done);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function createTimeoutSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
422
|
+
return (currentValue, done) =>
|
|
423
|
+
new PromptComponent(
|
|
424
|
+
"Review timeout (e.g. 30s or 45000ms)",
|
|
425
|
+
currentValue,
|
|
426
|
+
host.settingsTheme.hint,
|
|
427
|
+
(value) => {
|
|
428
|
+
const change = applyTimeoutInput(host.getSettings(), value);
|
|
429
|
+
host.commit(change);
|
|
430
|
+
if (change.kind === "error") {
|
|
431
|
+
done(undefined);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
done(formatTimeout(timeoutValue(host.getSettings())));
|
|
435
|
+
},
|
|
436
|
+
() => done(undefined),
|
|
437
|
+
);
|
|
438
|
+
}
|