@trim21/personal-pi-extensions 0.1.530 → 0.1.532

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.530",
3
+ "version": "0.1.532",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -18,12 +18,7 @@ import { type TObject, Type } from "typebox";
18
18
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
19
  import { fenceCodeBlock } from "../lib/markdown.js";
20
20
  import { formatDisplayPath } from "../lib/path.js";
21
- import {
22
- type CheckboxAction,
23
- type SelectAction,
24
- selectCheckboxActions,
25
- selectWithOptionalInput,
26
- } from "../lib/ui.js";
21
+ import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
27
22
  import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
28
23
  import { commandPatternsFor } from "./approval-suggest.js";
29
24
  import {
@@ -55,24 +50,19 @@ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision
55
50
 
56
51
  /** 全权限审批对话框的选项 label(也作为 switch 匹配键与测试引用)。 */
57
52
  export const ALLOW_ONCE = "Allow once";
58
- export const ALLOW_FOREVER = "Allow forever";
59
53
  export const DENY = "Deny";
60
54
  export const DENY_WITH_REASON = "Deny with reason";
61
-
62
- /** 审批对话框选项:允许一次 / 永久允许 / 拒绝 / 拒绝并附理由。 */
63
- export const FULL_ACCESS_CHOICES: readonly SelectAction[] = [
64
- { label: ALLOW_ONCE },
65
- { label: ALLOW_FOREVER },
66
- { label: DENY },
67
- { label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
68
- ];
55
+ /** 第一层的折叠入口:进入按 pattern 勾选持久化规则的子菜单。 */
56
+ export const EDIT_RULES = "Edit approval rules";
57
+ /** 规则子菜单的返回项:结束勾选,回到第一层做放行/拒绝决策。 */
58
+ export const BACK = "Back";
69
59
 
70
60
  /**
71
61
  * 全权限审批 UI 的决策结果:业务层(execute/approveFullAccess)据此
72
62
  * 决定放行、拒绝并持久化勾选的规则,UI 层不直接产生副作用。
73
63
  */
74
64
  export interface FullAccessUIDecision {
75
- /** 用户选择的动作 label(ALLOW_ONCE / ALLOW_FOREVER / DENY / DENY_WITH_REASON)。 */
65
+ /** 用户选择的动作 label(ALLOW_ONCE / DENY / DENY_WITH_REASON)。 */
76
66
  result: string;
77
67
  /** 用户勾选、需持久化为 allow 规则的 pattern;未勾选时为空数组。 */
78
68
  foreverApprovedPattern: string[];
@@ -511,10 +501,6 @@ export class BwrapRuntime {
511
501
  }
512
502
  return;
513
503
  }
514
- case ALLOW_FOREVER: {
515
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
516
- return;
517
- }
518
504
  case DENY: {
519
505
  if (foreverApprovedPattern.length > 0) {
520
506
  await this.persistAllowRule(ctx, command, foreverApprovedPattern);
@@ -546,25 +532,29 @@ export class BwrapRuntime {
546
532
  reason: string | undefined,
547
533
  execCwd: string,
548
534
  ): Promise<FullAccessUIDecision | undefined> {
549
- // 弹框前解析命令的持久化规则(勾选的 pattern 会写入),在弹框里以
550
- // checkbox 列出:`echo 1 | head` → `echo *`、`head *`,逐项决定是否
551
- // allow forever,避免用户对"永久允许"持久化什么一无所知。
535
+ // 弹框前解析命令的持久化规则:`echo 1 | head` → `echo *`、`head *`。
536
+ // 持久化规则的勾选折叠进 EDIT_RULES 子菜单,主决策列表只保留放行/拒绝,
537
+ // 避免一屏 checkbox 淹没决策项。
552
538
  const patterns = await commandPatternsFor(command);
553
- // checkbox 只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
539
+ // 子菜单只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
554
540
  // 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
555
541
  const rules = this.resolve(ctx).approvalRules;
556
- const unallowedPatterns = patterns.filter((pattern) => {
557
- const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
558
- return rule?.action !== "allow";
559
- });
542
+ const unallowedPatterns = [
543
+ ...new Set(
544
+ patterns.filter((pattern) => {
545
+ const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
546
+ return rule?.action !== "allow";
547
+ }),
548
+ ),
549
+ ];
560
550
  // dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
561
551
  // 时 notify 提示,弹窗本身与无 dcg 时一致
562
552
  const outcome = await dcgSuggestion(command);
563
553
  if (outcome.kind === "failed") {
564
554
  ctx.ui.notify(`dcg 扫描失败,本次无破坏性命令建议: ${outcome.detail}`, "warning");
565
555
  }
566
- // 弹框主体按行组织('\n' join),便于 review;suggestion 与规则说明
567
- // 块带前导空行 + 尾部 "---" 分隔,输出与历史逐字符一致。
556
+ // 弹框主体按行组织('\n' join),便于 review;suggestion 块带前导空行 +
557
+ // 尾部 "---" 分隔。
568
558
  const lines: string[] = [
569
559
  "Allow this command to run without sandbox?",
570
560
  "---",
@@ -575,13 +565,6 @@ export class BwrapRuntime {
575
565
  if (outcome.kind === "suggestion") {
576
566
  lines.push("", outcome.suggestion.text, "---");
577
567
  }
578
- if (unallowedPatterns.length > 0) {
579
- lines.push(
580
- "",
581
- "勾选规则将持久化为允许规则(后续同模式命令自动放行),未勾选规则仅本次处理:",
582
- "---",
583
- );
584
- }
585
568
  lines.push(fenceCodeBlock(command));
586
569
  // 执行目录与工作区不同时,提示实际执行目录(execCwd 是解析后的绝对路径,
587
570
  // 显示用 pretty path 风格:home 内 `~/…`,否则绝对路径)
@@ -590,51 +573,51 @@ export class BwrapRuntime {
590
573
  }
591
574
  const description = lines.join("\n");
592
575
 
593
- // 解析失败(无 pattern 可勾选):保持单选对话框(Allow forever 是空操作)
594
- if (patterns.length === 0) {
595
- // 单选:允许一次 / 永久允许(写入规则)/ 拒绝 / 拒绝并附理由(弹输入框)
596
- const verdict = await selectWithOptionalInput(description, FULL_ACCESS_CHOICES, ctx.ui, {
576
+ // 主决策列表:允许一次 / 拒绝 / 拒绝并附理由。有可持久化的 pattern 时
577
+ // 追加折叠入口,进入子菜单逐项勾选。
578
+ const actions: SelectAction[] = [
579
+ { label: ALLOW_ONCE },
580
+ { label: DENY },
581
+ { label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
582
+ ];
583
+ if (unallowedPatterns.length > 0) {
584
+ actions.push({ label: EDIT_RULES });
585
+ }
586
+ // 子菜单沿用同一份说明,并补上勾选规则的语义提示。
587
+ const editDescription = [
588
+ "勾选要持久化为允许规则的命令模式(后续同模式命令自动放行,未勾选仅本次处理):",
589
+ "---",
590
+ "",
591
+ description,
592
+ ].join("\n");
593
+
594
+ // 两层循环:子菜单勾选后回到主决策,直到用户在 Allow once / Deny 系列中做出
595
+ // 选择。勾选的规则在放行时持久化;Deny 系列同样持久化(用户确认该模式可信,
596
+ // 只是本次命令不执行)。子菜单关闭 = 返回主决策;主决策关闭 = 取消(上层按拒绝处理)。
597
+ let selected: string[] = [];
598
+ for (;;) {
599
+ const pending =
600
+ selected.length > 0
601
+ ? `\n\n将持久化为允许规则: ${selected.map((pattern) => escapeHtml(pattern)).join(", ")}`
602
+ : "";
603
+ const verdict = await selectWithOptionalInput(description + pending, actions, ctx.ui, {
597
604
  signal: ctx.signal,
598
605
  });
599
- // 关闭对话框 = 中断并拒绝,不循环重问
600
606
  if (verdict === undefined) return undefined;
601
- return {
602
- result: verdict.label,
603
- foreverApprovedPattern: [],
604
- reason: verdict.input,
605
- };
607
+ if (verdict.label !== EDIT_RULES) {
608
+ return {
609
+ result: verdict.label,
610
+ foreverApprovedPattern: selected,
611
+ reason: verdict.input,
612
+ };
613
+ }
614
+ selected = await selectMultiple(
615
+ editDescription,
616
+ unallowedPatterns.map((pattern) => ({ label: pattern })),
617
+ ctx.ui,
618
+ { signal: ctx.signal, doneLabel: BACK },
619
+ );
606
620
  }
607
-
608
- // 每个识别到的 pattern 一个 checkbox:勾选 = 持久化为 allow 规则。
609
- // Allow once = 执行本次并持久化勾选的规则;Deny 系列 = 拒绝本次,
610
- // 勾选的规则仍持久化(用户确认该模式可信,只是本次命令不执行)。
611
- const actions = [
612
- { action: "allow-once", label: ALLOW_ONCE },
613
- { action: "deny", label: DENY },
614
- {
615
- action: "deny-with-reason",
616
- label: DENY_WITH_REASON,
617
- inputPrompt: "Why was this denied?",
618
- },
619
- ] as const satisfies readonly CheckboxAction<"allow-once" | "deny" | "deny-with-reason">[];
620
- const verdict = await selectCheckboxActions(
621
- description,
622
- [...new Set(unallowedPatterns)].map((pattern) => ({ label: pattern })),
623
- actions,
624
- ctx.ui,
625
- { signal: ctx.signal },
626
- );
627
- if (verdict === undefined) return undefined;
628
- const resultByAction = {
629
- "allow-once": ALLOW_ONCE,
630
- deny: DENY,
631
- "deny-with-reason": DENY_WITH_REASON,
632
- } as const;
633
- return {
634
- result: resultByAction[verdict.action],
635
- foreverApprovedPattern: verdict.selected,
636
- reason: verdict.input,
637
- };
638
621
  }
639
622
 
640
623
  /** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
@@ -307,16 +307,17 @@ export function mergeConfig(globalConfig: LspConfig, localConfig: LspConfig): Ls
307
307
  };
308
308
  }
309
309
 
310
- /** 读取全局 + 本地配置,合并并解析为生效配置;未知字段警告经 onWarning 上报。 */
310
+ /**
311
+ * 读取全局 + 本地配置,合并并解析为生效配置;未知字段警告经 onWarning 上报。
312
+ * 顺序读取两份配置,让警告顺序稳定为全局在前、本地在后。
313
+ */
311
314
  export async function loadLspConfig(
312
315
  cwd: string,
313
316
  globalConfigPath: string = join(homedir(), ".pi", "agent", "lsp.json"),
314
317
  onWarning?: (message: string) => void,
315
318
  ): Promise<ResolvedLspConfig> {
316
- const [globalConfig, localConfig] = await Promise.all([
317
- readConfigFile(globalConfigPath, onWarning),
318
- readConfigFile(join(cwd, ".pi", "lsp.json"), onWarning),
319
- ]);
319
+ const globalConfig = await readConfigFile(globalConfigPath, onWarning);
320
+ const localConfig = await readConfigFile(join(cwd, ".pi", "lsp.json"), onWarning);
320
321
  return resolveConfig(mergeConfig(globalConfig, localConfig));
321
322
  }
322
323
 
package/src/lib/ui.ts CHANGED
@@ -111,54 +111,3 @@ export async function selectMultiple(
111
111
  }
112
112
  return selected;
113
113
  }
114
-
115
- /** 结束动作:选中即结束多选循环;带 inputPrompt 时先弹输入框,内容经 input 返回。 */
116
- export interface CheckboxAction<T extends string> {
117
- action: T;
118
- label: string;
119
- inputPrompt?: string;
120
- }
121
-
122
- export interface CheckboxActionResult<T extends string> {
123
- /** 勾选的条目 label(勾选顺序,经 inputPrompt 输入的条目已并入)。 */
124
- selected: string[];
125
- /** 用户选中的结束动作。 */
126
- action: T;
127
- /** 结束动作带 inputPrompt 时的输入内容:undefined=取消输入,""=空提交。 */
128
- input?: string;
129
- }
130
-
131
- /**
132
- * Checkbox 多选 + 结束动作的组合对话框(selectMultiple 的带操作变体)。
133
- *
134
- * 循环列出全部 `actions`(固定操作,选中即结束循环)与全部 `entries`
135
- * (☑/☐ 前缀切换勾选);对话框关闭(dismiss)返回 undefined。操作在前、
136
- * checkbox 条目在后,用户先决定本次动作,需要时再勾选要持久化的规则。
137
- */
138
- export async function selectCheckboxActions<T extends string>(
139
- title: string,
140
- entries: readonly SelectAction[],
141
- actions: readonly CheckboxAction<T>[],
142
- ui: ExtensionContext["ui"],
143
- opts: { signal?: AbortSignal } = {},
144
- ): Promise<CheckboxActionResult<T> | undefined> {
145
- const selected: string[] = [];
146
- for (;;) {
147
- const selectedSet = new Set(selected);
148
- const displayToLabel = new Map<string, string>();
149
- const round: SelectAction[] = [];
150
- for (const entry of entries) {
151
- const display = `${selectedSet.has(entry.label) ? CHECKED_PREFIX : UNCHECKED_PREFIX}${entry.label}`;
152
- displayToLabel.set(display, entry.label);
153
- round.push({ ...entry, label: display });
154
- }
155
- const result = await selectWithOptionalInput(title, [...actions, ...round], ui, opts);
156
- if (result === undefined) return undefined;
157
- const action = actions.find((candidate) => candidate.label === result.label);
158
- if (action !== undefined) return { selected, action: action.action, input: result.input };
159
- const label = displayToLabel.get(result.label);
160
- if (label === undefined) continue;
161
- if (selectedSet.has(label)) selected.splice(selected.indexOf(label), 1);
162
- else selected.push(label);
163
- }
164
- }