feat(pi-ask-user): support RPC UI dialogs

This commit is contained in:
云服务部-叶林立
2026-09-01 11:04:31 +08:00
parent e80959cc3f
commit 44f27f7585
8 changed files with 291 additions and 17 deletions
+3 -3
View File
@@ -85,7 +85,7 @@
- `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。 - `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。 - Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。 - 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。 - `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。 - `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
@@ -250,9 +250,9 @@
## `pi-ask-user` 开发约定 ## `pi-ask-user` 开发约定
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`。 - 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI、RPC extension UI bridge 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`。
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。 - 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor。 - 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor;RPC 必须只依赖 Pi 标准 `select` / `input` extension UI、逐题执行、传递 abort signal,并把 dismiss/abort 归一化为取消。
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。 - 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。 - 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。 - 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
+2 -2
View File
@@ -16,7 +16,7 @@
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。 - 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。 - 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
- 本仓库维护的 `pi-condense`:从官方 `v2.9.1` 源码导入,把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;默认开启,瞬时摘要故障默认最多重试 2 次。 - 本仓库维护的 `pi-condense`:从官方 `v2.9.1` 源码导入,把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;默认开启,瞬时摘要故障默认最多重试 2 次。
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。 - 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。 - 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
@@ -129,7 +129,7 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
### 模型主动提问 ### 模型主动提问
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具仅在交互式 TUI 中运行,最多一次提交 8 个问题,支持选择、自由文本、显式跳过、返回修改和最终复核;取消结果不会作为 AutoReview 的授权证据。 `pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具最多一次提交 8 个问题:交互式 TUI 支持选择、自由文本、显式跳过、返回修改和最终复核;Paseo 等具备 Pi extension UI bridge 的 RPC 客户端使用逐题 `select` / `input` 对话框。取消结果不会作为 AutoReview 的授权证据,JSON/print 等无交互 UI 模式保持拒绝执行。
### 工具与搜索路由 ### 工具与搜索路由
+8 -5
View File
@@ -4,16 +4,17 @@ A locally maintained Pi extension that consolidates the upstream `question.ts` a
## Tool ## Tool
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction: The extension registers `ask_user_question`. The model can present one to eight questions through an interactive Pi UI:
- `select`: one to eight stable `{ value, label, description? }` choices, always followed by a free-form choice that the model cannot disable; - `select`: one to eight stable `{ value, label, description? }` choices, always followed by a free-form choice that the model cannot disable;
- `text`: a free-form Editor answer; - `text`: a free-form answer;
- `required: false`: lets the user explicitly skip the question; - `required: false`: lets the user explicitly skip the question;
- multiple questions: progress tabs plus a final review page. - TUI mode: one sequential custom interaction with progress tabs and a final review page;
- RPC mode: standard Pi `select` / `input` dialogs shown one question at a time by compatible clients such as Paseo.
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages. The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
## Controls ## TUI controls
- `↑` / `↓`: move through choices; - `↑` / `↓`: move through choices;
- `Enter`: select, edit, or submit; - `Enter`: select, edit, or submit;
@@ -22,6 +23,8 @@ The model should call the tool only when a missing decision, preference, or clar
The outer component propagates focus to the embedded Editor for IME cursor positioning. The outer component propagates focus to the embedded Editor for IME cursor positioning.
RPC clients use their native dialog controls. Dismissing a dialog or aborting the tool returns `cancelled: true`; required blank text is requested again, while optional blank text is recorded as an explicit skip.
## Result contract ## Result contract
Successful results include readable text and structured `details`: Successful results include readable text and structured `details`:
@@ -48,7 +51,7 @@ Successful results include readable text and structured `details`:
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence. The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer. The tool requires dialog-capable interactive UI. TUI mode uses the custom questionnaire, RPC mode uses Pi's standard extension UI bridge, and JSON/print modes receive a tool error rather than an invented answer.
## Development ## Development
+1 -1
View File
@@ -7,6 +7,6 @@
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24). Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, keep a user-controlled free-form escape hatch on every select question, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation. The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, keep a user-controlled free-form escape hatch on every select question, validation, bounded schemas, cancellation/abort handling, width-aware rendering, IME focus propagation, and a standard Pi extension-UI fallback for dialog-capable RPC clients.
The copied MIT license and original copyright notice are retained in `LICENSE`. The copied MIT license and original copyright notice are retained in `LICENSE`.
+142
View File
@@ -0,0 +1,142 @@
import type { NormalizedQuestion, QuestionOptionInput, UserAnswer, AskUserResult } from "./types.ts";
interface RpcDialogOptions {
signal?: AbortSignal;
}
export interface RpcQuestionUI {
select(title: string, options: string[], opts?: RpcDialogOptions): Promise<string | undefined>;
input(title: string, placeholder?: string, opts?: RpcDialogOptions): Promise<string | undefined>;
notify(message: string, type?: "info" | "warning" | "error"): void;
}
type RpcChoice =
| { kind: "option"; display: string; option: QuestionOptionInput }
| { kind: "custom"; display: string }
| { kind: "skip"; display: string };
type TextResponse =
| { kind: "answer"; text: string }
| { kind: "skipped" }
| { kind: "cancelled" };
function dialogTitle(question: NormalizedQuestion, index: number, total: number): string {
const progress = total > 1 ? `[${index + 1}/${total}] ` : "";
return `${progress}${question.label}: ${question.prompt}`;
}
function choicesFor(question: NormalizedQuestion): RpcChoice[] {
const choices: RpcChoice[] = question.options.map((option, index) => ({
kind: "option",
display: `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`,
option,
}));
choices.push({ kind: "custom", display: "Type something." });
if (!question.required) choices.push({ kind: "skip", display: "Skip this question." });
return choices;
}
function saveTextAnswer(question: NormalizedQuestion, text: string, custom: boolean): UserAnswer {
return {
id: question.id,
question: question.prompt,
type: question.type,
answer: text,
value: text,
label: text,
custom,
skipped: false,
};
}
function saveSkippedAnswer(question: NormalizedQuestion): UserAnswer {
return {
id: question.id,
question: question.prompt,
type: question.type,
answer: "",
value: "",
label: "Skipped",
custom: false,
skipped: true,
};
}
function cancelledResult(questions: NormalizedQuestion[], answers: UserAnswer[]): AskUserResult {
return { version: 1, cancelled: true, questions, answers };
}
async function requestText(
ui: RpcQuestionUI,
title: string,
required: boolean,
placeholder: string,
signal?: AbortSignal,
): Promise<TextResponse> {
while (true) {
if (signal?.aborted) return { kind: "cancelled" };
const response = await ui.input(title, placeholder, { signal });
if (signal?.aborted || response === undefined) return { kind: "cancelled" };
const text = response.trim();
if (text) return { kind: "answer", text };
if (!required) return { kind: "skipped" };
ui.notify("An answer is required.", "warning");
}
}
export async function runRpcQuestions(
ui: RpcQuestionUI,
questions: NormalizedQuestion[],
signal?: AbortSignal,
): Promise<AskUserResult> {
const answers: UserAnswer[] = [];
for (const [index, question] of questions.entries()) {
if (signal?.aborted) return cancelledResult(questions, answers);
const title = dialogTitle(question, index, questions.length);
if (question.type === "text") {
const response = await requestText(
ui,
title,
question.required,
question.required ? "Type your answer" : "Type an answer, or leave blank to skip",
signal,
);
if (response.kind === "cancelled") return cancelledResult(questions, answers);
answers.push(response.kind === "skipped" ? saveSkippedAnswer(question) : saveTextAnswer(question, response.text, false));
continue;
}
const choices = choicesFor(question);
const selected = await ui.select(title, choices.map((choice) => choice.display), { signal });
if (signal?.aborted || selected === undefined) return cancelledResult(questions, answers);
const choice = choices.find((item) => item.display === selected);
if (!choice) throw new Error("RPC UI returned an unknown choice");
if (choice.kind === "skip") {
answers.push(saveSkippedAnswer(question));
continue;
}
if (choice.kind === "custom") {
const response = await requestText(ui, `${title} — custom answer`, true, "Type a different answer", signal);
if (response.kind !== "answer") return cancelledResult(questions, answers);
answers.push(saveTextAnswer(question, response.text, true));
continue;
}
answers.push({
id: question.id,
question: question.prompt,
type: question.type,
answer: choice.option.label,
value: choice.option.value,
label: choice.option.label,
custom: false,
skipped: false,
});
}
return { version: 1, cancelled: false, questions, answers };
}
+9 -4
View File
@@ -3,6 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
import { formatAnswers, normalizeQuestions } from "./normalize.ts"; import { formatAnswers, normalizeQuestions } from "./normalize.ts";
import { AskUserParamsSchema } from "./schema.ts"; import { AskUserParamsSchema } from "./schema.ts";
import { runRpcQuestions } from "./rpc.ts";
import type { AskUserResult, QuestionInput } from "./types.ts"; import type { AskUserResult, QuestionInput } from "./types.ts";
import { AskUserView } from "./view.ts"; import { AskUserView } from "./view.ts";
@@ -15,23 +16,27 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
name: "ask_user_question", name: "ask_user_question",
label: "Ask User", label: "Ask User",
description: description:
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.", "Ask the user one or more structured questions in an interactive UI when their input is genuinely needed to continue. Supports TUI and RPC clients, choices, free-form text, optional custom answers, and cancellation. Do not ask for information already stated by the user.",
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing", promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
parameters: AskUserParamsSchema, parameters: AskUserParamsSchema,
executionMode: "sequential", executionMode: "sequential",
async execute(_toolCallId, params, signal, _onUpdate, ctx) { async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const questions = normalizeQuestions(params.questions as QuestionInput[]); const questions = normalizeQuestions(params.questions as QuestionInput[]);
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode"); if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
throw new Error("ask_user_question requires interactive TUI or RPC UI mode");
}
if (signal?.aborted) { if (signal?.aborted) {
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] }; const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result }; return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
} }
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) => const result = ctx.mode === "tui"
? await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
new AskUserView(tui, theme, questions, done, signal), new AskUserView(tui, theme, questions, done, signal),
); )
: await runRpcQuestions(ctx.ui, questions, signal);
if (result.cancelled) { if (result.cancelled) {
return { return {
+124
View File
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeQuestions } from "../src/normalize.ts";
import { runRpcQuestions, type RpcQuestionUI } from "../src/rpc.ts";
function mockUi(responses: {
select?: Array<string | undefined>;
input?: Array<string | undefined>;
}): RpcQuestionUI & {
calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }>;
notifications: Array<{ message: string; type?: "info" | "warning" | "error" }>;
} {
const selectResponses = [...(responses.select ?? [])];
const inputResponses = [...(responses.input ?? [])];
const calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }> = [];
const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [];
return {
calls,
notifications,
async select(title, options) {
calls.push({ kind: "select", title, options });
assert.ok(selectResponses.length > 0, "unexpected select dialog");
return selectResponses.shift();
},
async input(title) {
calls.push({ kind: "input", title });
assert.ok(inputResponses.length > 0, "unexpected input dialog");
return inputResponses.shift();
},
notify(message, type) {
notifications.push({ message, type });
},
};
}
test("asks RPC questions sequentially and preserves stable option values", async () => {
const questions = normalizeQuestions([
{
id: "language",
label: "Language",
prompt: "Which language?",
type: "select",
options: [
{ value: "ts", label: "TypeScript", description: "Strict typing" },
{ value: "js", label: "JavaScript" },
],
},
{ id: "notes", prompt: "Anything else?", type: "text", required: false },
]);
const ui = mockUi({ select: ["2. JavaScript"], input: [" "] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, false);
assert.deepEqual(result.answers, [
{
id: "language",
question: "Which language?",
type: "select",
answer: "JavaScript",
value: "js",
label: "JavaScript",
custom: false,
skipped: false,
},
{
id: "notes",
question: "Anything else?",
type: "text",
answer: "",
value: "",
label: "Skipped",
custom: false,
skipped: true,
},
]);
assert.match(ui.calls[0]?.title ?? "", /^\[1\/2\] Language:/);
assert.ok(ui.calls[0]?.options?.includes("1. TypeScript — Strict typing"));
});
test("supports custom RPC answers and repeats required blank input", async () => {
const questions = normalizeQuestions([{
id: "other",
prompt: "Choose or write one",
type: "select",
options: [{ value: "known", label: "Known" }],
}]);
const ui = mockUi({ select: ["Type something."], input: [" ", " Custom value "] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, false);
assert.equal(result.answers[0]?.answer, "Custom value");
assert.equal(result.answers[0]?.custom, true);
assert.deepEqual(ui.notifications, [{ message: "An answer is required.", type: "warning" }]);
});
test("returns cancelled with partial answers when an RPC dialog is dismissed", async () => {
const questions = normalizeQuestions([
{ id: "name", prompt: "Name?", type: "text" },
{ id: "confirm", prompt: "Continue?", type: "select", options: [{ value: "yes", label: "Yes" }] },
]);
const ui = mockUi({ input: ["Ada"], select: [undefined] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, true);
assert.equal(result.answers.length, 1);
assert.equal(result.answers[0]?.answer, "Ada");
});
test("does not open RPC dialogs after abort", async () => {
const questions = normalizeQuestions([{ id: "name", prompt: "Name?", type: "text" }]);
const ui = mockUi({});
const controller = new AbortController();
controller.abort();
const result = await runRpcQuestions(ui, questions, controller.signal);
assert.equal(result.cancelled, true);
assert.deepEqual(result.answers, []);
assert.deepEqual(ui.calls, []);
});
+1 -1
View File
@@ -42,7 +42,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery | | `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
| `memory-management` | Official Hippo remember/outcome tools | | `memory-management` | Official Hippo remember/outcome tools |
| `subagents` | Launch agents, collect background results, and steer running agents | | `subagents` | Launch agents, collect background results, and steer running agents |
| `user-interaction` | `ask_user_question` model-initiated TUI questions | | `user-interaction` | `ask_user_question` model-initiated TUI/RPC questions |
| `chrome-navigation` | Chrome bridge setup, tabs, navigation, snapshots/find/inspect, waits, and screenshots | | `chrome-navigation` | Chrome bridge setup, tabs, navigation, snapshots/find/inspect, waits, and screenshots |
| `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input | | `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input |
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload | | `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload |