@rol1n/pi-kit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -2
- package/ask-u-questions/README.md +26 -0
- package/ask-u-questions/dialog-ui.ts +80 -0
- package/ask-u-questions/index.ts +92 -0
- package/ask-u-questions/multi-choice.ts +258 -0
- package/ask-u-questions/single-choice.ts +180 -0
- package/ask-u-questions/tool-renderers.ts +67 -0
- package/ask-u-questions/types.ts +192 -0
- package/continue-signal/README.md +20 -0
- package/continue-signal/index.ts +69 -0
- package/custom-providers/README.md +20 -0
- package/custom-providers/asr/handlers/dashscope.ts +154 -0
- package/custom-providers/asr/index.ts +37 -0
- package/custom-providers/asr/models.ts +18 -0
- package/custom-providers/asr/types.ts +20 -0
- package/custom-providers/chat/index.ts +33 -0
- package/custom-providers/chat/magpie.ts +258 -0
- package/custom-providers/chat/models.ts +111 -0
- package/custom-providers/classifier/index.ts +6 -0
- package/custom-providers/classifier/models.ts +1 -0
- package/custom-providers/image/handlers/dashscope.ts +240 -0
- package/custom-providers/image/handlers/openrouter.ts +185 -0
- package/custom-providers/image/index.ts +96 -0
- package/custom-providers/image/models.ts +71 -0
- package/custom-providers/image/types.ts +22 -0
- package/custom-providers/index.ts +64 -0
- package/custom-providers/types.ts +17 -0
- package/image-generate/README.md +25 -0
- package/image-generate/files.ts +49 -0
- package/image-generate/index.ts +220 -0
- package/image-generate/model-selector.ts +150 -0
- package/memory-log/README.md +54 -0
- package/memory-log/agent/consolidator/prompt.ts +47 -0
- package/memory-log/agent/consolidator/tools.ts +160 -0
- package/memory-log/agent/cost.ts +32 -0
- package/memory-log/agent/index.ts +42 -0
- package/memory-log/agent/observer/prompt.ts +73 -0
- package/memory-log/agent/observer/tool.ts +73 -0
- package/memory-log/commands/compact.ts +35 -0
- package/memory-log/commands/consolidate.ts +47 -0
- package/memory-log/commands/status.ts +50 -0
- package/memory-log/config.ts +253 -0
- package/memory-log/debug-log.ts +76 -0
- package/memory-log/hooks/compaction-hook.ts +161 -0
- package/memory-log/hooks/compaction-trigger.ts +160 -0
- package/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/memory-log/hooks/observer-trigger.ts +176 -0
- package/memory-log/ids.ts +78 -0
- package/memory-log/index.ts +149 -0
- package/memory-log/ledger/fold.ts +77 -0
- package/memory-log/ledger/index.ts +7 -0
- package/memory-log/ledger/pool.ts +38 -0
- package/memory-log/ledger/progress.ts +181 -0
- package/memory-log/ledger/projection.ts +115 -0
- package/memory-log/ledger/render.ts +38 -0
- package/memory-log/ledger/serialize.ts +161 -0
- package/memory-log/ledger/types.ts +201 -0
- package/memory-log/memory/index-render.ts +45 -0
- package/memory-log/memory/paths.ts +120 -0
- package/memory-log/memory/session.ts +64 -0
- package/memory-log/runtime.ts +127 -0
- package/memory-log/spawn/launch.ts +133 -0
- package/memory-log/spawn/runs.ts +83 -0
- package/memory-log/tokens.ts +66 -0
- package/memory-log/ui/status-controller.ts +225 -0
- package/memory-log/ui/timeline.ts +103 -0
- package/model-vision/README.md +22 -0
- package/multi-harness/README.md +63 -0
- package/multi-harness/adapters.mjs +72 -0
- package/multi-harness/harness-done.mjs +14 -0
- package/multi-harness/index.ts +375 -0
- package/multi-harness/ipc.mjs +37 -0
- package/multi-harness/opencode-plugin.mjs +99 -0
- package/multi-harness/process-runner.mjs +115 -0
- package/multi-harness/turn-hook.mjs +29 -0
- package/multi-harness/worker-bridge.ts +52 -0
- package/oai-subquota/README.md +22 -0
- package/oai-subquota/index.ts +286 -0
- package/oai-subquota/snapshot.ts +293 -0
- package/oai-subquota/ui.ts +183 -0
- package/package.json +92 -4
- package/prompt-snipaste/README.md +27 -0
- package/prompt-snipaste/index.ts +143 -0
- package/prompt-snipaste/menu.ts +159 -0
- package/prompt-snipaste/prompts/ask-questions.md +8 -0
- package/prompt-snipaste/prompts/delegate-exploration.md +7 -0
- package/prompt-snipaste/prompts/diagnose-report.md +7 -0
- package/prompt-snipaste/prompts/frontend-tech-preset.md +18 -0
- package/prompt-snipaste/prompts/orchestrator-mode.md +7 -0
- package/prompt-snipaste/prompts/session-kickoff.md +7 -0
- package/prompt-snipaste/prompts/verify-not-assume.md +7 -0
- package/prompt-snipaste/renderers.ts +87 -0
- package/prompt-snipaste/snippets.ts +206 -0
- package/prompt-snipaste/types.ts +18 -0
- package/screenshot/README.md +14 -0
- package/srp-theme/README.md +18 -0
- package/srp-theme/footer.ts +193 -0
- package/srp-theme/index.ts +172 -0
- package/srp-theme/tps-meter.ts +441 -0
- package/subagent/README.md +35 -0
- package/subagent/bridge.ts +171 -0
- package/subagent/index.ts +590 -0
- package/subagent/mux.ts +621 -0
- package/subagent/session.ts +166 -0
- package/subagent/ui.ts +100 -0
- package/tui-asr/README.md +21 -0
- package/tui-asr/dictation.ts +272 -0
- package/tui-asr/index.ts +181 -0
- package/tui-asr/input.ts +87 -0
- package/tui-asr/model-selector.ts +163 -0
- package/tui-asr/status.ts +79 -0
- package/web-reach/README.md +19 -0
- package/web-reach/html.ts +83 -0
- package/web-reach/index.ts +82 -0
- package/web-reach/page.ts +214 -0
- package/web-reach/rsc.ts +161 -0
- package/web-reach/search.ts +85 -0
- package/web-reach/transport.ts +35 -0
- package/windows-wheel-fix/README.md +12 -0
- package/windows-wheel-fix/index.ts +44 -0
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi extensions and prompt templates distributed together as **`@rol1n/pi-kit`** and individually as **`@rol1n/<extension>`**. Each extension directory contains its own `index.ts` and `README.md`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/pi-kit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install from GitHub or a local checkout:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi install https://github.com/RolinShmily/pi-kit
|
|
15
|
+
pi install /absolute/path/to/pi-kit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Update with `pi update npm:@rol1n/pi-kit` or `pi update --extensions`.
|
|
19
|
+
|
|
20
|
+
## Extensions
|
|
21
|
+
|
|
22
|
+
| Extension | Purpose | Default in kit | Standalone npm |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| [ask-u-questions](ask-u-questions/README.md) | Interactive single/multi-choice and text dialogs | Yes | `@rol1n/ask-u-questions` |
|
|
25
|
+
| [continue-signal](continue-signal/README.md) | Resume an idle agent via `/continue` or Alt+C | Yes | `@rol1n/continue-signal` |
|
|
26
|
+
| [custom-providers](custom-providers/README.md) | Additional model providers and gateway registration | Yes | `@rol1n/custom-providers` |
|
|
27
|
+
| [image-generate](image-generate/README.md) | Image generation tool and `/image-generate` selector | Yes | `@rol1n/image-generate` |
|
|
28
|
+
| [memory-log](memory-log/README.md) | Background observational memory and compaction | Yes | `@rol1n/memory-log` |
|
|
29
|
+
| [model-vision](model-vision/README.md) | Image understanding and OCR tool | No | `@rol1n/model-vision` |
|
|
30
|
+
| [multi-harness](multi-harness/README.md) | Delegate to Pi, Codex, Claude, and OpenCode | Yes | `@rol1n/multi-harness` |
|
|
31
|
+
| [oai-subquota](oai-subquota/README.md) | OpenAI Codex subscription quota monitor | Yes | `@rol1n/oai-subquota` |
|
|
32
|
+
| [prompt-snipaste](prompt-snipaste/README.md) | Select prompt snippets for the next user message (bundles 7 built-in prompts) | Yes | `@rol1n/prompt-snipaste` |
|
|
33
|
+
| [screenshot](screenshot/README.md) | Insert the latest Windows/WSL screenshot path | No | `@rol1n/screenshot` |
|
|
34
|
+
| [srp-theme](srp-theme/README.md) | Prompt preview footer and streaming TPS meter | Yes | `@rol1n/srp-theme` |
|
|
35
|
+
| [subagent](subagent/README.md) | Delegate to asynchronous Pi workers | Yes | `@rol1n/subagent` |
|
|
36
|
+
| [tui-asr](tui-asr/README.md) | Streaming voice dictation via DashScope ASR | Yes | `@rol1n/tui-asr` |
|
|
37
|
+
| [web-reach](web-reach/README.md) | Keyless Exa web search and multi-tier page extraction | Yes | `@rol1n/web-reach` |
|
|
38
|
+
| [windows-wheel-fix](windows-wheel-fix/README.md) | Windows ConPTY/Zellij mouse-wheel reporting fix | Yes (Windows only) | `@rol1n/windows-wheel-fix` |
|
|
39
|
+
|
|
40
|
+
## Resource selection
|
|
41
|
+
|
|
42
|
+
The kit exposes 13 extension entrypoints and the 7 prompt templates bundled in [prompt-snipaste/prompts](prompt-snipaste/prompts/). Narrow loaded resources with `pi config` or in `settings.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"packages": [
|
|
47
|
+
{
|
|
48
|
+
"source": "npm:@rol1n/pi-kit",
|
|
49
|
+
"extensions": ["**/*.ts", "!tui-asr/**"],
|
|
50
|
+
"prompts": ["prompt-snipaste/prompts/diagnose-report.md"]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Build and publish
|
|
57
|
+
|
|
58
|
+
[kit.config.json](kit.config.json) controls which extensions are bundled into `@rol1n/pi-kit` (`bundle: true`) and inter-extension npm dependencies (`deps`).
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
# Sync package.json from kit.config.json and validate
|
|
62
|
+
npm run manifest
|
|
63
|
+
npm run check
|
|
64
|
+
|
|
65
|
+
# Stage any extension into dist/<id>
|
|
66
|
+
npm run prepare:extension -- subagent
|
|
67
|
+
|
|
68
|
+
# Publish all standalone extensions, or the kit, or one extension
|
|
69
|
+
npm run publish:ci -- extensions
|
|
70
|
+
npm run publish:ci -- kit
|
|
71
|
+
npm run publish:ci -- tui-asr
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
GitHub Actions OIDC Trusted Publishing is configured in [`.github/workflows/publish.yml`](.github/workflows/publish.yml).
|
|
@@ -0,0 +1,26 @@
|
|
|
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`.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"question": "Which output format should I use?",
|
|
18
|
+
"options": [
|
|
19
|
+
{ "label": "Markdown (Recommended)", "value": "markdown" },
|
|
20
|
+
{ "label": "JSON", "value": "json" }
|
|
21
|
+
],
|
|
22
|
+
"multiSelect": false
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Omit `options` or pass `[]` for free-text input. Set `multiSelect: true` only when multiple choices are expected. `details` adds supporting context, and an `Other` option is provided automatically for choice questions.
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
|
|
58
|
+
const SHARED_UI_LOCK_KEY = "__piSharedUiLock";
|
|
59
|
+
function getSharedUiLock(): { withLock<T>(fn: () => T | Promise<T>): Promise<T> } {
|
|
60
|
+
const g = globalThis as any;
|
|
61
|
+
if (!g[SHARED_UI_LOCK_KEY]) {
|
|
62
|
+
let chain: Promise<void> = Promise.resolve();
|
|
63
|
+
g[SHARED_UI_LOCK_KEY] = {
|
|
64
|
+
withLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
65
|
+
const prev = chain;
|
|
66
|
+
let release: () => void;
|
|
67
|
+
chain = new Promise<void>((r) => {
|
|
68
|
+
release = r;
|
|
69
|
+
});
|
|
70
|
+
return prev.then(fn).finally(() => release!());
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return g[SHARED_UI_LOCK_KEY];
|
|
75
|
+
}
|
|
76
|
+
const sharedUiLock = getSharedUiLock();
|
|
77
|
+
|
|
78
|
+
export function withUILock<T>(fn: () => Promise<T>): Promise<T> {
|
|
79
|
+
return sharedUiLock.withLock(fn);
|
|
80
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Interactive questions, serialized through the shared dialog lock. */
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
AskUserQuestionParams,
|
|
5
|
+
buildResult,
|
|
6
|
+
cancelledResult,
|
|
7
|
+
normalizeOptions,
|
|
8
|
+
unavailableResult,
|
|
9
|
+
type AskUserQuestionMode,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
import { withUILock } from "./dialog-ui.ts";
|
|
12
|
+
import { askSingleChoice } from "./single-choice.ts";
|
|
13
|
+
import { askMultiChoice } from "./multi-choice.ts";
|
|
14
|
+
import { questionRenderers } from "./tool-renderers.ts";
|
|
15
|
+
|
|
16
|
+
export type { AskOption, TextAnswer, OptionAnswer, OtherAnswer, AskAnswer, AskUserQuestionStatus, AskUserQuestionMode, AskUserQuestionResultDetails } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
pi.registerTool({
|
|
20
|
+
name: "ask_user_question",
|
|
21
|
+
label: "Ask User Question",
|
|
22
|
+
description:
|
|
23
|
+
"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.",
|
|
24
|
+
promptSnippet:
|
|
25
|
+
"Ask the user a question to clarify requirements or gather decisions instead of making assumptions.",
|
|
26
|
+
promptGuidelines: [
|
|
27
|
+
"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).",
|
|
28
|
+
"Ask exactly one specific question per tool call.",
|
|
29
|
+
"If you need input across multiple distinct dimensions, make separate tool calls instead of combining them into one.",
|
|
30
|
+
"When options are provided, an 'Other (custom input)' choice is automatically available in the user interface.",
|
|
31
|
+
"Set multiSelect: true only when the user is expected to select multiple choices.",
|
|
32
|
+
'Put the recommended option first in the list and append "(Recommended)" to its label.',
|
|
33
|
+
"Prefer using this tool whenever multiple valid implementation paths exist that depend on user preference.",
|
|
34
|
+
],
|
|
35
|
+
parameters: AskUserQuestionParams,
|
|
36
|
+
|
|
37
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
38
|
+
const options = normalizeOptions(params.options);
|
|
39
|
+
const context = params.details?.trim() || undefined;
|
|
40
|
+
const mode: AskUserQuestionMode =
|
|
41
|
+
options.length === 0
|
|
42
|
+
? "text"
|
|
43
|
+
: params.multiSelect
|
|
44
|
+
? "multi-select"
|
|
45
|
+
: "single-select";
|
|
46
|
+
|
|
47
|
+
if (signal?.aborted) {
|
|
48
|
+
return cancelledResult(params.question, mode, context);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!ctx.hasUI) {
|
|
52
|
+
return unavailableResult(
|
|
53
|
+
params.question,
|
|
54
|
+
mode,
|
|
55
|
+
"ask_user_question requires an interactive TUI environment",
|
|
56
|
+
context,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return withUILock(async () => {
|
|
61
|
+
if (mode === "text") {
|
|
62
|
+
const editorTitle = context
|
|
63
|
+
? `${params.question}\n\n${context}`
|
|
64
|
+
: params.question;
|
|
65
|
+
const answer = await ctx.ui.editor(editorTitle);
|
|
66
|
+
if (answer === undefined) {
|
|
67
|
+
return cancelledResult(params.question, mode, context);
|
|
68
|
+
}
|
|
69
|
+
return buildResult(params.question, context, mode, [
|
|
70
|
+
{ type: "text", label: answer.trim(), value: answer.trim() },
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (mode === "single-select") {
|
|
75
|
+
const answer = await askSingleChoice(ctx, params.question, context, options);
|
|
76
|
+
if (!answer) {
|
|
77
|
+
return cancelledResult(params.question, mode, context);
|
|
78
|
+
}
|
|
79
|
+
return buildResult(params.question, context, mode, [answer]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const answers = await askMultiChoice(ctx, params.question, context, options);
|
|
83
|
+
if (!answers) {
|
|
84
|
+
return cancelledResult(params.question, mode, context);
|
|
85
|
+
}
|
|
86
|
+
return buildResult(params.question, context, mode, answers);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
...questionRenderers,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getOtherLabel, isChineseContext, sortAnswers, type AskAnswer, type AskOption, type DisplayOption } from "./types.ts";
|
|
4
|
+
import { addWrapped, createEditorTheme } from "./dialog-ui.ts";
|
|
5
|
+
|
|
6
|
+
export async function askMultiChoice(
|
|
7
|
+
ctx: ExtensionContext,
|
|
8
|
+
question: string,
|
|
9
|
+
context: string | undefined,
|
|
10
|
+
options: AskOption[],
|
|
11
|
+
): Promise<AskAnswer[] | null> {
|
|
12
|
+
const isZh = isChineseContext(question, options, context);
|
|
13
|
+
const otherLabel = getOtherLabel(options, isZh);
|
|
14
|
+
const choiceItems: DisplayOption[] = options.map((option, index) => ({
|
|
15
|
+
...option,
|
|
16
|
+
id: `option:${index}`,
|
|
17
|
+
index: index + 1,
|
|
18
|
+
}));
|
|
19
|
+
const submitItem: DisplayOption = {
|
|
20
|
+
id: "submit",
|
|
21
|
+
label: isZh ? "提交确认" : "Confirm & Submit",
|
|
22
|
+
value: "__submit__",
|
|
23
|
+
isSubmit: true,
|
|
24
|
+
};
|
|
25
|
+
const allItems: DisplayOption[] = [
|
|
26
|
+
...choiceItems,
|
|
27
|
+
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
|
28
|
+
submitItem,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
return ctx.ui.custom<AskAnswer[] | null>(
|
|
32
|
+
(tui, theme, _kb, done: (result: AskAnswer[] | null) => void) => {
|
|
33
|
+
let optionIndex = 0;
|
|
34
|
+
let editMode = false;
|
|
35
|
+
let cachedLines: string[] | undefined;
|
|
36
|
+
let cachedWidth = -1;
|
|
37
|
+
let componentFocused = false;
|
|
38
|
+
const selected = new Map<string, AskAnswer>();
|
|
39
|
+
const editor = new Editor(tui, createEditorTheme(theme));
|
|
40
|
+
|
|
41
|
+
editor.onSubmit = (value) => {
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
if (!trimmed) return;
|
|
44
|
+
selected.set("other", { type: "other", label: trimmed, value: trimmed });
|
|
45
|
+
editMode = false;
|
|
46
|
+
editor.focused = false;
|
|
47
|
+
refresh();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function refresh() {
|
|
51
|
+
cachedLines = undefined;
|
|
52
|
+
tui.requestRender();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toggleOption(item: DisplayOption) {
|
|
56
|
+
if (selected.has(item.id)) {
|
|
57
|
+
selected.delete(item.id);
|
|
58
|
+
} else {
|
|
59
|
+
selected.set(item.id, {
|
|
60
|
+
type: "option",
|
|
61
|
+
label: item.label,
|
|
62
|
+
value: item.value,
|
|
63
|
+
index: item.index!,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
refresh();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleInput(data: string) {
|
|
70
|
+
if (editMode) {
|
|
71
|
+
if (matchesKey(data, Key.escape)) {
|
|
72
|
+
editMode = false;
|
|
73
|
+
editor.focused = false;
|
|
74
|
+
editor.setText(selected.get("other")?.label || "");
|
|
75
|
+
refresh();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
editor.handleInput(data);
|
|
79
|
+
refresh();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (matchesKey(data, Key.up)) {
|
|
84
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
85
|
+
refresh();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (matchesKey(data, Key.down)) {
|
|
89
|
+
optionIndex = Math.min(allItems.length - 1, optionIndex + 1);
|
|
90
|
+
refresh();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const current = allItems[optionIndex];
|
|
95
|
+
if (matchesKey(data, Key.space)) {
|
|
96
|
+
if (current.isSubmit) return;
|
|
97
|
+
if (current.isOther) {
|
|
98
|
+
if (selected.has("other")) {
|
|
99
|
+
selected.delete("other");
|
|
100
|
+
refresh();
|
|
101
|
+
} else {
|
|
102
|
+
editMode = true;
|
|
103
|
+
editor.focused = componentFocused;
|
|
104
|
+
editor.setText("");
|
|
105
|
+
refresh();
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
toggleOption(current);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (matchesKey(data, Key.enter)) {
|
|
114
|
+
if (current.isSubmit) {
|
|
115
|
+
if (selected.size > 0) {
|
|
116
|
+
done(sortAnswers(Array.from(selected.values())));
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (current.isOther) {
|
|
121
|
+
editMode = true;
|
|
122
|
+
editor.focused = componentFocused;
|
|
123
|
+
editor.setText(selected.get("other")?.label || "");
|
|
124
|
+
refresh();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
toggleOption(current);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (matchesKey(data, Key.escape)) {
|
|
132
|
+
done(null);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function render(width: number): string[] {
|
|
137
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
138
|
+
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
141
|
+
|
|
142
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
143
|
+
|
|
144
|
+
if (editMode) {
|
|
145
|
+
addWrapped(lines, question, width, " ", " ", (l) => theme.fg("text", theme.bold(l)));
|
|
146
|
+
if (context) {
|
|
147
|
+
addWrapped(lines, context, width, " ", " ", (l) => theme.fg("muted", l));
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
const header = allItems[optionIndex]?.label || otherLabel;
|
|
151
|
+
add(theme.fg("accent", ` > ${header}:`));
|
|
152
|
+
for (const line of editor.render(Math.max(1, width - 4))) {
|
|
153
|
+
add(` ${line}`);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
const editHint = isZh
|
|
157
|
+
? " Enter 保存 • Esc 返回多选"
|
|
158
|
+
: " Enter to save • Esc to return";
|
|
159
|
+
add(theme.fg("dim", editHint));
|
|
160
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
161
|
+
cachedLines = lines;
|
|
162
|
+
cachedWidth = width;
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
addWrapped(lines, question, width, " ", " ", (l) => theme.fg("text", theme.bold(l)));
|
|
167
|
+
if (context) {
|
|
168
|
+
addWrapped(lines, context, width, " ", " ", (l) => theme.fg("muted", l));
|
|
169
|
+
}
|
|
170
|
+
lines.push("");
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
173
|
+
const item = allItems[i];
|
|
174
|
+
const isFocused = i === optionIndex;
|
|
175
|
+
const prefix = isFocused ? theme.fg("accent", " > ") : " ";
|
|
176
|
+
|
|
177
|
+
if (item.isSubmit) {
|
|
178
|
+
const hasSelected = selected.size > 0;
|
|
179
|
+
const countText = isZh
|
|
180
|
+
? `(已选 ${selected.size} 项)`
|
|
181
|
+
: `(${selected.size} selected)`;
|
|
182
|
+
const label = hasSelected
|
|
183
|
+
? `✓ ${item.label} ${countText}`
|
|
184
|
+
: `○ ${item.label}`;
|
|
185
|
+
const styled = isFocused
|
|
186
|
+
? theme.bold(theme.fg("accent", label))
|
|
187
|
+
: theme.fg(hasSelected ? "success" : "dim", label);
|
|
188
|
+
lines.push(truncateToWidth(`${prefix}${styled}`, width));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (item.isOther) {
|
|
193
|
+
const other = selected.get("other");
|
|
194
|
+
const marker = other ? "[x]" : "[ ]";
|
|
195
|
+
const suffix = other ? ` — ${other.label}` : "";
|
|
196
|
+
const label = `${marker} ${item.label}${suffix}`;
|
|
197
|
+
const styled = isFocused
|
|
198
|
+
? theme.bold(theme.fg("accent", label))
|
|
199
|
+
: theme.fg(other ? "success" : "text", label);
|
|
200
|
+
lines.push(truncateToWidth(`${prefix}${styled}`, width));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const checked = selected.has(item.id);
|
|
205
|
+
const marker = checked ? "[x] " : "[ ] ";
|
|
206
|
+
const numPrefix = `${item.index}. `;
|
|
207
|
+
const firstIndent = `${prefix}${checked ? theme.fg("success", marker) : marker}${numPrefix}`;
|
|
208
|
+
const restIndent = " ".repeat(visibleWidth(firstIndent));
|
|
209
|
+
const styleLabel = (l: string) => {
|
|
210
|
+
if (isFocused) return theme.bold(theme.fg("accent", l));
|
|
211
|
+
if (checked) return theme.fg("success", l);
|
|
212
|
+
return theme.fg("text", l);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
addWrapped(lines, item.label, width, firstIndent, restIndent, styleLabel);
|
|
216
|
+
|
|
217
|
+
if (item.description) {
|
|
218
|
+
const descIndent = " ".repeat(visibleWidth(firstIndent));
|
|
219
|
+
const descStyle = (l: string) => theme.fg("muted", l);
|
|
220
|
+
addWrapped(lines, item.description, width, descIndent, descIndent, descStyle);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lines.push("");
|
|
225
|
+
if (selected.size === 0) {
|
|
226
|
+
const warnText = isZh
|
|
227
|
+
? " 请至少选择一项回答后再提交。"
|
|
228
|
+
: " Please select at least one option before submitting.";
|
|
229
|
+
add(theme.fg("warning", warnText));
|
|
230
|
+
}
|
|
231
|
+
const navHint = isZh
|
|
232
|
+
? " ↑↓ 切换 • 空格 勾选 • Enter 编辑/提交 • Esc 取消"
|
|
233
|
+
: " ↑↓ to navigate • Space to select • Enter to edit/submit • Esc to cancel";
|
|
234
|
+
add(theme.fg("dim", navHint));
|
|
235
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
236
|
+
|
|
237
|
+
cachedLines = lines;
|
|
238
|
+
cachedWidth = width;
|
|
239
|
+
return lines;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
render,
|
|
244
|
+
invalidate: () => {
|
|
245
|
+
cachedLines = undefined;
|
|
246
|
+
},
|
|
247
|
+
handleInput,
|
|
248
|
+
get focused(): boolean {
|
|
249
|
+
return componentFocused;
|
|
250
|
+
},
|
|
251
|
+
set focused(value: boolean) {
|
|
252
|
+
componentFocused = value;
|
|
253
|
+
editor.focused = editMode && componentFocused;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
}
|