@armadra/agent 0.5.0 → 0.5.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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * CLI 子命令的方向键选择(零依赖):TTY 下 raw mode 画一个编号选项列表,原地重画。
3
+ *
4
+ * - 按键:↑↓(与 Ctrl+P / Ctrl+N)移动、Enter 确认、数字直选、选项按键提示里的单个字母直选(`y` / `n`),
5
+ * Esc / Ctrl+C / stdin 结束 = 取消;
6
+ * - 结束后把列表收成一行「? 问题 答案」,恢复 raw 状态与光标,暂停 stdin;进程意外退出时也恢复;
7
+ * - 颜色与字形按环境:`NO_COLOR` / 非 TTY 输出无色,`AMA_ASCII` / 区域设置决定 ASCII 字形(字形表见
8
+ * docs/tui-design.md §2.2);
9
+ * - 输入不是可开 raw 的 TTY(管道、CI)时 {@link confirmContinue} 回落为原来的文本 `[y/N]` 问答。
10
+ */
11
+ export interface ChoiceInput {
12
+ isTTY?: boolean;
13
+ isRaw?: boolean;
14
+ setRawMode?(raw: boolean): unknown;
15
+ on(event: "data" | "end", listener: (chunk?: Buffer | string) => void): unknown;
16
+ off(event: "data" | "end", listener: (chunk?: Buffer | string) => void): unknown;
17
+ resume?(): unknown;
18
+ pause?(): unknown;
19
+ }
20
+ export interface ChoiceOption {
21
+ label: string;
22
+ /** 右侧按键提示(空格分隔);其中的单个字母同时是直选键。 */
23
+ keys?: string;
24
+ }
25
+ export interface PromptChoiceOptions {
26
+ question: string;
27
+ options: readonly ChoiceOption[];
28
+ /** 缺省选中的下标。 */
29
+ selected?: number;
30
+ /** 缺省 `process.stdin`。 */
31
+ input?: ChoiceInput;
32
+ /** 缺省写 stderr。 */
33
+ write?(text: string): void;
34
+ env?: NodeJS.ProcessEnv;
35
+ /** 输出是否 TTY(决定颜色);缺省看 stderr。 */
36
+ outputIsTTY?: boolean;
37
+ }
38
+ /** 能否用方向键选择:输入是 TTY 且能开 raw。 */
39
+ export declare function canPromptChoice(input?: ChoiceInput): boolean;
40
+ /** 一块输入切成单个按键(转义序列整体、其余逐字符)。 */
41
+ export declare function splitKeys(data: string): string[];
42
+ /** 方向键选择:返回选中的下标,取消返回 undefined。调用方先用 {@link canPromptChoice} 判断。 */
43
+ export declare function promptChoice(options: PromptChoiceOptions): Promise<number | undefined>;
44
+ export interface ConfirmOptions {
45
+ /** 缺省「继续?」。 */
46
+ question?: string;
47
+ input?: ChoiceInput & NodeJS.ReadableStream;
48
+ write?(text: string): void;
49
+ /** 文本回落时问句写到哪里,缺省 stderr。 */
50
+ output?: NodeJS.WritableStream;
51
+ env?: NodeJS.ProcessEnv;
52
+ outputIsTTY?: boolean;
53
+ }
54
+ /**
55
+ * 计费 / 写入前的确认:TTY 下方向键选择「继续 / 取消」(缺省取消,`y` / `n` 直选);
56
+ * 否则回落为文本 `继续?[y/N]`(只有 y / yes 算同意)。
57
+ */
58
+ export declare function confirmContinue(options?: ConfirmOptions): Promise<boolean>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * CLI 子命令的方向键选择(零依赖):TTY 下 raw mode 画一个编号选项列表,原地重画。
3
+ *
4
+ * - 按键:↑↓(与 Ctrl+P / Ctrl+N)移动、Enter 确认、数字直选、选项按键提示里的单个字母直选(`y` / `n`),
5
+ * Esc / Ctrl+C / stdin 结束 = 取消;
6
+ * - 结束后把列表收成一行「? 问题 答案」,恢复 raw 状态与光标,暂停 stdin;进程意外退出时也恢复;
7
+ * - 颜色与字形按环境:`NO_COLOR` / 非 TTY 输出无色,`AMA_ASCII` / 区域设置决定 ASCII 字形(字形表见
8
+ * docs/tui-design.md §2.2);
9
+ * - 输入不是可开 raw 的 TTY(管道、CI)时 {@link confirmContinue} 回落为原来的文本 `[y/N]` 问答。
10
+ */
11
+ import { createInterface } from "node:readline/promises";
12
+ import { createTheme, defaultKeybindings, detectCapabilities, padToWidth, resolveAscii, visibleWidth, } from "../tui.js";
13
+ const HIDE_CURSOR = "\x1b[?25l";
14
+ const SHOW_CURSOR = "\x1b[?25h";
15
+ /** 能否用方向键选择:输入是 TTY 且能开 raw。 */
16
+ export function canPromptChoice(input = process.stdin) {
17
+ return input.isTTY === true && typeof input.setRawMode === "function";
18
+ }
19
+ /** 一块输入切成单个按键(转义序列整体、其余逐字符)。 */
20
+ export function splitKeys(data) {
21
+ return data.match(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[A-Za-z]|\x1b(?![[O])|[\s\S]/gu) ?? [];
22
+ }
23
+ /** 方向键选择:返回选中的下标,取消返回 undefined。调用方先用 {@link canPromptChoice} 判断。 */
24
+ export function promptChoice(options) {
25
+ const input = options.input ?? process.stdin;
26
+ const write = options.write ?? ((text) => void process.stderr.write(text));
27
+ const env = options.env ?? process.env;
28
+ const theme = createTheme("dark", {
29
+ caps: detectCapabilities(env, options.outputIsTTY ?? process.stderr.isTTY === true),
30
+ ascii: resolveAscii(undefined, env),
31
+ });
32
+ const g = theme.glyphs;
33
+ const items = options.options;
34
+ const n = items.length;
35
+ let selected = Math.max(0, Math.min(n - 1, options.selected ?? 0));
36
+ let drawn = 0;
37
+ const keyWidth = Math.max(0, ...items.map((o) => (o.keys ?? "").length));
38
+ const labelWidth = Math.max(...items.map((o) => visibleWidth(o.label)));
39
+ const question = `${theme.fg("accent", "?")} ${theme.bold(options.question)}`;
40
+ const frame = () => [
41
+ question,
42
+ ...items.map((item, i) => {
43
+ const on = i === selected;
44
+ const left = `${on ? g.prompt : " "} ${i + 1}. ${padToWidth(item.label, labelWidth)}`;
45
+ const keys = item.keys === undefined ? "" : ` ${theme.fg("dim", item.keys.padEnd(keyWidth))}`;
46
+ return (on ? theme.fg("accent", theme.bold(left)) : left) + keys;
47
+ }),
48
+ theme.fg("dim", `${g.arrowUp}${g.arrowDown} 选择 · Enter 确认 · 1-${n} 直接选 · Esc 取消`),
49
+ ];
50
+ const draw = (lines) => {
51
+ const up = drawn > 0 ? `\x1b[${drawn}A` : "";
52
+ write(`${up}\r\x1b[J${lines.join("\n")}\n`);
53
+ drawn = lines.length;
54
+ };
55
+ return new Promise((resolve) => {
56
+ const wasRaw = input.isRaw === true;
57
+ let done = false;
58
+ const restore = () => {
59
+ input.setRawMode?.(wasRaw);
60
+ write(SHOW_CURSOR);
61
+ };
62
+ const finish = (index) => {
63
+ if (done)
64
+ return;
65
+ done = true;
66
+ input.off("data", onData);
67
+ input.off("end", onEnd);
68
+ process.off("exit", restore);
69
+ const answer = index === undefined ? "(已取消)" : items[index].label;
70
+ draw([`${question} ${theme.fg("dim", answer)}`]);
71
+ restore();
72
+ input.pause?.();
73
+ resolve(index);
74
+ };
75
+ const onKey = (key) => {
76
+ const letter = /^[a-z]$/i.test(key) ? key.toLowerCase() : "";
77
+ const byLetter = letter === "" ? -1 : items.findIndex((o) => (o.keys ?? "").split(/\s+/).includes(letter));
78
+ if (byLetter !== -1)
79
+ return finish(byLetter);
80
+ if (defaultKeybindings.matches(key, "tui.select.cancel"))
81
+ return finish(undefined);
82
+ if (/^[1-9]$/.test(key) && Number(key) <= n)
83
+ return finish(Number(key) - 1);
84
+ if (key === "\r" || key === "\n")
85
+ return finish(selected);
86
+ if (defaultKeybindings.matches(key, "tui.select.up"))
87
+ selected = (selected + n - 1) % n;
88
+ else if (defaultKeybindings.matches(key, "tui.select.down"))
89
+ selected = (selected + 1) % n;
90
+ else
91
+ return;
92
+ draw(frame());
93
+ };
94
+ const onData = (chunk) => {
95
+ for (const key of splitKeys(String(chunk ?? ""))) {
96
+ if (done)
97
+ return;
98
+ onKey(key);
99
+ }
100
+ };
101
+ const onEnd = () => finish(undefined);
102
+ process.once("exit", restore);
103
+ input.setRawMode?.(true);
104
+ write(HIDE_CURSOR);
105
+ draw(frame());
106
+ input.on("data", onData);
107
+ input.on("end", onEnd);
108
+ input.resume?.();
109
+ });
110
+ }
111
+ /**
112
+ * 计费 / 写入前的确认:TTY 下方向键选择「继续 / 取消」(缺省取消,`y` / `n` 直选);
113
+ * 否则回落为文本 `继续?[y/N]`(只有 y / yes 算同意)。
114
+ */
115
+ export async function confirmContinue(options = {}) {
116
+ const question = options.question ?? "继续?";
117
+ const input = options.input ?? process.stdin;
118
+ if (canPromptChoice(input)) {
119
+ const index = await promptChoice({
120
+ question,
121
+ options: [
122
+ { label: "继续", keys: "y" },
123
+ { label: "取消", keys: "n Esc" },
124
+ ],
125
+ selected: 1,
126
+ input,
127
+ ...(options.write !== undefined ? { write: options.write } : {}),
128
+ ...(options.env !== undefined ? { env: options.env } : {}),
129
+ ...(options.outputIsTTY !== undefined ? { outputIsTTY: options.outputIsTTY } : {}),
130
+ });
131
+ return index === 0;
132
+ }
133
+ const rl = createInterface({ input, output: options.output ?? process.stderr });
134
+ try {
135
+ return /^y(es)?$/i.test((await rl.question(`${question}[y/N] `)).trim());
136
+ }
137
+ finally {
138
+ rl.close();
139
+ }
140
+ }
@@ -7,7 +7,7 @@
7
7
  * (字段缺失或恒 0);其余(读到一点、只有写入)→ `inconclusive`(粒度或 TTL 问题)。
8
8
  *
9
9
  * 计费动作:执行前打印预估(两次 × `--tokens` × 目录输入价,无价 `$?`);非 TTY 必须 `--yes`
10
- * (否则退出 2),TTY 下问一次 y/N。`--json` 时预估写 stderr,stdout 只有一个结果对象。
10
+ * (否则退出 2),TTY 下方向键选一次「继续 / 取消」(choice-prompt.ts)。`--json` 时预估写 stderr,stdout 只有一个结果对象。
11
11
  * 协议层不保留原始响应,「usage 字段」列的是该协议解析时读取的字段名。
12
12
  */
13
13
  import type { Api, Model, Usage } from "../../ai/types.js";
@@ -7,12 +7,12 @@
7
7
  * (字段缺失或恒 0);其余(读到一点、只有写入)→ `inconclusive`(粒度或 TTL 问题)。
8
8
  *
9
9
  * 计费动作:执行前打印预估(两次 × `--tokens` × 目录输入价,无价 `$?`);非 TTY 必须 `--yes`
10
- * (否则退出 2),TTY 下问一次 y/N。`--json` 时预估写 stderr,stdout 只有一个结果对象。
10
+ * (否则退出 2),TTY 下方向键选一次「继续 / 取消」(choice-prompt.ts)。`--json` 时预估写 stderr,stdout 只有一个结果对象。
11
11
  * 协议层不保留原始响应,「usage 字段」列的是该协议解析时读取的字段名。
12
12
  */
13
- import { createInterface } from "node:readline/promises";
14
13
  import { priceTokens } from "../../ai/cost.js";
15
14
  import { UsageError } from "../args.js";
15
+ import { confirmContinue } from "../choice-prompt.js";
16
16
  import { ExitCode } from "../exit-codes.js";
17
17
  export const PROBE_DEFAULT_TOKENS = 2048;
18
18
  export const PROBE_DEFAULT_GAP_MS = 3000;
@@ -78,15 +78,6 @@ function intOption(ctx, name, fallback, min) {
78
78
  throw new UsageError(`--${name} 应为不小于 ${min} 的整数`);
79
79
  return value;
80
80
  }
81
- async function confirm() {
82
- const rl = createInterface({ input: process.stdin, output: process.stderr });
83
- try {
84
- return /^y(es)?$/i.test((await rl.question("继续?[y/N] ")).trim());
85
- }
86
- finally {
87
- rl.close();
88
- }
89
- }
90
81
  function sampleLine(index, s) {
91
82
  const field = s.cacheReported === undefined ? "?" : s.cacheReported ? "有" : "无";
92
83
  return `#${index} input ${s.input} · cacheRead ${s.cacheRead} · cacheWrite ${s.cacheWrite} · 缓存字段 ${field}`;
@@ -122,7 +113,7 @@ async function run(ctx) {
122
113
  io.stderr("ama: cache-probe 会发两次计费请求,非交互环境需加 --yes\n");
123
114
  return ExitCode.Usage;
124
115
  }
125
- if (!(await confirm())) {
116
+ if (!(await confirmContinue({ env: io.env }))) {
126
117
  io.stderr("ama: 已取消\n");
127
118
  return ExitCode.Ok;
128
119
  }
@@ -8,7 +8,7 @@
8
8
  * - list / channels:供应商 → 渠道 → 模型数与 key 来源(从不显示 key)。remove:删配置与 auth.json 条目。
9
9
  *
10
10
  * 计费:`--probe` 每模型每渠道一次最小请求(并发执行,见到首个流事件即判可用并断开,见
11
- * probe-runner.ts);执行前打印预估,TTY 问 y/N,非 TTY 必须 `--yes`。
11
+ * probe-runner.ts);执行前打印预估,TTY 下方向键选「继续 / 取消」(choice-prompt.ts,y / n 直选),非 TTY 必须 `--yes`。
12
12
  */
13
13
  import type { AmaConfig } from "../../config/types.js";
14
14
  import type { CliIo, RuntimeDeps } from "../deps.js";
@@ -8,10 +8,9 @@
8
8
  * - list / channels:供应商 → 渠道 → 模型数与 key 来源(从不显示 key)。remove:删配置与 auth.json 条目。
9
9
  *
10
10
  * 计费:`--probe` 每模型每渠道一次最小请求(并发执行,见到首个流事件即判可用并断开,见
11
- * probe-runner.ts);执行前打印预估,TTY 问 y/N,非 TTY 必须 `--yes`。
11
+ * probe-runner.ts);执行前打印预估,TTY 下方向键选「继续 / 取消」(choice-prompt.ts,y / n 直选),非 TTY 必须 `--yes`。
12
12
  */
13
13
  import { existsSync } from "node:fs";
14
- import { createInterface } from "node:readline/promises";
15
14
  import { describeModelsDev, loadModelsDevIndex } from "../../ai/providers/models-dev-cache.js";
16
15
  import { modelsDevFields } from "../../ai/providers/models-dev.js";
17
16
  import { PROVIDER_ID_PATTERN, setAuthKey } from "../../config/auth-file.js";
@@ -19,6 +18,7 @@ import { loadConfigFile } from "../../config/load.js";
19
18
  import { CONFIG_FILE_VERSION } from "../../config/types.js";
20
19
  import { writeConfigFile } from "../../config/write.js";
21
20
  import { parseSubArgs, UsageError } from "../args.js";
21
+ import { confirmContinue } from "../choice-prompt.js";
22
22
  import { pickByPrice } from "../default-model.js";
23
23
  import { ExitCode } from "../exit-codes.js";
24
24
  import { extractKey } from "./auth.js";
@@ -76,13 +76,7 @@ export function userConfig(level) {
76
76
  }
77
77
  async function confirm(io, question) {
78
78
  io.stderr(question);
79
- const rl = createInterface({ input: process.stdin, output: process.stderr });
80
- try {
81
- return /^y(es)?$/i.test((await rl.question("继续?[y/N] ")).trim());
82
- }
83
- finally {
84
- rl.close();
85
- }
79
+ return confirmContinue({ env: io.env });
86
80
  }
87
81
  /** --yes / TTY 确认;非 TTY 且没有 --yes 返回 false 并提示(调用方退出 2)。 */
88
82
  async function approved(ctx, what) {
@@ -14,6 +14,7 @@
14
14
  import type { AgentSession, RewindDraftText } from "../agent/types.js";
15
15
  import type { SwitchRequest } from "../cli/compose-session.js";
16
16
  import type { Runtime } from "../cli/runtime.js";
17
+ import type { PermissionMode } from "../permissions/types.js";
17
18
  export { describeSession } from "./session-report.js";
18
19
  export type CommandResult = {
19
20
  kind: "handled";
@@ -36,6 +37,11 @@ export interface CommandContext {
36
37
  /** 当前会话(切换后是新的那个)。 */
37
38
  session(): AgentSession;
38
39
  switchSession(request: SwitchRequest): Promise<AgentSession>;
40
+ /**
41
+ * `/permission <模式>` 切换前的确认(进入 Bypass 前问一次,见 permissions/bypass.ts);返回 false
42
+ * 保持原模式。没有时直接切换(管道、RPC 等无人值守入口)。
43
+ */
44
+ confirmPermissionMode?(mode: PermissionMode): boolean | Promise<boolean>;
39
45
  }
40
46
  export interface CommandInfo {
41
47
  name: string;
@@ -171,6 +171,15 @@ export async function runSlashCommand(line, ctx) {
171
171
  if (mode === undefined) {
172
172
  throw new AmaError("invalid_arguments", `权限模式应为 ${PERMISSION_MODE_ORDER.join(" | ")}(也可写显示名,如 "Accept edits")`);
173
173
  }
174
+ const current = session.state.permissionMode;
175
+ if (mode !== current && ctx.confirmPermissionMode !== undefined) {
176
+ if (!(await ctx.confirmPermissionMode(mode))) {
177
+ return {
178
+ kind: "handled",
179
+ message: `已取消,权限模式仍为 ${permissionModeLabel(current)}`,
180
+ };
181
+ }
182
+ }
174
183
  session.setPermissionMode(mode);
175
184
  return { kind: "handled", message: `权限模式:${permissionModeLabel(mode)}` };
176
185
  }
@@ -14,6 +14,7 @@
14
14
  import type { AgentSession, RewindDraftText } from "../../agent/types.js";
15
15
  import type { SwitchRequest } from "../../cli/compose-session.js";
16
16
  import type { Runtime } from "../../cli/runtime.js";
17
+ import type { PermissionMode } from "../../permissions/types.js";
17
18
  import type { Component, SelectItem, Theme } from "../../tui.js";
18
19
  import { type CommandInfo } from "../commands-core.js";
19
20
  import { type NoticeLevel } from "./message-view.js";
@@ -48,6 +49,8 @@ export interface CommandUi {
48
49
  home?: string;
49
50
  /** 进程环境:给出时 /model 选择器按 fake-visibility 规则藏起测试供应商 fake。 */
50
51
  env?: Readonly<Record<string, string | undefined>>;
52
+ /** 切换权限模式前的确认(进入 Bypass 前弹确认框);返回 false 保持原模式。 */
53
+ confirmMode?(mode: PermissionMode): boolean | Promise<boolean>;
51
54
  /** [W5-U] 界面自己处理的第五波命令(agent-ui.ts);处理了返回 true。 */
52
55
  extra?(name: string, args: string): Promise<boolean>;
53
56
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { formatModelRef } from "../../ai/providers/channels.js";
15
15
  import { AgentSessionImpl } from "../../agent/session.js";
16
+ import { THINKING_LEVELS } from "../../ai/thinking.js";
16
17
  import { listSessions } from "../../cli/compose-store.js";
17
18
  import { hideFakeProvider } from "../../cli/fake-visibility.js";
18
19
  import { AUTO_LAYER_TEXT, permissionModeLabel } from "../../permissions/modes.js";
@@ -30,7 +31,7 @@ export const KEY_HINTS = [
30
31
  "Enter 发送(运行中 = 插话) Alt+Enter 排到本轮之后 Shift+Enter / Ctrl+J 换行",
31
32
  "Esc 中断(排队消息回填编辑器) Alt+↑ 取回最后一条排队消息",
32
33
  "空闲时 Esc Esc:输入框为空 = 回滚(/rewind),有字 = 清空(↑ 取回)",
33
- "Shift+Tab 切换权限模式 Ctrl+L 模型 Ctrl+T 思考级别 Ctrl+O 展开工具输出与思考",
34
+ "Shift+Tab / Tab(输入为空时)切换权限模式(进入 Bypass 前确认) Ctrl+L 模型 Ctrl+T 思考级别 Ctrl+O 展开工具输出与思考",
34
35
  "审批:1–3 或 ↑↓ Enter 选择,y 允许 a 本会话允许同类 n / Esc 拒绝 v 完整输入",
35
36
  "计划审批:1 批准 2 新上下文执行 3 继续修改 4 放弃并退出 Plan e 编辑计划 Esc 留在 Plan",
36
37
  "Ctrl+V 粘贴剪贴板图片(插入 @路径) Ctrl+G 底部信息行两行 / 一行",
@@ -164,6 +165,11 @@ async function handlePick(what, ui) {
164
165
  if (picked === undefined)
165
166
  return;
166
167
  const mode = picked.value;
168
+ const current = session.state.permissionMode;
169
+ if (mode !== current && ui.confirmMode !== undefined && !(await ui.confirmMode(mode))) {
170
+ ui.notice("info", `已取消,权限模式仍为 ${permissionModeLabel(current)}`);
171
+ return;
172
+ }
167
173
  session.setPermissionMode(mode);
168
174
  ui.notice("info", `权限模式:${permissionModeLabel(mode)}`);
169
175
  return;
@@ -176,6 +182,10 @@ async function handlePick(what, ui) {
176
182
  title: "思考级别",
177
183
  items: thinkingItems(reasoning),
178
184
  selected: session.state.thinkingLevel,
185
+ currentValue: session.state.thinkingLevel,
186
+ filterable: false,
187
+ numberKeys: true,
188
+ footer: `↑↓ 选择 · 1-${THINKING_LEVELS.length} 直接选 · Enter 确认 · Esc 取消`,
179
189
  });
180
190
  if (picked !== undefined)
181
191
  session.setThinkingLevel(picked.value);
@@ -222,6 +232,7 @@ export async function runInteractiveCommand(line, ui) {
222
232
  runtime: ui.runtime,
223
233
  session: () => ui.session(),
224
234
  switchSession: (request) => ui.switchSession(request),
235
+ ...(ui.confirmMode !== undefined ? { confirmPermissionMode: ui.confirmMode } : {}),
225
236
  });
226
237
  if (result === undefined)
227
238
  return false;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 通用确认框:底部覆盖层 + `Box`,选项样式与审批对话框一致(终端界面视觉设计 v1 §3.11)。
3
+ *
4
+ * - 编号选项 `› 1. 进入 Bypass y`,选中行 `›` + `selection` 底色,右侧按键提示 `dim`;
5
+ * - 按键:↑↓ 移动(循环)、Enter 确认、数字直选、选项按键提示里的单个字母直选(如 `y` / `n`);
6
+ * Esc / Ctrl+C / `tui.select.cancel` 取消(返回 undefined);
7
+ * - 宽 < 56 列紧凑:去掉空行、按键提示只留第一个、底部提示缩短。字形取 `theme.glyphs`(ASCII 回退)。
8
+ *
9
+ * 用途:进入 Bypass permissions 前的确认(`confirmBypass`,permissions/bypass.ts)。
10
+ */
11
+ import { type Component, type Focusable, type Keybindings, type OverlayHandle, type OverlayOptions, type SemanticColor, type Theme } from "../../tui.js";
12
+ export interface ChoiceOption {
13
+ label: string;
14
+ /** 右侧按键提示(空格分隔);其中的单个字母同时是直选键。 */
15
+ keys: string;
16
+ }
17
+ export interface ChoiceSpec {
18
+ title: string;
19
+ /** 正文(已着色),选项之上。 */
20
+ body: readonly string[];
21
+ options: readonly ChoiceOption[];
22
+ /** 缺省选中的下标。 */
23
+ selected?: number;
24
+ borderColor?: SemanticColor;
25
+ }
26
+ export interface ChoiceHost {
27
+ theme: Theme;
28
+ keybindings?: Keybindings;
29
+ showOverlay(component: Component, options: OverlayOptions): OverlayHandle;
30
+ }
31
+ export declare class ChoiceDialog implements Component, Focusable {
32
+ private readonly spec;
33
+ private readonly host;
34
+ private readonly done;
35
+ focused: boolean;
36
+ private selected;
37
+ constructor(spec: ChoiceSpec, host: Omit<ChoiceHost, "showOverlay">, done: (index: number | undefined) => void);
38
+ get selectedIndex(): number;
39
+ handleInput(data: string): void;
40
+ render(width: number): string[];
41
+ private optionLines;
42
+ invalidate(): void;
43
+ }
44
+ /** 打开确认框(底部覆盖层):返回选中的下标,取消返回 undefined。 */
45
+ export declare function openChoice(host: ChoiceHost, spec: ChoiceSpec): Promise<number | undefined>;
46
+ /** Bypass 确认框的内容(缺省选中「取消」,边框 warning)。 */
47
+ export declare function bypassChoiceSpec(theme: Theme): ChoiceSpec;
48
+ /** 进入 Bypass 前问一次:选「进入 Bypass」返回 true,取消 / 选「取消」返回 false。 */
49
+ export declare function confirmBypass(host: ChoiceHost): Promise<boolean>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 通用确认框:底部覆盖层 + `Box`,选项样式与审批对话框一致(终端界面视觉设计 v1 §3.11)。
3
+ *
4
+ * - 编号选项 `› 1. 进入 Bypass y`,选中行 `›` + `selection` 底色,右侧按键提示 `dim`;
5
+ * - 按键:↑↓ 移动(循环)、Enter 确认、数字直选、选项按键提示里的单个字母直选(如 `y` / `n`);
6
+ * Esc / Ctrl+C / `tui.select.cancel` 取消(返回 undefined);
7
+ * - 宽 < 56 列紧凑:去掉空行、按键提示只留第一个、底部提示缩短。字形取 `theme.glyphs`(ASCII 回退)。
8
+ *
9
+ * 用途:进入 Bypass permissions 前的确认(`confirmBypass`,permissions/bypass.ts)。
10
+ */
11
+ import { BYPASS_CHOICES, BYPASS_CONFIRM_TITLE, BYPASS_DEFAULT_CHOICE, BYPASS_RISK_LINES, } from "../../permissions/bypass.js";
12
+ import { Box, defaultKeybindings, matchesKey, padToWidth, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "../../tui.js";
13
+ /** 宽度低于它(Box 外宽)时用紧凑布局。 */
14
+ const COMPACT_WIDTH = 56;
15
+ export class ChoiceDialog {
16
+ spec;
17
+ host;
18
+ done;
19
+ focused = false;
20
+ selected;
21
+ constructor(spec, host, done) {
22
+ this.spec = spec;
23
+ this.host = host;
24
+ this.done = done;
25
+ this.selected = Math.max(0, Math.min(spec.options.length - 1, spec.selected ?? 0));
26
+ }
27
+ get selectedIndex() {
28
+ return this.selected;
29
+ }
30
+ handleInput(data) {
31
+ const keys = this.host.keybindings ?? defaultKeybindings;
32
+ const n = this.spec.options.length;
33
+ const letter = data.length === 1 ? data.toLowerCase() : "";
34
+ const byLetter = letter === "" || !/^[a-z]$/.test(letter)
35
+ ? -1
36
+ : this.spec.options.findIndex((o) => o.keys.split(/\s+/).includes(letter));
37
+ if (byLetter !== -1)
38
+ this.done(byLetter);
39
+ else if (matchesKey(data, "escape") ||
40
+ keys.matches(data, "app.clear") ||
41
+ keys.matches(data, "tui.select.cancel")) {
42
+ this.done(undefined);
43
+ }
44
+ else if (/^[1-9]$/.test(data) && Number(data) <= n)
45
+ this.done(Number(data) - 1);
46
+ else if (keys.matches(data, "tui.select.up"))
47
+ this.selected = (this.selected + n - 1) % n;
48
+ else if (keys.matches(data, "tui.select.down"))
49
+ this.selected = (this.selected + 1) % n;
50
+ else if (matchesKey(data, "enter"))
51
+ this.done(this.selected);
52
+ }
53
+ render(width) {
54
+ const { theme } = this.host;
55
+ const compact = width + 4 < COMPACT_WIDTH;
56
+ const lines = [];
57
+ for (const line of this.spec.body)
58
+ lines.push(...wrapTextWithAnsi(line, width));
59
+ if (!compact)
60
+ lines.push("");
61
+ lines.push(...this.optionLines(width, compact));
62
+ if (!compact)
63
+ lines.push("");
64
+ const g = theme.glyphs;
65
+ const arrows = g.arrowUp + g.arrowDown;
66
+ const n = this.spec.options.length;
67
+ lines.push(theme.fg("dim", compact
68
+ ? `${arrows} Enter · 1-${n} · Esc`
69
+ : `${arrows} 选择 · Enter 确认 · 1-${n} 直接选 · Esc 取消`));
70
+ return lines.map((l) => truncateToWidth(l, width));
71
+ }
72
+ optionLines(width, compact) {
73
+ const { theme } = this.host;
74
+ const g = theme.glyphs;
75
+ const shown = this.spec.options.map((o) => (compact ? o.keys.split(/\s+/)[0] : o.keys));
76
+ const keyWidth = Math.max(...shown.map((k) => visibleWidth(k)));
77
+ return this.spec.options.map((option, i) => {
78
+ const selected = i === this.selected;
79
+ const left = `${selected ? g.prompt : " "} ${i + 1}. ${option.label}`;
80
+ const right = padToWidth(shown[i], keyWidth);
81
+ const room = Math.max(1, width - keyWidth - 1);
82
+ const label = selected ? theme.fg("accent", theme.bold(left)) : left;
83
+ const line = padToWidth(truncateToWidth(label, room), room) + " " + theme.fg("dim", right);
84
+ return selected ? theme.bg("selection", line) : line;
85
+ });
86
+ }
87
+ invalidate() { }
88
+ }
89
+ /** 打开确认框(底部覆盖层):返回选中的下标,取消返回 undefined。 */
90
+ export function openChoice(host, spec) {
91
+ return new Promise((resolve) => {
92
+ let handle;
93
+ let settled = false;
94
+ const dialog = new ChoiceDialog(spec, host, (index) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ handle?.hide();
99
+ resolve(index);
100
+ });
101
+ handle = host.showOverlay(new Box(dialog, {
102
+ title: spec.title,
103
+ theme: host.theme,
104
+ ...(spec.borderColor !== undefined ? { borderColor: spec.borderColor } : {}),
105
+ }), { anchor: "bottom" });
106
+ });
107
+ }
108
+ /** Bypass 确认框的内容(缺省选中「取消」,边框 warning)。 */
109
+ export function bypassChoiceSpec(theme) {
110
+ return {
111
+ title: BYPASS_CONFIRM_TITLE,
112
+ body: BYPASS_RISK_LINES.map((line, i) => (i === 0 ? theme.fg("warning", line) : line)),
113
+ options: BYPASS_CHOICES,
114
+ selected: BYPASS_DEFAULT_CHOICE,
115
+ borderColor: "warning",
116
+ };
117
+ }
118
+ /** 进入 Bypass 前问一次:选「进入 Bypass」返回 true,取消 / 选「取消」返回 false。 */
119
+ export async function confirmBypass(host) {
120
+ return (await openChoice(host, bypassChoiceSpec(host.theme))) === 0;
121
+ }
@@ -28,12 +28,14 @@ import { startupInfo, startupScreenLevel, tildePath } from "../../cli/startup-sc
28
28
  import { KEYBINDINGS_FILE } from "../../config/paths.js";
29
29
  import { AmaError, isAmaError } from "../../errors.js";
30
30
  import { Container, Editor, Keybindings, Loader, ProcessTerminal, Spacer, TUI, Text, createTheme, detectCapabilities, resolveAscii, resolveThemeName, loadKeybindingsFile, } from "../../tui.js";
31
+ import { BYPASS_MODE, createBypassGate } from "../../permissions/bypass.js";
31
32
  import { onTerminationSignals } from "../shared.js";
32
33
  import { AgentUi } from "./agent-ui.js";
33
34
  import { ApprovalDialogBroker, approvalOutcomeText } from "./approval-dialog.js";
34
35
  import { mergingBroker } from "./approval-merge.js";
35
36
  import { ALL_COMMANDS, runInteractiveCommand } from "./commands.js";
36
37
  import { InteractiveCompletion } from "./completion.js";
38
+ import { confirmBypass } from "./confirm-dialog.js";
37
39
  import { createKeyDispatch } from "./key-dispatch.js";
38
40
  import { MessageView, exitSummaryLines } from "./message-view.js";
39
41
  import { openPicker } from "./pickers.js";
@@ -270,6 +272,8 @@ export function runInteractiveMode(runtime, context, options = {}) {
270
272
  columns: () => terminal.columns,
271
273
  rows: () => terminal.rows,
272
274
  };
275
+ // 进入 Bypass 前确认一次;启动时已是 Bypass(命令行 / 配置指定)视为已确认
276
+ const confirmMode = createBypassGate(() => confirmBypass(pickerHost), session.state.permissionMode === BYPASS_MODE);
273
277
  const rewind = createRewindFlow({
274
278
  ...pickerHost,
275
279
  session: () => session,
@@ -290,6 +294,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
290
294
  return next;
291
295
  },
292
296
  pick: (spec) => openPicker(pickerHost, spec),
297
+ confirmMode,
293
298
  rewind: () => rewind.open(),
294
299
  setDraft,
295
300
  notice,
@@ -350,6 +355,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
350
355
  onExpandToggle: (expanded) => view.setThinkingExpanded(expanded),
351
356
  onStatusLineToggle: () => area.toggle(),
352
357
  onPasteImage: () => void agentUi.paste(),
358
+ confirmMode,
353
359
  submit: (text, via) => submit(text, via),
354
360
  runCommand: (line) => void runCommand(line),
355
361
  exit: (code) => exit(code),
@@ -11,6 +11,7 @@
11
11
  * 输入历史;运行中 Esc 仍为中断,中断后交给 `onInterrupted`(中断即撤回)。
12
12
  */
13
13
  import type { AgentSession } from "../../agent/types.js";
14
+ import type { PermissionMode } from "../../permissions/types.js";
14
15
  import type { Editor, Keybindings } from "../../tui.js";
15
16
  import type { StatusBar } from "./status-bar.js";
16
17
  import type { ToolTracker } from "./tool-view.js";
@@ -31,6 +32,11 @@ export interface KeyDispatchDeps {
31
32
  onExpandToggle?(expanded: boolean): void;
32
33
  /** Ctrl+G:切换底部信息行,返回切换后的布局。 */
33
34
  onStatusLineToggle?(): "full" | "compact";
35
+ /**
36
+ * 循环到某模式前的确认(进入 Bypass 前弹确认框,permissions/bypass.ts);返回 false 时跳过该模式,
37
+ * 落到循环里的下一个。没有时直接切换。
38
+ */
39
+ confirmMode?(mode: PermissionMode): boolean | Promise<boolean>;
34
40
  /** Ctrl+V:粘贴剪贴板图片(W5-U)。 */
35
41
  onPasteImage?(): void;
36
42
  submit(text: string, via: "followUp"): void;