pi-cc-extensions 0.8.7 → 0.8.9
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/README.md +21 -5
- package/assets/readme/welcome.webp +0 -0
- package/extensions/ask-user-question/LICENSE +21 -0
- package/extensions/ask-user-question/README.md +63 -0
- package/extensions/ask-user-question/ask-user-question.ts +244 -0
- package/extensions/ask-user-question/config.ts +75 -0
- package/extensions/ask-user-question/events.ts +55 -0
- package/extensions/ask-user-question/index.ts +17 -0
- package/extensions/ask-user-question/reconcile.ts +47 -0
- package/extensions/ask-user-question/rpc-fallback.ts +160 -0
- package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
- package/extensions/ask-user-question/state/dialog-height.ts +16 -0
- package/extensions/ask-user-question/state/key-router.ts +269 -0
- package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
- package/extensions/ask-user-question/state/row-intent.ts +143 -0
- package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
- package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
- package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
- package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
- package/extensions/ask-user-question/state/state-reducer.ts +282 -0
- package/extensions/ask-user-question/state/state.ts +49 -0
- package/extensions/ask-user-question/tool/format-answer.ts +29 -0
- package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
- package/extensions/ask-user-question/tool/types.ts +145 -0
- package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
- package/extensions/ask-user-question/view/component-binding.ts +45 -0
- package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
- package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
- package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
- package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
- package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
- package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
- package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
- package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
- package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
- package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
- package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
- package/extensions/ask-user-question/view/props-adapter.ts +123 -0
- package/extensions/ask-user-question/view/stateful-view.ts +24 -0
- package/extensions/ask-user-question/view/tab-components.ts +16 -0
- package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
- package/package.json +14 -4
- package/themes/github-dark-default.json +91 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="./assets/readme/hero.svg" width="100%" alt="pi-cc-extensions:集成 Claude Code
|
|
2
|
+
<img src="./assets/readme/hero.svg" width="100%" alt="pi-cc-extensions:集成 Claude Code 风格界面、结构化问卷、上下文检查及 Agent 与 Session 引用的 Pi 效率扩展套件">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
|
-
面向 Pi 的终端效率扩展套件 · Claude Code 风格界面 · 上下文检查 · Agent 与 Session 引用
|
|
13
|
+
面向 Pi 的终端效率扩展套件 · Claude Code 风格界面 · 结构化问卷 · 上下文检查 · Agent 与 Session 引用
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
---
|
|
@@ -86,9 +86,23 @@
|
|
|
86
86
|
|
|
87
87
|
`mode` 可取 `on`、`off` 或 `compact`。旧版 `enabled: true/false` 会自动迁移为 `on/off`。排除名单使用精确工具名;手动修改配置后执行 `/reload`。`Agent` 的专用 renderer 始终保留。
|
|
88
88
|
|
|
89
|
-
|
|
90
89
|
Compact transcript 行为改编自 [avhagedorn/pi-compact-transcript](https://github.com/avhagedorn/pi-compact-transcript) v0.6.2(MIT,Alan Hagedorn)。
|
|
91
90
|
|
|
91
|
+
### 结构化问卷
|
|
92
|
+
|
|
93
|
+
`extensions/ask-user-question/index.ts` 内置 `ask_user_question` 工具,支持:
|
|
94
|
+
|
|
95
|
+
- 单选、多选、2–4 个选项及最多 4 个问题的标签页问卷
|
|
96
|
+
- `Type something.` 自定义回答、备注和 Markdown 预览
|
|
97
|
+
- RPC / ACP 原生对话框降级;无 UI 时自动移除工具
|
|
98
|
+
- TUI 使用 Pi 原生临时 editor component,不替换 editor factory:兼容默认滚动编辑器,也兼容 `pi-zentui` fixed-editor
|
|
99
|
+
- 响应式高度预算:常规终端为历史 transcript 至少保留 6 行、约 30% 高度;`Ctrl+]` 可折叠为单行并保留答案
|
|
100
|
+
- UI 固定使用英文,不加载国际化套件或语言包
|
|
101
|
+
|
|
102
|
+
配置文件为 `~/.config/rpiv-ask-user-question/config.json`。可通过 `collapseKey` 修改折叠快捷键,详见 [`extensions/ask-user-question/README.md`](./extensions/ask-user-question/README.md)。
|
|
103
|
+
|
|
104
|
+
该扩展基于 [`@juicesharp/rpiv-ask-user-question`](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-ask-user-question) v2.1.0(MIT)内置,并包含本项目针对默认滚动编辑器、固定编辑器与历史消息视口的兼容优化。
|
|
105
|
+
|
|
92
106
|
### 上下文窗口查看
|
|
93
107
|
|
|
94
108
|
`extensions/context.ts` 注册 `/context`,展示当前上下文窗口的使用分布,并可进一步预览:
|
|
@@ -140,6 +154,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
140
154
|
```text
|
|
141
155
|
/context
|
|
142
156
|
/ccstyle on
|
|
157
|
+
# ask_user_question 会自动作为工具提供给模型
|
|
143
158
|
```
|
|
144
159
|
|
|
145
160
|
## 本地开发
|
|
@@ -161,6 +176,8 @@ pi install /absolute/path/to/pi-cc-extensions
|
|
|
161
176
|
/reload
|
|
162
177
|
```
|
|
163
178
|
|
|
179
|
+
`ask-user-question` 的 TUI 模块会被进程缓存;修改该目录或重装依赖后需完整退出并重启 Pi,不能只执行 `/reload`。
|
|
180
|
+
|
|
164
181
|
## 兼容性
|
|
165
182
|
|
|
166
183
|
- Node.js `>=22.19.0`
|
|
@@ -172,7 +189,6 @@ pi install /absolute/path/to/pi-cc-extensions
|
|
|
172
189
|
| 扩展 | 作用 |
|
|
173
190
|
| ------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
174
191
|
| `npm:pi-theme-picker` | 通过`/theme` 交互式切换 Pi 主题,支持模糊搜索和实时预览 |
|
|
175
|
-
| `npm:@juicesharp/rpiv-ask-user-question` | 提供`ask_user_question` 结构化问卷工具,支持单选、多选、预览和备注 |
|
|
176
192
|
| `npm:pi-mcp-adapter` | 将 MCP 服务接入 Pi,并通过代理工具按需发现,减少上下文占用 |
|
|
177
193
|
| `npm:@tintinweb/pi-subagents` | Claude Code 风格的并行 SubAgent、后台任务、任务编排、工作树隔离和自定义 Agent |
|
|
178
194
|
| `npm:@ayulab/pi-rewind` | 基于 checkpoint 的`/rewind` 回退,支持分别恢复代码、对话或两者 |
|
|
@@ -182,6 +198,6 @@ pi install /absolute/path/to/pi-cc-extensions
|
|
|
182
198
|
|
|
183
199
|
### 使用注意
|
|
184
200
|
|
|
185
|
-
-
|
|
201
|
+
- 内置问卷采用非 overlay 的临时 editor component,兼容 `pi-zentui` 固定编辑器;常规终端会保留历史消息视口。
|
|
186
202
|
- `pi-mcp-adapter` 默认延迟连接 MCP 服务,有助于节省上下文;包含凭据的 MCP 配置不要提交到仓库。
|
|
187
203
|
- `pi-compact-thinking` 和 `pi-zentui` 都涉及 Pi 内部 UI 行为,升级 Pi 后若出现异常,优先执行 `/reload` 或暂时停用对应扩展。
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 juicesharp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ask_user_question
|
|
2
|
+
|
|
3
|
+
English-only structured questionnaire bundled with `pi-cc-extensions`.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- One to four questions per call.
|
|
8
|
+
- Two to four authored options per question.
|
|
9
|
+
- Single-select and multi-select modes.
|
|
10
|
+
- Automatically appended `Type something.` custom-answer row.
|
|
11
|
+
- Optional notes and Markdown previews.
|
|
12
|
+
- Submit review for multi-question calls.
|
|
13
|
+
- RPC/ACP fallback through native select/input dialogs.
|
|
14
|
+
- Removed from the model's tool list when no UI is available.
|
|
15
|
+
|
|
16
|
+
## Editor compatibility
|
|
17
|
+
|
|
18
|
+
The TUI uses Pi's native temporary editor flow and does not replace the configured editor factory.
|
|
19
|
+
|
|
20
|
+
- Pi's default scrolling editor is restored with its draft and focus after the questionnaire closes.
|
|
21
|
+
- Fixed-editor compositors remain active.
|
|
22
|
+
- On terminals with at least 16 rows, the questionnaire preserves at least six transcript rows and about 30% of terminal height.
|
|
23
|
+
- On smaller terminals, the questionnaire may use the full height to remain usable.
|
|
24
|
+
|
|
25
|
+
## Keyboard
|
|
26
|
+
|
|
27
|
+
| Key | Action |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `Up` / `Down` | Move between options |
|
|
30
|
+
| `Enter` | Confirm or activate the selected row |
|
|
31
|
+
| `Space` | Toggle a multi-select option |
|
|
32
|
+
| `Tab` / `Shift+Tab` | Switch question tabs |
|
|
33
|
+
| `n` | Open notes |
|
|
34
|
+
| `Ctrl+]` | Collapse or expand the questionnaire |
|
|
35
|
+
| `Esc` | Cancel |
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
Optional file: `~/.config/rpiv-ask-user-question/config.json`
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"collapseKey": "alt+o",
|
|
44
|
+
"guidance": {
|
|
45
|
+
"promptSnippet": "Ask before guessing on ambiguous requirements",
|
|
46
|
+
"promptGuidelines": [
|
|
47
|
+
"Batch related questions into one ask_user_question call."
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Set `collapseKey` to `"off"` to disable collapsing. UI text is intentionally English-only; no localization package or locale files are loaded.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
The TUI module graph is cached for the process lifetime. After changing this extension or reinstalling dependencies, fully restart Pi instead of relying only on `/reload`.
|
|
58
|
+
|
|
59
|
+
## Attribution
|
|
60
|
+
|
|
61
|
+
Based on `@juicesharp/rpiv-ask-user-question` v2.1.0 with local editor-layout and transcript-viewport compatibility changes.
|
|
62
|
+
|
|
63
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, resolveCollapseKey, validateGuidanceFields } from "./config.js";
|
|
3
|
+
import {
|
|
4
|
+
ASK_USER_BLOCKED_EVENT,
|
|
5
|
+
ASK_USER_PROMPT_EVENT,
|
|
6
|
+
type AskUserBlockedEventPayload,
|
|
7
|
+
type AskUserPromptEventPayload,
|
|
8
|
+
} from "./events.js";
|
|
9
|
+
// Static import is fine: rpc-fallback does not pull in the TUI render graph.
|
|
10
|
+
import { hasDialogUI, runRpcQuestionnaire } from "./rpc-fallback.js";
|
|
11
|
+
import { ROW_INTENT_META, sentinelsToAppend } from "./state/row-intent.js";
|
|
12
|
+
import { buildQuestionnaireResponse, buildToolResult } from "./tool/response-envelope.js";
|
|
13
|
+
import {
|
|
14
|
+
MAX_OPTIONS,
|
|
15
|
+
MAX_QUESTIONS,
|
|
16
|
+
MIN_OPTIONS,
|
|
17
|
+
type QuestionData,
|
|
18
|
+
type QuestionnaireError,
|
|
19
|
+
type QuestionnaireResult,
|
|
20
|
+
type QuestionParams,
|
|
21
|
+
QuestionParamsSchema,
|
|
22
|
+
} from "./tool/types.js";
|
|
23
|
+
import { validateQuestionnaire } from "./tool/validate-questionnaire.js";
|
|
24
|
+
import type { WrappingSelectItem } from "./view/components/wrapping-select.js";
|
|
25
|
+
|
|
26
|
+
function emitAskUserPromptEvent(pi: ExtensionAPI, params: QuestionParams): void {
|
|
27
|
+
const payload: AskUserPromptEventPayload = {
|
|
28
|
+
questions: params.questions.map((q) => ({
|
|
29
|
+
question: q.question,
|
|
30
|
+
header: q.header,
|
|
31
|
+
multiSelect: q.multiSelect ?? false,
|
|
32
|
+
options: q.options.map((o) => ({
|
|
33
|
+
label: o.label,
|
|
34
|
+
description: o.description,
|
|
35
|
+
hasPreview: typeof o.preview === "string" && o.preview.length > 0,
|
|
36
|
+
})),
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
pi.events.emit(ASK_USER_PROMPT_EVENT, payload);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emitAskUserBlockedEvent(pi: ExtensionAPI, active: boolean): void {
|
|
43
|
+
const payload: AskUserBlockedEventPayload = { active };
|
|
44
|
+
pi.events.emit(ASK_USER_BLOCKED_EVENT, payload);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Canonical tool name — single source of truth shared with the reconcile module. */
|
|
48
|
+
export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
|
|
49
|
+
|
|
50
|
+
const ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
|
|
51
|
+
|
|
52
|
+
const ERROR_NO_CUSTOM_UI =
|
|
53
|
+
"Error: this client cannot render the questionnaire (custom UI is unavailable, e.g. RPC/ACP hosts such as Zed or Paseo). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
|
|
54
|
+
|
|
55
|
+
const ERROR_SESSION_LOAD_FAILED =
|
|
56
|
+
"Error: the questionnaire UI failed to load — the host's installed dependencies were likely replaced or removed on disk while Pi was running (e.g. a package-manager install touched the store). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user that restoring this tool requires repairing the install if needed and restarting Pi.";
|
|
57
|
+
|
|
58
|
+
const ERROR_STALE_MODULE_CACHE =
|
|
59
|
+
"Error: the questionnaire UI cannot load — the host's module cache went stale after an earlier failed load (typically dependencies replaced on disk mid-session). This is unrecoverable within the current Pi process. The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user to restart Pi to restore this tool.";
|
|
60
|
+
|
|
61
|
+
/** Delay before the background session-graph pre-warm; mirrors rpiv-workflow's /wf prewarm. */
|
|
62
|
+
export const PREWARM_DELAY_MS = 2000;
|
|
63
|
+
|
|
64
|
+
type SessionModule = typeof import("./state/questionnaire-session.js");
|
|
65
|
+
|
|
66
|
+
type SessionLoad =
|
|
67
|
+
| { ok: true; module: SessionModule }
|
|
68
|
+
| { ok: false; error: Extract<QuestionnaireError, "session_load_failed" | "stale_module_cache">; message: string };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lazy-load the ~560ms QuestionnaireSession view/TUI render graph, guarding
|
|
72
|
+
* the two failure shapes of issue #107. Pi's jiti loader registers a module in
|
|
73
|
+
* its graph cache BEFORE evaluating the body and does not evict it when
|
|
74
|
+
* evaluation throws (jiti 2.7.0), so one failed load — e.g. `pnpm install
|
|
75
|
+
* --force` replacing the store entry mid-session — leaves every later import
|
|
76
|
+
* of this specifier resolving to a namespace without the class. That state is
|
|
77
|
+
* unrecoverable in-process (cache-busting specifiers fail jiti resolution);
|
|
78
|
+
* both branches therefore return an LLM-facing envelope that names the restart
|
|
79
|
+
* requirement instead of leaking a bare "not a constructor" TypeError.
|
|
80
|
+
*/
|
|
81
|
+
export async function loadQuestionnaireSession(): Promise<SessionLoad> {
|
|
82
|
+
let mod: SessionModule;
|
|
83
|
+
try {
|
|
84
|
+
mod = await import("./state/questionnaire-session.js");
|
|
85
|
+
} catch (e) {
|
|
86
|
+
const cause = e instanceof Error ? e.message : String(e);
|
|
87
|
+
return { ok: false, error: "session_load_failed", message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})` };
|
|
88
|
+
}
|
|
89
|
+
if (typeof mod.QuestionnaireSession !== "function") {
|
|
90
|
+
const keys = JSON.stringify(Object.keys(mod));
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "stale_module_cache",
|
|
94
|
+
message: `${ERROR_STALE_MODULE_CACHE} (resolved namespace keys: ${keys})`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, module: mod };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildItemsForQuestion(question: QuestionData): WrappingSelectItem[] {
|
|
101
|
+
const items: WrappingSelectItem[] = question.options.map((o) => ({
|
|
102
|
+
kind: "option",
|
|
103
|
+
label: o.label,
|
|
104
|
+
description: o.description,
|
|
105
|
+
}));
|
|
106
|
+
for (const kind of sentinelsToAppend(question)) {
|
|
107
|
+
items.push({ kind, label: ROW_INTENT_META[kind].label });
|
|
108
|
+
}
|
|
109
|
+
return items;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const DEFAULT_PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`;
|
|
113
|
+
export const DEFAULT_PROMPT_GUIDELINES: string[] = [
|
|
114
|
+
`Use ask_user_question whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
|
|
115
|
+
`Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended "Type something." row on every question, or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.`,
|
|
116
|
+
`Set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The "Type something." row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append "(Recommended)" to its label.`,
|
|
117
|
+
"Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export function registerAskUserQuestionTool(pi: ExtensionAPI): void {
|
|
121
|
+
const guidance = validateGuidanceFields(loadConfig().guidance);
|
|
122
|
+
pi.registerTool({
|
|
123
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
124
|
+
label: "Ask User Question",
|
|
125
|
+
description: `Ask the user one or more structured questions during execution. Use when you need to:
|
|
126
|
+
1. Gather user preferences or requirements
|
|
127
|
+
2. Clarify ambiguous instructions
|
|
128
|
+
3. Get decisions on implementation choices as you work
|
|
129
|
+
4. Offer choices to the user about what direction to take
|
|
130
|
+
|
|
131
|
+
Usage notes:
|
|
132
|
+
- Users can type a custom answer via the automatically appended "Type something." row on every question or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
|
|
133
|
+
- Use multiSelect: true when multiple answers are valid. The "Type something." row is available on every question, including when options carry a \`preview\`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.
|
|
134
|
+
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label.
|
|
135
|
+
|
|
136
|
+
Preview feature:
|
|
137
|
+
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
|
|
138
|
+
- ASCII mockups of UI layouts or components
|
|
139
|
+
- Code snippets showing different implementations
|
|
140
|
+
- Diagram variations
|
|
141
|
+
- Configuration examples
|
|
142
|
+
|
|
143
|
+
Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).`,
|
|
144
|
+
promptSnippet: guidance.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
|
|
145
|
+
promptGuidelines: guidance.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
|
|
146
|
+
parameters: QuestionParamsSchema,
|
|
147
|
+
|
|
148
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
149
|
+
const typed = params as unknown as QuestionParams;
|
|
150
|
+
if (!ctx.hasUI) return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
|
|
151
|
+
|
|
152
|
+
const validation = validateQuestionnaire(typed);
|
|
153
|
+
if (validation.ok === false) {
|
|
154
|
+
return buildToolResult(validation.message, {
|
|
155
|
+
answers: [],
|
|
156
|
+
cancelled: true,
|
|
157
|
+
error: validation.error,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Emit event for external listeners (e.g., notification plugins)
|
|
162
|
+
emitAskUserPromptEvent(pi, typed);
|
|
163
|
+
|
|
164
|
+
// RPC hosts (VSCode pendant, ACP clients like Zed/Paseo — issue #78):
|
|
165
|
+
// ui.custom() cannot render there, but the select/input dialog
|
|
166
|
+
// sub-protocol works. Hosts that advertise ctx.mode (pi ≥0.79) route to
|
|
167
|
+
// the sequential dialog walker up front, skipping the TUI render-graph
|
|
168
|
+
// import entirely; RPC builds that predate ctx.mode are caught by the
|
|
169
|
+
// custom()-resolved-undefined backstop below. See ./rpc-fallback.ts.
|
|
170
|
+
if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
|
|
171
|
+
emitAskUserBlockedEvent(pi, true);
|
|
172
|
+
try {
|
|
173
|
+
return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
|
|
174
|
+
} finally {
|
|
175
|
+
emitAskUserBlockedEvent(pi, false);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const itemsByTab: WrappingSelectItem[][] = typed.questions.map((q) => buildItemsForQuestion(q));
|
|
180
|
+
|
|
181
|
+
// Lazy — QuestionnaireSession pulls the ~560ms view/TUI render graph;
|
|
182
|
+
// load it only when the tool runs, not at extension registration.
|
|
183
|
+
const sessionLoad = await loadQuestionnaireSession();
|
|
184
|
+
if (sessionLoad.ok === false) {
|
|
185
|
+
return buildToolResult(sessionLoad.message, { answers: [], cancelled: true, error: sessionLoad.error });
|
|
186
|
+
}
|
|
187
|
+
const { QuestionnaireSession } = sessionLoad.module;
|
|
188
|
+
// Resolve the collapse/expand key spec from config. Default is `ctrl+]`; users
|
|
189
|
+
// with non-US layouts (e.g. Latin American, where `]` is shifted) can override
|
|
190
|
+
// via the `collapseKey` config field. `resolveCollapseKey` also accepts the
|
|
191
|
+
// sentinel value `"off"` to disable the shortcut entirely.
|
|
192
|
+
const collapseKey = resolveCollapseKey(loadConfig());
|
|
193
|
+
|
|
194
|
+
// Use Pi's native temporary editor flow without replacing the configured
|
|
195
|
+
// editor factory. The default scrolling editor is restored by the host, while
|
|
196
|
+
// fixed-editor compositors stay active and preserve their transcript viewport.
|
|
197
|
+
|
|
198
|
+
emitAskUserBlockedEvent(pi, true);
|
|
199
|
+
try {
|
|
200
|
+
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
|
|
201
|
+
const session = new QuestionnaireSession({
|
|
202
|
+
tui,
|
|
203
|
+
theme,
|
|
204
|
+
params: typed,
|
|
205
|
+
itemsByTab,
|
|
206
|
+
done,
|
|
207
|
+
collapseKey,
|
|
208
|
+
});
|
|
209
|
+
return session.component;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// A TUI questionnaire ALWAYS resolves a QuestionnaireResult (cancel
|
|
213
|
+
// included — state-reducer emits `{ answers, cancelled }`), so
|
|
214
|
+
// `undefined` uniquely means "host cannot render", never "user
|
|
215
|
+
// declined". RPC builds that predate ctx.mode land here: run the
|
|
216
|
+
// dialog walker when the host has the primitives; otherwise tell the
|
|
217
|
+
// model the user never saw the questions.
|
|
218
|
+
if (result === undefined) {
|
|
219
|
+
if (hasDialogUI(ctx.ui)) {
|
|
220
|
+
return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
|
|
221
|
+
}
|
|
222
|
+
return buildToolResult(ERROR_NO_CUSTOM_UI, { answers: [], cancelled: true, error: "no_custom_ui" });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return buildQuestionnaireResponse(result, typed);
|
|
226
|
+
} finally {
|
|
227
|
+
emitAskUserBlockedEvent(pi, false);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Pre-warm the lazy session graph once startup settles (#107). A graph
|
|
233
|
+
// evaluated while the paths Pi resolved at boot still exist stays in memory
|
|
234
|
+
// for the process lifetime, so later on-disk dependency churn (e.g. `pnpm
|
|
235
|
+
// install --force` replacing the store mid-session) can no longer poison
|
|
236
|
+
// jiti's graph cache. Swallowed failure is safe: the first real call
|
|
237
|
+
// re-imports and surfaces it through loadQuestionnaireSession's structured
|
|
238
|
+
// envelope. unref keeps the timer from holding a non-TUI embedder's process
|
|
239
|
+
// open.
|
|
240
|
+
const timer = setTimeout(() => void loadQuestionnaireSession().catch(() => undefined), PREWARM_DELAY_MS);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { buildQuestionnaireResponse, buildToolResult };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { GuidanceFields } from "@juicesharp/rpiv-config";
|
|
2
|
+
import { loadJsonConfigWithLegacyFallback, validateGuidanceFields } from "@juicesharp/rpiv-config";
|
|
3
|
+
|
|
4
|
+
/** Key spec for the questionnaire collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
|
|
5
|
+
export type CollapseKeySpec = string;
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_COLLAPSE_KEY: CollapseKeySpec = "ctrl+]";
|
|
8
|
+
export const COLLAPSE_KEY_OFF: CollapseKeySpec = "off";
|
|
9
|
+
|
|
10
|
+
export interface AskUserQuestionConfig {
|
|
11
|
+
guidance?: GuidanceFields;
|
|
12
|
+
/**
|
|
13
|
+
* Key spec for the collapse/expand shortcut, in the same format as pi-coding-agent
|
|
14
|
+
* keybinding ids (`modifier+key`, e.g. `ctrl+]`, `alt+o`, `ctrl+shift+h`). Defaults
|
|
15
|
+
* to `"ctrl+]"`. Set this to a key that is reachable on your keyboard layout — Latin
|
|
16
|
+
* American layouts (where `]` is on the shifted layer) often want `"ctrl+}"` instead.
|
|
17
|
+
* Pass `"off"` to disable the collapse shortcut entirely.
|
|
18
|
+
*/
|
|
19
|
+
collapseKey?: CollapseKeySpec;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Named keys accepted by pi-tui's `matchesKey` (keys.js switch on the parsed base key).
|
|
23
|
+
// parseKeyId lowercases the id before matching, so lowercase spellings are canonical.
|
|
24
|
+
const SPECIAL_KEYS = new Set([
|
|
25
|
+
"escape",
|
|
26
|
+
"esc",
|
|
27
|
+
"enter",
|
|
28
|
+
"return",
|
|
29
|
+
"tab",
|
|
30
|
+
"space",
|
|
31
|
+
"backspace",
|
|
32
|
+
"delete",
|
|
33
|
+
"insert",
|
|
34
|
+
"clear",
|
|
35
|
+
"home",
|
|
36
|
+
"end",
|
|
37
|
+
"pageup",
|
|
38
|
+
"pagedown",
|
|
39
|
+
"up",
|
|
40
|
+
"down",
|
|
41
|
+
"left",
|
|
42
|
+
"right",
|
|
43
|
+
...Array.from({ length: 12 }, (_, i) => `f${i + 1}`),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
47
|
+
|
|
48
|
+
function isValidCollapseKeySpec(spec: string): boolean {
|
|
49
|
+
// Mirror pi-tui's KeyId grammar strictly: zero or more distinct modifiers, then a
|
|
50
|
+
// base key that is a single printable character or a named special key. A loose
|
|
51
|
+
// check is not enough — pi-tui's `parseKeyId` takes the LAST `+`-part as the key
|
|
52
|
+
// and ignores unknown parts, so a typo like `ctr+]` would silently match every
|
|
53
|
+
// bare `]` keypress.
|
|
54
|
+
if (!spec) return false;
|
|
55
|
+
if (spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
|
|
56
|
+
const parts = spec.split("+");
|
|
57
|
+
const base = parts[parts.length - 1] ?? "";
|
|
58
|
+
const modifiers = parts.slice(0, -1);
|
|
59
|
+
if (modifiers.length !== new Set(modifiers).size) return false;
|
|
60
|
+
if (!modifiers.every((m) => MODIFIERS.has(m))) return false;
|
|
61
|
+
return base.length === 1 ? /[a-z0-9_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]/.test(base) : SPECIAL_KEYS.has(base);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveCollapseKey(config: Pick<AskUserQuestionConfig, "collapseKey">): CollapseKeySpec {
|
|
65
|
+
const raw = config.collapseKey?.trim().toLowerCase();
|
|
66
|
+
if (raw === undefined || raw === "") return DEFAULT_COLLAPSE_KEY;
|
|
67
|
+
if (raw === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
|
|
68
|
+
return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadConfig(): AskUserQuestionConfig {
|
|
72
|
+
return loadJsonConfigWithLegacyFallback<AskUserQuestionConfig>("rpiv-ask-user-question");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { validateGuidanceFields };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public event contract for @juicesharp/rpiv-ask-user-question.
|
|
3
|
+
*
|
|
4
|
+
* STABILITY POLICY — applies to every event in the `rpiv:*` namespace.
|
|
5
|
+
*
|
|
6
|
+
* 1. Channel names are immutable. Once shipped, never rename.
|
|
7
|
+
* 2. Payload changes are append-only. Listeners MUST tolerate unknown
|
|
8
|
+
* fields. New fields ship as optional (`?:`).
|
|
9
|
+
* 3. Breaking changes (rename, retype, remove a field; change emission
|
|
10
|
+
* semantics) require a NEW channel, e.g. `rpiv:ask-user:prompt.v2`,
|
|
11
|
+
* with dual-emit during a deprecation window.
|
|
12
|
+
* 4. No `version` field inside payloads. Version via channel name only.
|
|
13
|
+
* 5. Payloads must be JSON-safe: primitives, arrays, plain objects.
|
|
14
|
+
* No Set/Map/Date/class instances — payloads must survive JSON
|
|
15
|
+
* serialization when listeners forward them across process or
|
|
16
|
+
* network boundaries.
|
|
17
|
+
*
|
|
18
|
+
* Naming: `rpiv:<package-or-tool>:<phase>`, lowercase, hyphen-separated.
|
|
19
|
+
* Aligns with Pi's `"my-extension:status"` example and UniPi's `unipi:*`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt" as const;
|
|
23
|
+
|
|
24
|
+
export interface AskUserPromptEventPayload {
|
|
25
|
+
questions: ReadonlyArray<AskUserPromptQuestion>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Emitted while the questionnaire is awaiting user input (TUI `ui.custom` and
|
|
30
|
+
* RPC dialog walker). Cleared with `{ active: false }` in `finally` so listeners
|
|
31
|
+
* can distinguish blocked-on-human from working.
|
|
32
|
+
*/
|
|
33
|
+
export const ASK_USER_BLOCKED_EVENT = "rpiv:ask-user:blocked" as const;
|
|
34
|
+
|
|
35
|
+
export interface AskUserBlockedEventPayload {
|
|
36
|
+
/** True while input is awaited; false when the wait ends (answer, cancel, or error). */
|
|
37
|
+
active: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AskUserPromptQuestion {
|
|
41
|
+
/** The full question text, exactly as the agent authored it. */
|
|
42
|
+
question: string;
|
|
43
|
+
/** The short chip/tag shown next to the question. */
|
|
44
|
+
header: string;
|
|
45
|
+
/** True iff the user may pick multiple options. Normalized from optional. */
|
|
46
|
+
multiSelect: boolean;
|
|
47
|
+
options: ReadonlyArray<AskUserPromptOption>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AskUserPromptOption {
|
|
51
|
+
label: string;
|
|
52
|
+
description: string;
|
|
53
|
+
/** True iff the option carries rich preview content (content not shipped). */
|
|
54
|
+
hasPreview: boolean;
|
|
55
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerAskUserQuestionTool } from "./ask-user-question.js";
|
|
3
|
+
import { registerAskUserQuestionReconciler } from "./reconcile.js";
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
ASK_USER_BLOCKED_EVENT,
|
|
7
|
+
ASK_USER_PROMPT_EVENT,
|
|
8
|
+
type AskUserBlockedEventPayload,
|
|
9
|
+
type AskUserPromptEventPayload,
|
|
10
|
+
type AskUserPromptOption,
|
|
11
|
+
type AskUserPromptQuestion,
|
|
12
|
+
} from "./events.js";
|
|
13
|
+
|
|
14
|
+
export default function (pi: ExtensionAPI) {
|
|
15
|
+
registerAskUserQuestionTool(pi);
|
|
16
|
+
registerAskUserQuestionReconciler(pi);
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile — mid-session lifecycle reconciliation for ask_user_question.
|
|
3
|
+
*
|
|
4
|
+
* Strips or re-adds the tool to the active set so it is invisible to the LLM
|
|
5
|
+
* in non-interactive runs (no UI) and present in interactive ones. Mirrors the
|
|
6
|
+
* advisor's reconcileAdvisorTool / registerAdvisorBeforeAgentStart pattern
|
|
7
|
+
* (packages/rpiv-advisor/advisor/handlers.ts), simplified: ask_user_question's
|
|
8
|
+
* only gating signal is ctx.hasUI (no model or executor blocklist), so it
|
|
9
|
+
* reads the flag directly and omits the notify.
|
|
10
|
+
*
|
|
11
|
+
* RPC hosts (ctx.mode === "rpc": VSCode pendant, Zed, Paseo) are deliberately
|
|
12
|
+
* NOT stripped: since PR #100 the tool renders there via the select/input
|
|
13
|
+
* dialog walker (rpc-fallback.ts), so hasUI is the honest signal again. The
|
|
14
|
+
* brief period where RPC was stripped (872ef1c) predates that fallback.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-user-question.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip-or-restore `ask_user_question` to match `ctx.hasUI`. Reads the active
|
|
22
|
+
* tool list itself. Idempotent: when the tool is already in the right state it
|
|
23
|
+
* leaves the active set (and sibling tools) untouched.
|
|
24
|
+
*/
|
|
25
|
+
export function reconcileAskUserQuestionTool(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
26
|
+
const active = pi.getActiveTools();
|
|
27
|
+
const hasTool = active.includes(ASK_USER_QUESTION_TOOL_NAME);
|
|
28
|
+
// !hasUI → strip so the tool never reaches the LLM's tool list in
|
|
29
|
+
// non-interactive runs; hasUI → restore. The in-handler guards in
|
|
30
|
+
// ask-user-question.ts (!hasUI, and the custom()-undefined → dialog-walker /
|
|
31
|
+
// no_custom_ui backstop) remain as one-turn backstops if a future Pi change
|
|
32
|
+
// reorders the tool-list snapshot ahead of before_agent_start, or a host
|
|
33
|
+
// reports hasUI without any usable rendering primitive.
|
|
34
|
+
if (!ctx.hasUI && hasTool) {
|
|
35
|
+
pi.setActiveTools(active.filter((n) => n !== ASK_USER_QUESTION_TOOL_NAME));
|
|
36
|
+
} else if (ctx.hasUI && !hasTool) {
|
|
37
|
+
pi.setActiveTools([...active, ASK_USER_QUESTION_TOOL_NAME]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attach the reconciler to `before_agent_start` so the active set is fixed up
|
|
43
|
+
* before each turn's tool-list snapshot is read. Safe to call once at load.
|
|
44
|
+
*/
|
|
45
|
+
export function registerAskUserQuestionReconciler(pi: ExtensionAPI): void {
|
|
46
|
+
pi.on("before_agent_start", (_event, ctx) => reconcileAskUserQuestionTool(pi, ctx));
|
|
47
|
+
}
|