@rol1n/pi-kit 0.0.0-stage → 0.1.0
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 +98 -2
- package/extensions/ask-u-questions/README.md +32 -0
- package/extensions/ask-u-questions/answers.ts +53 -0
- package/extensions/ask-u-questions/dialog-ui.ts +57 -0
- package/extensions/ask-u-questions/index.ts +88 -0
- package/extensions/ask-u-questions/multi-choice.ts +260 -0
- package/extensions/ask-u-questions/results.ts +83 -0
- package/extensions/ask-u-questions/schema.ts +17 -0
- package/extensions/ask-u-questions/single-choice.ts +182 -0
- package/extensions/ask-u-questions/tool-renderers.ts +69 -0
- package/extensions/ask-u-questions/types.ts +45 -0
- package/extensions/ask-u-questions/ui-lock.ts +24 -0
- package/extensions/continue-signal/README.md +28 -0
- package/extensions/continue-signal/index.ts +51 -0
- package/extensions/continue-signal/renderer.ts +21 -0
- package/extensions/custom-providers/README.md +28 -0
- package/extensions/custom-providers/asr/handlers/dashscope.ts +154 -0
- package/extensions/custom-providers/asr/index.ts +37 -0
- package/extensions/custom-providers/asr/models.ts +18 -0
- package/extensions/custom-providers/asr/types.ts +20 -0
- package/extensions/custom-providers/chat/index.ts +33 -0
- package/extensions/custom-providers/chat/magpie.ts +258 -0
- package/extensions/custom-providers/chat/models.ts +111 -0
- package/extensions/custom-providers/classifier/index.ts +6 -0
- package/extensions/custom-providers/classifier/models.ts +1 -0
- package/extensions/custom-providers/image/handlers/dashscope.ts +240 -0
- package/extensions/custom-providers/image/handlers/openrouter.ts +185 -0
- package/extensions/custom-providers/image/index.ts +96 -0
- package/extensions/custom-providers/image/models.ts +71 -0
- package/extensions/custom-providers/image/types.ts +22 -0
- package/extensions/custom-providers/index.ts +50 -0
- package/extensions/custom-providers/types.ts +17 -0
- package/extensions/image-generate/README.md +29 -0
- package/extensions/image-generate/files.ts +49 -0
- package/extensions/image-generate/index.ts +200 -0
- package/extensions/image-generate/model-selector.ts +150 -0
- package/extensions/image-generate/models.ts +19 -0
- package/extensions/memory-log/README.md +54 -0
- package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
- package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
- package/extensions/memory-log/agent/cost.ts +32 -0
- package/extensions/memory-log/agent/index.ts +42 -0
- package/extensions/memory-log/agent/observer/prompt.ts +73 -0
- package/extensions/memory-log/agent/observer/tool.ts +73 -0
- package/extensions/memory-log/commands/compact.ts +35 -0
- package/extensions/memory-log/commands/consolidate.ts +47 -0
- package/extensions/memory-log/commands/status.ts +50 -0
- package/extensions/memory-log/config.ts +253 -0
- package/extensions/memory-log/debug-log.ts +76 -0
- package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
- package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
- package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
- package/extensions/memory-log/ids.ts +78 -0
- package/extensions/memory-log/index.ts +149 -0
- package/extensions/memory-log/ledger/fold.ts +77 -0
- package/extensions/memory-log/ledger/index.ts +7 -0
- package/extensions/memory-log/ledger/pool.ts +38 -0
- package/extensions/memory-log/ledger/progress.ts +181 -0
- package/extensions/memory-log/ledger/projection.ts +115 -0
- package/extensions/memory-log/ledger/render.ts +38 -0
- package/extensions/memory-log/ledger/serialize.ts +161 -0
- package/extensions/memory-log/ledger/types.ts +201 -0
- package/extensions/memory-log/memory/index-render.ts +45 -0
- package/extensions/memory-log/memory/paths.ts +120 -0
- package/extensions/memory-log/memory/session.ts +64 -0
- package/extensions/memory-log/runtime.ts +127 -0
- package/extensions/memory-log/spawn/launch.ts +133 -0
- package/extensions/memory-log/spawn/runs.ts +83 -0
- package/extensions/memory-log/tokens.ts +66 -0
- package/extensions/memory-log/ui/status-controller.ts +225 -0
- package/extensions/memory-log/ui/timeline.ts +103 -0
- package/extensions/model-vision/README.md +28 -0
- package/extensions/multi-harness/README.md +63 -0
- package/extensions/multi-harness/adapters.mjs +72 -0
- package/extensions/multi-harness/harness-done.mjs +14 -0
- package/extensions/multi-harness/index.ts +375 -0
- package/extensions/multi-harness/ipc.mjs +37 -0
- package/extensions/multi-harness/opencode-plugin.mjs +99 -0
- package/extensions/multi-harness/process-runner.mjs +115 -0
- package/extensions/multi-harness/turn-hook.mjs +29 -0
- package/extensions/multi-harness/worker-bridge.ts +52 -0
- package/extensions/oai-subquota/README.md +30 -0
- package/extensions/oai-subquota/api.ts +27 -0
- package/extensions/oai-subquota/auth.ts +34 -0
- package/extensions/oai-subquota/cache.ts +30 -0
- package/extensions/oai-subquota/index.ts +276 -0
- package/extensions/oai-subquota/snapshot.ts +167 -0
- package/extensions/oai-subquota/types.ts +40 -0
- package/extensions/oai-subquota/ui.ts +183 -0
- package/extensions/prompt-snipaste/README.md +33 -0
- package/extensions/prompt-snipaste/index.ts +143 -0
- package/extensions/prompt-snipaste/menu.ts +159 -0
- package/extensions/prompt-snipaste/renderers.ts +88 -0
- package/extensions/prompt-snipaste/snippets.ts +203 -0
- package/extensions/prompt-snipaste/types.ts +19 -0
- package/extensions/screenshot/README.md +25 -0
- package/extensions/srp-theme/README.md +24 -0
- package/extensions/srp-theme/footer.ts +194 -0
- package/extensions/srp-theme/index.ts +172 -0
- package/extensions/srp-theme/tps-meter.ts +442 -0
- package/extensions/subagent/README.md +35 -0
- package/extensions/subagent/bridge.ts +171 -0
- package/extensions/subagent/index.ts +590 -0
- package/extensions/subagent/mux.ts +621 -0
- package/extensions/subagent/session.ts +166 -0
- package/extensions/subagent/skills/subagent/SKILL.md +70 -0
- package/extensions/subagent/ui.ts +100 -0
- package/extensions/tui-asr/README.md +28 -0
- package/extensions/tui-asr/audio.ts +40 -0
- package/extensions/tui-asr/clipboard.ts +30 -0
- package/extensions/tui-asr/dictation.ts +205 -0
- package/extensions/tui-asr/index.ts +181 -0
- package/extensions/tui-asr/input.ts +58 -0
- package/extensions/tui-asr/model-selector.ts +163 -0
- package/extensions/tui-asr/providers.ts +25 -0
- package/extensions/tui-asr/status.ts +79 -0
- package/extensions/web-reach/README.md +37 -0
- package/extensions/web-reach/html.ts +83 -0
- package/extensions/web-reach/index.ts +82 -0
- package/extensions/web-reach/page.ts +132 -0
- package/extensions/web-reach/pdf.ts +42 -0
- package/extensions/web-reach/reader.ts +42 -0
- package/extensions/web-reach/rsc.ts +161 -0
- package/extensions/web-reach/search.ts +85 -0
- package/extensions/web-reach/transport.ts +35 -0
- package/extensions/windows-wheel-fix/README.md +21 -0
- package/extensions/windows-wheel-fix/index.ts +44 -0
- package/package.json +93 -4
- package/prompts/ask-questions.md +8 -0
- package/prompts/delegate-exploration.md +7 -0
- package/prompts/diagnose-report.md +7 -0
- package/prompts/frontend-tech-preset.md +18 -0
- package/prompts/orchestrator-mode.md +7 -0
- package/prompts/session-kickoff.md +7 -0
- package/prompts/verify-not-assume.md +7 -0
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi extensions and prompt templates, published together as **`@rol1n/pi-kit`** or as individual extension packages. Each extension's README describes its commands, settings, platform support, and dependencies.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/pi-kit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install from GitHub or a local checkout during development:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi install git:git@github.com:RolinShmily/pi-kit.git
|
|
15
|
+
pi install /absolute/path/to/pi-kit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Update an installed package with `pi update npm:@rol1n/pi-kit`, or update all Pi packages with `pi update --extensions`.
|
|
19
|
+
|
|
20
|
+
## Extensions
|
|
21
|
+
|
|
22
|
+
| Package and documentation | Purpose | Included in the kit |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| [ask-u-questions](extensions/ask-u-questions/README.md) | Interactive questions | Yes |
|
|
25
|
+
| [continue-signal](extensions/continue-signal/README.md) | Resume an idle agent | Yes |
|
|
26
|
+
| [custom-providers](extensions/custom-providers/README.md) | Additional model providers and gateway configuration | Yes |
|
|
27
|
+
| [image-generate](extensions/image-generate/README.md) | Image generation and model selection | Yes |
|
|
28
|
+
| [memory-log](extensions/memory-log/README.md) | Background observational memory | Yes |
|
|
29
|
+
| [model-vision](extensions/model-vision/README.md) | Image understanding and OCR | No |
|
|
30
|
+
| [multi-harness](extensions/multi-harness/README.md) | Delegate to Pi, Codex, Claude, and OpenCode | Yes |
|
|
31
|
+
| [oai-subquota](extensions/oai-subquota/README.md) | Codex subscription quota | Yes |
|
|
32
|
+
| [prompt-snipaste](extensions/prompt-snipaste/README.md) | Select snippets for the next user message | Yes |
|
|
33
|
+
| [screenshot](extensions/screenshot/README.md) | Reference the latest Windows/WSL screenshot | No |
|
|
34
|
+
| [srp-theme](extensions/srp-theme/README.md) | Footer, prompt preview, and throughput display | Yes |
|
|
35
|
+
| [subagent](extensions/subagent/README.md) | Delegate to asynchronous Pi workers | Yes |
|
|
36
|
+
| [tui-asr](extensions/tui-asr/README.md) | Streaming voice dictation | Yes |
|
|
37
|
+
| [web-reach](extensions/web-reach/README.md) | Web search and page extraction | Yes |
|
|
38
|
+
| [windows-wheel-fix](extensions/windows-wheel-fix/README.md) | Windows ConPTY/Zellij mouse reporting | Yes, active only on Windows |
|
|
39
|
+
|
|
40
|
+
Independent package names use the `@rol1n/<name>` scope, such as `@rol1n/memory-log` or `@rol1n/multi-harness`. Each package has its own release version and README; the implementation is shared with the kit.
|
|
41
|
+
|
|
42
|
+
Every extension uses the same directory layout:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
extensions/<name>/
|
|
46
|
+
├── index.ts # Pi entrypoint and registration
|
|
47
|
+
├── README.md # Package documentation
|
|
48
|
+
└── ... # Modules grouped by responsibility
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
UI, transport, parsing, and storage helpers live beside the entrypoint as needed. Small extensions can remain a single cohesive `index.ts`.
|
|
52
|
+
|
|
53
|
+
## Resource selection
|
|
54
|
+
|
|
55
|
+
The kit currently exposes 13 extension entrypoints and the seven templates in [prompts](prompts/). Personal skills, provider credentials, MCP servers, and keybindings remain normal Pi configuration.
|
|
56
|
+
|
|
57
|
+
Use `pi config` or narrow package resources in personal/project settings:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"packages": [
|
|
62
|
+
{
|
|
63
|
+
"source": "npm:@rol1n/pi-kit",
|
|
64
|
+
"extensions": ["extensions/**", "!extensions/tui-asr/**"],
|
|
65
|
+
"prompts": ["prompts/diagnose-report.md"]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Omit a resource field to load the package defaults; use `[]` to disable that resource type. Filters select resources already exposed by the package. When also installing an independent extension package, exclude its entry from the kit to avoid loading the same feature twice.
|
|
72
|
+
|
|
73
|
+
## Build and publish
|
|
74
|
+
|
|
75
|
+
From a source checkout, edit [kit.config.json](https://github.com/RolinShmily/pi-kit/blob/main/kit.config.json) to choose each extension's `bundle` flag, package name, version, entrypoint, runtime files, and README. Then synchronize and inspect the aggregate release:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm run manifest
|
|
79
|
+
npm run check
|
|
80
|
+
npm pack --dry-run --ignore-scripts
|
|
81
|
+
npm publish --access public
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Prepare an independent release from the same source:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm run prepare:extension -- memory-log
|
|
88
|
+
cd dist/extensions/memory-log
|
|
89
|
+
npm pack --dry-run --ignore-scripts
|
|
90
|
+
npm publish --access public
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The preparation command copies that extension's README to the package root and includes shared implementation files where needed. Add new runtime assets to its `files` list. Names and versions are configured independently; use an available npm name and a new version for each publication. Preparation does not install dependencies or publish anything.
|
|
94
|
+
|
|
95
|
+
All packages link to [RolinShmily/pi-kit](https://github.com/RolinShmily/pi-kit). The `pi-package` keyword makes each published package eligible for the Pi gallery; indexing can take time. Pi supplies the host libraries declared as peer dependencies.
|
|
96
|
+
|
|
97
|
+
## Attribution
|
|
98
|
+
|
|
99
|
+
Existing author and copyright notices remain with their source files. Upstream issue references and integration details are linked from the individual extension READMEs, including the [Windows terminal workaround](extensions/windows-wheel-fix/README.md).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ask-u-questions
|
|
2
|
+
|
|
3
|
+
Interactive single-choice, multiple-choice, and free-text questions for Pi. Registers the `ask_user_question` tool and serializes dialogs through a shared UI lock.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/ask-u-questions
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`. Enable one copy of the extension.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
The agent calls the tool with a question and optional choices:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"question": "Which output format should I use?",
|
|
20
|
+
"options": [
|
|
21
|
+
{ "label": "Markdown (Recommended)", "value": "markdown" },
|
|
22
|
+
{ "label": "JSON", "value": "json" }
|
|
23
|
+
],
|
|
24
|
+
"multiSelect": false
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Omit `options` or supply an empty array for free-text input. Set `multiSelect` only when several choices are expected. `details` adds supporting context; an Other choice is supplied automatically for choice questions.
|
|
29
|
+
|
|
30
|
+
## Requirements and behavior
|
|
31
|
+
|
|
32
|
+
Use an interactive Pi terminal session for dialogs. Results distinguish answered, cancelled, and unavailable states, so a cancelled dialog is not an approval. No API credentials or package-specific settings are required.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AskAnswer, AskOption } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function normalizeOptions(
|
|
4
|
+
options: Array<{ label: string; value?: string; description?: string }> | undefined,
|
|
5
|
+
): AskOption[] {
|
|
6
|
+
return (options || [])
|
|
7
|
+
.map((option) => ({
|
|
8
|
+
label: option.label.trim(),
|
|
9
|
+
value: option.value?.trim() || option.label.trim(),
|
|
10
|
+
description: option.description?.trim() || undefined,
|
|
11
|
+
}))
|
|
12
|
+
.filter((option) => option.label.length > 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isChineseContext(
|
|
16
|
+
question: string,
|
|
17
|
+
options?: Array<{ label?: string; description?: string }>,
|
|
18
|
+
context?: string,
|
|
19
|
+
): boolean {
|
|
20
|
+
if (/[\u4e00-\u9fa5]/.test(question)) return true;
|
|
21
|
+
if (context && /[\u4e00-\u9fa5]/.test(context)) return true;
|
|
22
|
+
if (options && options.some((o) => (o.label && /[\u4e00-\u9fa5]/.test(o.label)) || (o.description && /[\u4e00-\u9fa5]/.test(o.description)))) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getOtherLabel(options: AskOption[], isZh = false): string {
|
|
29
|
+
if (isZh) {
|
|
30
|
+
return options.some((option) => option.label === "其他(自定义输入)")
|
|
31
|
+
? "自定义输入"
|
|
32
|
+
: "其他(自定义输入)";
|
|
33
|
+
}
|
|
34
|
+
return options.some((option) => option.label === "Other (custom input)")
|
|
35
|
+
? "Custom input"
|
|
36
|
+
: "Other (custom input)";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function answerSortRank(answer: AskAnswer): number {
|
|
40
|
+
switch (answer.type) {
|
|
41
|
+
case "option":
|
|
42
|
+
return answer.index;
|
|
43
|
+
case "other":
|
|
44
|
+
return Number.MAX_SAFE_INTEGER - 1;
|
|
45
|
+
case "text":
|
|
46
|
+
return Number.MAX_SAFE_INTEGER;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sortAnswers(answers: AskAnswer[]): AskAnswer[] {
|
|
51
|
+
return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b));
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type EditorTheme, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export function createEditorTheme(theme: Theme): EditorTheme {
|
|
5
|
+
return {
|
|
6
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
7
|
+
selectList: {
|
|
8
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
9
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
10
|
+
description: (t) => theme.fg("muted", t),
|
|
11
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
12
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function addWrapped(
|
|
18
|
+
lines: string[],
|
|
19
|
+
text: string,
|
|
20
|
+
width: number,
|
|
21
|
+
firstIndent = " ",
|
|
22
|
+
restIndent = firstIndent,
|
|
23
|
+
styleFn?: (line: string) => string,
|
|
24
|
+
): void {
|
|
25
|
+
if (!text) return;
|
|
26
|
+
const paragraphs = text.split(/\r?\n/);
|
|
27
|
+
for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
|
|
28
|
+
const paragraph = paragraphs[pIdx];
|
|
29
|
+
if (paragraph.length === 0) {
|
|
30
|
+
lines.push("");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const isFirstParagraph = pIdx === 0;
|
|
34
|
+
const currentFirstIndent = isFirstParagraph ? firstIndent : restIndent;
|
|
35
|
+
const firstWidth = Math.max(1, width - visibleWidth(currentFirstIndent));
|
|
36
|
+
const restWidth = Math.max(1, width - visibleWidth(restIndent));
|
|
37
|
+
|
|
38
|
+
if (firstWidth === restWidth) {
|
|
39
|
+
const wrapped = wrapTextWithAnsi(paragraph, firstWidth);
|
|
40
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
41
|
+
const indent = isFirstParagraph && i === 0 ? firstIndent : restIndent;
|
|
42
|
+
const rawLine = wrapped[i];
|
|
43
|
+
const styled = styleFn ? styleFn(rawLine) : rawLine;
|
|
44
|
+
lines.push(truncateToWidth(`${indent}${styled}`, width));
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const wrapped = wrapTextWithAnsi(paragraph, Math.min(firstWidth, restWidth));
|
|
48
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
49
|
+
const indent = isFirstParagraph && i === 0 ? firstIndent : restIndent;
|
|
50
|
+
const rawLine = wrapped[i];
|
|
51
|
+
const styled = styleFn ? styleFn(rawLine) : rawLine;
|
|
52
|
+
lines.push(truncateToWidth(`${indent}${styled}`, width));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Interactive questions, serialized through the shared dialog lock. */
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AskUserQuestionMode } from "./types.ts";
|
|
4
|
+
import { AskUserQuestionParams } from "./schema.ts";
|
|
5
|
+
import { normalizeOptions } from "./answers.ts";
|
|
6
|
+
import { buildResult, cancelledResult, unavailableResult } from "./results.ts";
|
|
7
|
+
import { askSingleChoice } from "./single-choice.ts";
|
|
8
|
+
import { askMultiChoice } from "./multi-choice.ts";
|
|
9
|
+
import { withUILock } from "./ui-lock.ts";
|
|
10
|
+
import { questionRenderers } from "./tool-renderers.ts";
|
|
11
|
+
|
|
12
|
+
export type { AskOption, TextAnswer, OptionAnswer, OtherAnswer, AskAnswer, AskUserQuestionStatus, AskUserQuestionMode, AskUserQuestionResultDetails } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export default function (pi: ExtensionAPI) {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "ask_user_question",
|
|
17
|
+
label: "Ask User Question",
|
|
18
|
+
description:
|
|
19
|
+
"Ask the user a clear question and pause execution until they select an option or provide input. Useful when requirements are ambiguous, user preferences or architectural decisions are needed, or critical operations require explicit user confirmation. Always phrase the question and options in the same language as the conversation context. Ask exactly one focused question per call.",
|
|
20
|
+
promptSnippet:
|
|
21
|
+
"Ask the user a question to clarify requirements or gather decisions instead of making assumptions.",
|
|
22
|
+
promptGuidelines: [
|
|
23
|
+
"Always phrase the question and options in the same language as the conversation context (e.g. ask in Chinese if conversing in Chinese, ask in English if conversing in English).",
|
|
24
|
+
"Ask exactly one specific question per tool call.",
|
|
25
|
+
"If you need input across multiple distinct dimensions, make separate tool calls instead of combining them into one.",
|
|
26
|
+
"When options are provided, an 'Other (custom input)' choice is automatically available in the user interface.",
|
|
27
|
+
"Set multiSelect: true only when the user is expected to select multiple choices.",
|
|
28
|
+
'Put the recommended option first in the list and append "(Recommended)" to its label.',
|
|
29
|
+
"Prefer using this tool whenever multiple valid implementation paths exist that depend on user preference.",
|
|
30
|
+
],
|
|
31
|
+
parameters: AskUserQuestionParams,
|
|
32
|
+
|
|
33
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
34
|
+
const options = normalizeOptions(params.options);
|
|
35
|
+
const context = params.details?.trim() || undefined;
|
|
36
|
+
const mode: AskUserQuestionMode =
|
|
37
|
+
options.length === 0
|
|
38
|
+
? "text"
|
|
39
|
+
: params.multiSelect
|
|
40
|
+
? "multi-select"
|
|
41
|
+
: "single-select";
|
|
42
|
+
|
|
43
|
+
if (signal?.aborted) {
|
|
44
|
+
return cancelledResult(params.question, mode, context);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!ctx.hasUI) {
|
|
48
|
+
return unavailableResult(
|
|
49
|
+
params.question,
|
|
50
|
+
mode,
|
|
51
|
+
"ask_user_question requires an interactive TUI environment",
|
|
52
|
+
context,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return withUILock(async () => {
|
|
57
|
+
if (mode === "text") {
|
|
58
|
+
const editorTitle = context
|
|
59
|
+
? `${params.question}\n\n${context}`
|
|
60
|
+
: params.question;
|
|
61
|
+
const answer = await ctx.ui.editor(editorTitle);
|
|
62
|
+
if (answer === undefined) {
|
|
63
|
+
return cancelledResult(params.question, mode, context);
|
|
64
|
+
}
|
|
65
|
+
return buildResult(params.question, context, mode, [
|
|
66
|
+
{ type: "text", label: answer.trim(), value: answer.trim() },
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (mode === "single-select") {
|
|
71
|
+
const answer = await askSingleChoice(ctx, params.question, context, options);
|
|
72
|
+
if (!answer) {
|
|
73
|
+
return cancelledResult(params.question, mode, context);
|
|
74
|
+
}
|
|
75
|
+
return buildResult(params.question, context, mode, [answer]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const answers = await askMultiChoice(ctx, params.question, context, options);
|
|
79
|
+
if (!answers) {
|
|
80
|
+
return cancelledResult(params.question, mode, context);
|
|
81
|
+
}
|
|
82
|
+
return buildResult(params.question, context, mode, answers);
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
...questionRenderers,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskAnswer, AskOption, DisplayOption } from "./types.ts";
|
|
4
|
+
import { getOtherLabel, isChineseContext, sortAnswers } from "./answers.ts";
|
|
5
|
+
import { addWrapped, createEditorTheme } from "./dialog-ui.ts";
|
|
6
|
+
|
|
7
|
+
export async function askMultiChoice(
|
|
8
|
+
ctx: ExtensionContext,
|
|
9
|
+
question: string,
|
|
10
|
+
context: string | undefined,
|
|
11
|
+
options: AskOption[],
|
|
12
|
+
): Promise<AskAnswer[] | null> {
|
|
13
|
+
const isZh = isChineseContext(question, options, context);
|
|
14
|
+
const otherLabel = getOtherLabel(options, isZh);
|
|
15
|
+
const choiceItems: DisplayOption[] = options.map((option, index) => ({
|
|
16
|
+
...option,
|
|
17
|
+
id: `option:${index}`,
|
|
18
|
+
index: index + 1,
|
|
19
|
+
}));
|
|
20
|
+
const submitItem: DisplayOption = {
|
|
21
|
+
id: "submit",
|
|
22
|
+
label: isZh ? "提交确认" : "Confirm & Submit",
|
|
23
|
+
value: "__submit__",
|
|
24
|
+
isSubmit: true,
|
|
25
|
+
};
|
|
26
|
+
const allItems: DisplayOption[] = [
|
|
27
|
+
...choiceItems,
|
|
28
|
+
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
|
29
|
+
submitItem,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
return ctx.ui.custom<AskAnswer[] | null>(
|
|
33
|
+
(tui, theme, _kb, done: (result: AskAnswer[] | null) => void) => {
|
|
34
|
+
let optionIndex = 0;
|
|
35
|
+
let editMode = false;
|
|
36
|
+
let cachedLines: string[] | undefined;
|
|
37
|
+
let cachedWidth = -1;
|
|
38
|
+
let componentFocused = false;
|
|
39
|
+
const selected = new Map<string, AskAnswer>();
|
|
40
|
+
const editor = new Editor(tui, createEditorTheme(theme));
|
|
41
|
+
|
|
42
|
+
editor.onSubmit = (value) => {
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
if (!trimmed) return;
|
|
45
|
+
selected.set("other", { type: "other", label: trimmed, value: trimmed });
|
|
46
|
+
editMode = false;
|
|
47
|
+
editor.focused = false;
|
|
48
|
+
refresh();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function refresh() {
|
|
52
|
+
cachedLines = undefined;
|
|
53
|
+
tui.requestRender();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toggleOption(item: DisplayOption) {
|
|
57
|
+
if (selected.has(item.id)) {
|
|
58
|
+
selected.delete(item.id);
|
|
59
|
+
} else {
|
|
60
|
+
selected.set(item.id, {
|
|
61
|
+
type: "option",
|
|
62
|
+
label: item.label,
|
|
63
|
+
value: item.value,
|
|
64
|
+
index: item.index!,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
refresh();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleInput(data: string) {
|
|
71
|
+
if (editMode) {
|
|
72
|
+
if (matchesKey(data, Key.escape)) {
|
|
73
|
+
editMode = false;
|
|
74
|
+
editor.focused = false;
|
|
75
|
+
editor.setText(selected.get("other")?.label || "");
|
|
76
|
+
refresh();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
editor.handleInput(data);
|
|
80
|
+
refresh();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (matchesKey(data, Key.up)) {
|
|
85
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
86
|
+
refresh();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (matchesKey(data, Key.down)) {
|
|
90
|
+
optionIndex = Math.min(allItems.length - 1, optionIndex + 1);
|
|
91
|
+
refresh();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const current = allItems[optionIndex];
|
|
96
|
+
if (matchesKey(data, Key.space)) {
|
|
97
|
+
if (current.isSubmit) return;
|
|
98
|
+
if (current.isOther) {
|
|
99
|
+
if (selected.has("other")) {
|
|
100
|
+
selected.delete("other");
|
|
101
|
+
refresh();
|
|
102
|
+
} else {
|
|
103
|
+
editMode = true;
|
|
104
|
+
editor.focused = componentFocused;
|
|
105
|
+
editor.setText("");
|
|
106
|
+
refresh();
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
toggleOption(current);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matchesKey(data, Key.enter)) {
|
|
115
|
+
if (current.isSubmit) {
|
|
116
|
+
if (selected.size > 0) {
|
|
117
|
+
done(sortAnswers(Array.from(selected.values())));
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (current.isOther) {
|
|
122
|
+
editMode = true;
|
|
123
|
+
editor.focused = componentFocused;
|
|
124
|
+
editor.setText(selected.get("other")?.label || "");
|
|
125
|
+
refresh();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
toggleOption(current);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (matchesKey(data, Key.escape)) {
|
|
133
|
+
done(null);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function render(width: number): string[] {
|
|
138
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
139
|
+
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
142
|
+
|
|
143
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
144
|
+
|
|
145
|
+
if (editMode) {
|
|
146
|
+
addWrapped(lines, question, width, " ", " ", (l) => theme.fg("text", theme.bold(l)));
|
|
147
|
+
if (context) {
|
|
148
|
+
addWrapped(lines, context, width, " ", " ", (l) => theme.fg("muted", l));
|
|
149
|
+
}
|
|
150
|
+
lines.push("");
|
|
151
|
+
const header = allItems[optionIndex]?.label || otherLabel;
|
|
152
|
+
add(theme.fg("accent", ` > ${header}:`));
|
|
153
|
+
for (const line of editor.render(Math.max(1, width - 4))) {
|
|
154
|
+
add(` ${line}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push("");
|
|
157
|
+
const editHint = isZh
|
|
158
|
+
? " Enter 保存 • Esc 返回多选"
|
|
159
|
+
: " Enter to save • Esc to return";
|
|
160
|
+
add(theme.fg("dim", editHint));
|
|
161
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
162
|
+
cachedLines = lines;
|
|
163
|
+
cachedWidth = width;
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
addWrapped(lines, question, width, " ", " ", (l) => theme.fg("text", theme.bold(l)));
|
|
168
|
+
if (context) {
|
|
169
|
+
addWrapped(lines, context, width, " ", " ", (l) => theme.fg("muted", l));
|
|
170
|
+
}
|
|
171
|
+
lines.push("");
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
174
|
+
const item = allItems[i];
|
|
175
|
+
const isFocused = i === optionIndex;
|
|
176
|
+
const prefix = isFocused ? theme.fg("accent", " > ") : " ";
|
|
177
|
+
|
|
178
|
+
if (item.isSubmit) {
|
|
179
|
+
const hasSelected = selected.size > 0;
|
|
180
|
+
const countText = isZh
|
|
181
|
+
? `(已选 ${selected.size} 项)`
|
|
182
|
+
: `(${selected.size} selected)`;
|
|
183
|
+
const label = hasSelected
|
|
184
|
+
? `✓ ${item.label} ${countText}`
|
|
185
|
+
: `○ ${item.label}`;
|
|
186
|
+
const styled = isFocused
|
|
187
|
+
? theme.bold(theme.fg("accent", label))
|
|
188
|
+
: theme.fg(hasSelected ? "success" : "dim", label);
|
|
189
|
+
lines.push(truncateToWidth(`${prefix}${styled}`, width));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (item.isOther) {
|
|
194
|
+
const other = selected.get("other");
|
|
195
|
+
const marker = other ? "[x]" : "[ ]";
|
|
196
|
+
const suffix = other ? ` — ${other.label}` : "";
|
|
197
|
+
const label = `${marker} ${item.label}${suffix}`;
|
|
198
|
+
const styled = isFocused
|
|
199
|
+
? theme.bold(theme.fg("accent", label))
|
|
200
|
+
: theme.fg(other ? "success" : "text", label);
|
|
201
|
+
lines.push(truncateToWidth(`${prefix}${styled}`, width));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const checked = selected.has(item.id);
|
|
206
|
+
const marker = checked ? "[x] " : "[ ] ";
|
|
207
|
+
const numPrefix = `${item.index}. `;
|
|
208
|
+
const firstIndent = `${prefix}${checked ? theme.fg("success", marker) : marker}${numPrefix}`;
|
|
209
|
+
const restIndent = " ".repeat(visibleWidth(firstIndent));
|
|
210
|
+
const styleLabel = (l: string) => {
|
|
211
|
+
if (isFocused) return theme.bold(theme.fg("accent", l));
|
|
212
|
+
if (checked) return theme.fg("success", l);
|
|
213
|
+
return theme.fg("text", l);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
addWrapped(lines, item.label, width, firstIndent, restIndent, styleLabel);
|
|
217
|
+
|
|
218
|
+
if (item.description) {
|
|
219
|
+
const descIndent = " ".repeat(visibleWidth(firstIndent));
|
|
220
|
+
const descStyle = (l: string) => theme.fg("muted", l);
|
|
221
|
+
addWrapped(lines, item.description, width, descIndent, descIndent, descStyle);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lines.push("");
|
|
226
|
+
if (selected.size === 0) {
|
|
227
|
+
const warnText = isZh
|
|
228
|
+
? " 请至少选择一项回答后再提交。"
|
|
229
|
+
: " Please select at least one option before submitting.";
|
|
230
|
+
add(theme.fg("warning", warnText));
|
|
231
|
+
}
|
|
232
|
+
const navHint = isZh
|
|
233
|
+
? " ↑↓ 切换 • 空格 勾选 • Enter 编辑/提交 • Esc 取消"
|
|
234
|
+
: " ↑↓ to navigate • Space to select • Enter to edit/submit • Esc to cancel";
|
|
235
|
+
add(theme.fg("dim", navHint));
|
|
236
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
237
|
+
|
|
238
|
+
cachedLines = lines;
|
|
239
|
+
cachedWidth = width;
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
render,
|
|
245
|
+
invalidate: () => {
|
|
246
|
+
cachedLines = undefined;
|
|
247
|
+
},
|
|
248
|
+
handleInput,
|
|
249
|
+
get focused(): boolean {
|
|
250
|
+
return componentFocused;
|
|
251
|
+
},
|
|
252
|
+
set focused(value: boolean) {
|
|
253
|
+
componentFocused = value;
|
|
254
|
+
editor.focused = editMode && componentFocused;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AskAnswer, AskUserQuestionMode, AskUserQuestionResultDetails, AskUserQuestionStatus } from "./types.ts";
|
|
2
|
+
import { isChineseContext } from "./answers.ts";
|
|
3
|
+
|
|
4
|
+
function formatAnswerForModel(answer: AskAnswer): string {
|
|
5
|
+
switch (answer.type) {
|
|
6
|
+
case "text":
|
|
7
|
+
return answer.label;
|
|
8
|
+
case "other":
|
|
9
|
+
return `Other: ${answer.label}`;
|
|
10
|
+
case "option":
|
|
11
|
+
return `${answer.index}. ${answer.label}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildStructuredResult(
|
|
16
|
+
status: AskUserQuestionStatus,
|
|
17
|
+
question: string,
|
|
18
|
+
mode: AskUserQuestionMode,
|
|
19
|
+
answers: AskAnswer[],
|
|
20
|
+
context?: string,
|
|
21
|
+
message?: string,
|
|
22
|
+
): AskUserQuestionResultDetails {
|
|
23
|
+
return {
|
|
24
|
+
status,
|
|
25
|
+
question,
|
|
26
|
+
context,
|
|
27
|
+
mode,
|
|
28
|
+
answers,
|
|
29
|
+
message,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cancelledResult(question: string, mode: AskUserQuestionMode, context?: string) {
|
|
34
|
+
const isZh = isChineseContext(question, undefined, context);
|
|
35
|
+
const message = isZh ? "用户取消了本次提问" : "User cancelled the question.";
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text" as const, text: message }],
|
|
38
|
+
details: buildStructuredResult("cancelled", question, mode, [], context, message),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function unavailableResult(question: string, mode: AskUserQuestionMode, message: string, context?: string) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text" as const, text: message }],
|
|
45
|
+
details: buildStructuredResult("unavailable", question, mode, [], context, message),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildResult(
|
|
50
|
+
question: string,
|
|
51
|
+
context: string | undefined,
|
|
52
|
+
mode: AskUserQuestionMode,
|
|
53
|
+
answers: AskAnswer[],
|
|
54
|
+
) {
|
|
55
|
+
const isZh = isChineseContext(question, answers, context);
|
|
56
|
+
let text: string;
|
|
57
|
+
if (mode === "text") {
|
|
58
|
+
const answer = answers[0];
|
|
59
|
+
if (isZh) {
|
|
60
|
+
text = answer.label.trim().length > 0
|
|
61
|
+
? `用户输入回答: ${answer.label}`
|
|
62
|
+
: "用户提交了空回答";
|
|
63
|
+
} else {
|
|
64
|
+
text = answer.label.trim().length > 0
|
|
65
|
+
? `User answered: ${answer.label}`
|
|
66
|
+
: "User submitted an empty answer";
|
|
67
|
+
}
|
|
68
|
+
} else if (mode === "single-select") {
|
|
69
|
+
text = isZh
|
|
70
|
+
? `用户选择项: ${formatAnswerForModel(answers[0])}`
|
|
71
|
+
: `User selected: ${formatAnswerForModel(answers[0])}`;
|
|
72
|
+
} else {
|
|
73
|
+
text = isZh
|
|
74
|
+
? `用户选择多项:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}`
|
|
75
|
+
: `User selected multiple:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text" as const, text }],
|
|
80
|
+
details: buildStructuredResult("answered", question, mode, answers, context),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|