@bachi/pi-coder 1.0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
package/docs/themes.md ADDED
@@ -0,0 +1,107 @@
1
+ # Themes
2
+
3
+ Three themes ship with this package: `summer-night` (the one `config/settings.json` selects), `catppuccin` and `ayu`.
4
+
5
+ ## Switching themes
6
+
7
+ Two ways, both with the same result:
8
+
9
+ - `/theme` — the picker from `theme-command.ts`. Arrow keys preview live, Enter persists to `settings.json`, Esc leaves settings untouched. `/theme summer-night` switches directly.
10
+ - `/settings` → Theme — pi's built-in path, which also mixes in the light/dark auto modes.
11
+
12
+ pi loads themes from three places:
13
+
14
+ ```
15
+ ~/.pi/agent/themes/*.json # global
16
+ .pi/themes/*.json # project, after the project is trusted
17
+ <package>/themes/*.json # packages — this is how these three arrive
18
+ ```
19
+
20
+ The copy commands in [installation.md](installation.md#apply-the-global-config-files) put them in the global directory as well; that is optional, since the package already provides them.
21
+
22
+ ## The three themes
23
+
24
+ ### `summer-night`
25
+
26
+ The author's own palette and the current default: a near-black blue base with high-contrast accents. Body text sits at roughly 8.4–9.2:1 on panels. Its greys are deliberately dimmer than catppuccin's — `muted` around 3.3–3.8:1 and `dim` around 2.2–2.6:1 — so comments, `Think:` rows and settings hints read faintly. If that is too faint, raise `vars.muted` and `vars.dimmed`.
27
+
28
+ `text` is `""`, the terminal's own default foreground. Three values are literals rather than `vars` references: `syntaxComment` (`#95a1b0`), `thinkingXhigh` and `thinkingMax` (both `#4b7cc2`). The Chinese handbook still describes this file as having no literal colors — the file has since changed; trust the file.
29
+
30
+ ### `catppuccin`
31
+
32
+ A port of Catppuccin Mocha from [bacnh85/pi-extensions](https://github.com/bacnh85/pi-extensions). The palette lives in `vars` (36 of them) and `colors` only references it. Two entries are empty strings meaning "terminal default": `text` and `syntaxVariable`. The upstream file's single 256-color index (`toolPendingBg: 233`) was converted to hex `#140e1e`, so all three themes in this package are free of integer color values — `bgAnsi()` emits `48;5;N` for an integer, which mixes poorly with a truecolor palette.
33
+
34
+ ### `ayu`
35
+
36
+ A port of the official `ayu-dark` palette from [iodic/pi-ayu-themes](https://github.com/iodic/pi-ayu-themes), reformatted to the same four-section shape as `catppuccin.json` and with all 55 colors going through `vars`. It is the only theme here that defines `bashOutput`.
37
+
38
+ Three deliberate deviations from upstream:
39
+
40
+ 1. **Strings vs. added diff lines.** Upstream points `syntaxString` (strings in code) and `toolDiffAdded` (the foreground of added diff lines, including line numbers and `+`) at the same green `#AAD94C`. Here `syntaxString` points at a darker `stringGreen` (`#67a567`) so code strings and diff additions do not glow identically; diff additions keep the upstream green. Side effect: the new green is close in luminance to `muted`, so strings and comments are told apart mostly by hue.
41
+ 2. **Thinking borders.** Upstream paints `thinkingXhigh` red (`#D95757`). Here `thinkingXhigh` and `thinkingMax` are both a neutral grey (`#626262`), because the editor border takes the color of the current level and this setup defaults to `xhigh` — a red border reads as an error. The top two levels are now distinguished from each other only by luminance.
42
+ 3. **Pending and finished tool cards are inverted.** Upstream has `toolPendingBg: #10151F` (darker) and `toolSuccessBg: #171F24` (lighter); this file swaps them, so a tool call that is still running gets the lighter background and a finished one the darker. Nothing in the file explains the choice — it is a value inversion, not a renamed variable.
43
+
44
+ ## Anatomy of a theme file
45
+
46
+ ```jsonc
47
+ {
48
+ "$schema": "...",
49
+ "name": "summer-night", // must equal the file name (minus .json)
50
+ "vars": { "panelBg": "#0e1622", ... },
51
+ "colors": { "bg": "panelBg", ... },
52
+ "export": { "pageBg": "panelBg", "cardBg": "bg", "infoBg": "infoBg" }
53
+ }
54
+ ```
55
+
56
+ - Every non-`#` value in `colors` is looked up in `vars`. A missing reference throws `Variable reference not found`, **the whole theme fails to load**, and pi falls back to the built-in `dark` theme without an error message. This is the single most common way to break a theme.
57
+ - An empty string means "terminal default foreground" — used by `text` and, in catppuccin, `syntaxVariable`.
58
+ - `export` only affects HTML produced by `/export`; when it is missing, pi derives those colors from `userMessageBg`. In these files it is set explicitly (and `summer-night` uses literal hex there).
59
+
60
+ ## Custom tokens
61
+
62
+ Three tokens in these files are **not** part of pi's official theme schema:
63
+
64
+ | Token | Read by | Effect |
65
+ | --- | --- | --- |
66
+ | `toolDiffAddedBg` | `tool-diff.ts` | Full-line background of added diff lines. |
67
+ | `toolDiffRemovedBg` | `tool-diff.ts` | Full-line background of removed diff lines. |
68
+ | `bashOutput` | `bash-command-collapse.ts` | Foreground of bash output text only. |
69
+
70
+ All three themes define the two diff backgrounds. Only `ayu` defines `bashOutput`.
71
+
72
+ ### Why they work at all
73
+
74
+ Three things line up:
75
+
76
+ 1. Theme validation uses TypeBox's `Compile().Check()`, which **allows unknown keys** — the `additionalProperties: false` in pi's `theme-schema.json` is not on the executed path.
77
+ 2. `createTheme()` puts any color that is not one of the seven known background tokens into the `fgColors` table.
78
+ 3. `getFgAnsi()` looks colors up **by key**, without checking the key against the union type. Since a background SGR is a foreground SGR with `38` replaced by `48`, an unknown token can be resolved and used as a background.
79
+
80
+ If pi ever validates strictly, these tokens stop resolving. Nothing breaks loudly: `tool-diff` silently falls back to the much flatter `toolSuccessBg` / `toolErrorBg`, and `bash-command-collapse` falls back to `toolOutput`.
81
+
82
+ ### `bashOutput` in detail
83
+
84
+ pi's built-in bash renderer hardcodes output text to `toolOutput`, a slot shared by every tool (`read`, `grep`, `ls` all use it). To give bash output its own color, `bash-command-collapse.ts` temporarily swaps that one key in the module-level theme singleton while it delegates to the built-in renderer — the swap window must be synchronous, and it can only touch this one key.
85
+
86
+ The extension probes for the token by calling `getFgAnsi("bashOutput")` and does nothing if it throws `Unknown theme color: ...`. So:
87
+
88
+ - Themes without `bashOutput` (including pi's built-ins, `summer-night` and `catppuccin`) are unaffected — bash output simply uses `toolOutput`.
89
+ - To split the color out for any theme, add one `vars` entry and one `colors` line, exactly as `ayu` does.
90
+ - `bashOutput` does not appear in the `/theme` preview swatches, which only draw pi's standard token list.
91
+
92
+ ## Editing or porting a theme
93
+
94
+ Renaming a theme means changing **three** places: the file name, the `name` field inside it, and `theme` in `settings.json`. `loadThemeJson()` resolves `${name}.json`, so a mismatch shows the old name in the picker or leaves `theme` pointing at nothing. If the theme lives in `~/.pi/agent/themes/`, only the file name and `name` field matter; pi matches the setting by name.
95
+
96
+ A theme cannot verify itself. `toolDiffAddedBg` misspelled is silently ignored, and a bad `vars` reference silently drops you to `dark`. After writing a theme, at minimum:
97
+
98
+ ```js
99
+ // parse with pi's own loader and resolve every token
100
+ validateThemeJson(path, themeJson)
101
+ const theme = loadThemeFromPath(path, "truecolor") // and "256color"
102
+ for (const key of Object.keys(colors)) {
103
+ theme.getFgAnsi(key); theme.getBgAnsi(key) // throws on a missing vars reference
104
+ }
105
+ ```
106
+
107
+ If you are porting an upstream theme, parse both files and compare `getFgAnsi()` / `getBgAnsi()` per token name. Identical values mean a faithful port; a difference is either an oversight or a deviation that belongs in a comment.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for answers.ts — 结果封装 / 信封文本 / 错误路径。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/ask-user-question/answers.test.ts
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import {
10
+ buildAnswerSegment,
11
+ buildEnvelopeText,
12
+ buildResponse,
13
+ DECLINE_MESSAGE,
14
+ ENVELOPE_PREFIX,
15
+ ENVELOPE_SUFFIX,
16
+ errorResult,
17
+ formatAnswerScalar,
18
+ NO_INPUT_PLACEHOLDER,
19
+ toolResult,
20
+ } from "./answers.ts";
21
+ import type { AskAnswer, AskParams, AskResult } from "./types.ts";
22
+
23
+ const params: AskParams = {
24
+ questions: [
25
+ { question: "Which library?", header: "Lib", options: [{ label: "A", description: "a" }, { label: "B", description: "b" }] },
26
+ {
27
+ question: "Which features?",
28
+ header: "Feat",
29
+ multiSelect: true,
30
+ options: [{ label: "X", description: "x" }, { label: "Y", description: "y" }],
31
+ },
32
+ ],
33
+ };
34
+
35
+ const optionAnswer: AskAnswer = { questionIndex: 0, question: "Which library?", kind: "option", answer: "A" };
36
+ const multiAnswer: AskAnswer = { questionIndex: 1, question: "Which features?", kind: "multi", answer: null, selected: ["X", "Y"] };
37
+ const emptyMulti: AskAnswer = { questionIndex: 1, question: "Which features?", kind: "multi", answer: null, selected: [] };
38
+ const customAnswer: AskAnswer = { questionIndex: 0, question: "Which library?", kind: "custom", answer: "use luxon" };
39
+ const emptyCustom: AskAnswer = { questionIndex: 0, question: "Which library?", kind: "custom", answer: "" };
40
+
41
+ test("formatAnswerScalar: option / custom / multi / 空值", () => {
42
+ assert.equal(formatAnswerScalar(optionAnswer), "A");
43
+ assert.equal(formatAnswerScalar(customAnswer), "use luxon");
44
+ assert.equal(formatAnswerScalar(multiAnswer), "X, Y");
45
+ assert.equal(formatAnswerScalar(emptyMulti), NO_INPUT_PLACEHOLDER);
46
+ assert.equal(formatAnswerScalar(emptyCustom), NO_INPUT_PLACEHOLDER);
47
+ });
48
+
49
+ test("buildAnswerSegment: \"问题\"=\"答案\".", () => {
50
+ assert.equal(buildAnswerSegment(optionAnswer), '"Which library?"="A".');
51
+ assert.equal(buildAnswerSegment(multiAnswer), '"Which features?"="X, Y".');
52
+ });
53
+
54
+ test("buildEnvelopeText: 全答 → 前缀 + 各段 + 后缀", () => {
55
+ const result: AskResult = { answers: [optionAnswer, multiAnswer], cancelled: false };
56
+ const text = buildEnvelopeText(result, params);
57
+ assert.ok(text.startsWith(`${ENVELOPE_PREFIX} `));
58
+ assert.ok(text.endsWith(` ${ENVELOPE_SUFFIX}`));
59
+ assert.ok(text.includes('"Which library?"="A".'));
60
+ assert.ok(text.includes('"Which features?"="X, Y".'));
61
+ });
62
+
63
+ test("buildEnvelopeText: 部分提交只产生已答段", () => {
64
+ const result: AskResult = { answers: [optionAnswer], cancelled: false };
65
+ const text = buildEnvelopeText(result, params);
66
+ assert.ok(text.includes('"Which library?"="A".'));
67
+ assert.ok(!text.includes("Which features?"));
68
+ });
69
+
70
+ test("buildEnvelopeText: 取消 / null / 零答案段都塌缩成 DECLINE_MESSAGE", () => {
71
+ assert.equal(buildEnvelopeText({ answers: [], cancelled: true }, params), DECLINE_MESSAGE);
72
+ assert.equal(buildEnvelopeText(null, params), DECLINE_MESSAGE);
73
+ assert.equal(buildEnvelopeText(undefined, params), DECLINE_MESSAGE);
74
+ assert.equal(buildEnvelopeText({ answers: [], cancelled: false }, params), DECLINE_MESSAGE);
75
+ });
76
+
77
+ test("buildResponse: 成功路径 content 是信封、details 原样带回", () => {
78
+ const result: AskResult = { answers: [optionAnswer], cancelled: false };
79
+ const out = buildResponse(result, params);
80
+ assert.equal(out.content.length, 1);
81
+ assert.equal(out.content[0].type, "text");
82
+ assert.ok(out.content[0].text.startsWith(ENVELOPE_PREFIX));
83
+ assert.equal(out.details, result);
84
+ });
85
+
86
+ test("buildResponse: 取消路径 content 是 DECLINE_MESSAGE、cancelled:true", () => {
87
+ const out = buildResponse({ answers: [optionAnswer], cancelled: true }, params);
88
+ assert.equal(out.content[0].text, DECLINE_MESSAGE);
89
+ assert.equal(out.details.cancelled, true);
90
+ // 已答部分仍保留在 details 里(回放/渲染用)
91
+ assert.equal(out.details.answers.length, 1);
92
+ });
93
+
94
+ test("errorResult: cancelled:true + error 码 + 写给模型的正文", () => {
95
+ const out = errorResult("Error: boom", "no_ui");
96
+ assert.equal(out.content[0].text, "Error: boom");
97
+ assert.deepEqual(out.details, { answers: [], cancelled: true, error: "no_ui" });
98
+ });
99
+
100
+ test("toolResult: 结构稳定", () => {
101
+ const out = toolResult("hi", { answers: [], cancelled: false });
102
+ assert.deepEqual(out.content, [{ type: "text", text: "hi" }]);
103
+ assert.deepEqual(out.details, { answers: [], cancelled: false });
104
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * answers.ts — 把问卷结果封装成给模型的 tool result(纯函数)。
3
+ *
4
+ * 成功:`User has answered your questions: "<问题>"="<答案>". … You can now
5
+ * continue with the user's answers in mind.`;多选答案用 ", " 连接。
6
+ * 取消 / 无答案段:统一塌缩成 DECLINE_MESSAGE 这一个规范信号,
7
+ * 模型不会把"没答"误读成"拒绝回答内容"之外的任何东西。
8
+ *
9
+ * 错误路径(no_ui / 校验失败等)也走 errorResult:`cancelled: true` +
10
+ * 空 answers + error 码 —— content 文本是写给模型看的,不是日志。
11
+ */
12
+
13
+ import type { AskAnswer, AskErrorCode, AskParams, AskResult } from "./types.ts";
14
+
15
+ export const DECLINE_MESSAGE = "User declined to answer the questions";
16
+ export const ENVELOPE_PREFIX = "User has answered your questions:";
17
+ export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
18
+ export const NO_INPUT_PLACEHOLDER = "(no input)";
19
+
20
+ export interface AskToolResult {
21
+ content: Array<{ type: "text"; text: string }>;
22
+ details: AskResult;
23
+ }
24
+
25
+ /** 单个答案的标量形式:option → label;custom → 输入文本;multi → ", " 连接。 */
26
+ export function formatAnswerScalar(a: AskAnswer): string {
27
+ switch (a.kind) {
28
+ case "multi":
29
+ return a.selected && a.selected.length > 0 ? a.selected.join(", ") : NO_INPUT_PLACEHOLDER;
30
+ case "custom":
31
+ return a.answer && a.answer.length > 0 ? a.answer : NO_INPUT_PLACEHOLDER;
32
+ case "option":
33
+ return a.answer ?? NO_INPUT_PLACEHOLDER;
34
+ }
35
+ }
36
+
37
+ /** 单题答案段:`"<问题>"="<答案>".` */
38
+ export function buildAnswerSegment(a: AskAnswer): string {
39
+ return `"${a.question}"="${formatAnswerScalar(a)}".`;
40
+ }
41
+
42
+ /**
43
+ * 结果 → 给模型的正文。cancelled / null / 零答案段都落到 DECLINE_MESSAGE;
44
+ * 部分提交允许:未回答的题不产生段。
45
+ */
46
+ export function buildEnvelopeText(result: AskResult | null | undefined, params: AskParams): string {
47
+ if (!result || result.cancelled) return DECLINE_MESSAGE;
48
+ const segments: string[] = [];
49
+ for (let i = 0; i < params.questions.length; i++) {
50
+ const a = result.answers.find((x) => x.questionIndex === i);
51
+ if (a) segments.push(buildAnswerSegment(a));
52
+ }
53
+ if (segments.length === 0) return DECLINE_MESSAGE;
54
+ return `${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`;
55
+ }
56
+
57
+ export function toolResult(text: string, details: AskResult): AskToolResult {
58
+ return { content: [{ type: "text", text }], details };
59
+ }
60
+
61
+ /** 完整成功/取消路径:result + params → tool result。 */
62
+ export function buildResponse(result: AskResult | null | undefined, params: AskParams): AskToolResult {
63
+ if (!result || result.cancelled) {
64
+ return toolResult(DECLINE_MESSAGE, { answers: result?.answers ?? [], cancelled: true });
65
+ }
66
+ return toolResult(buildEnvelopeText(result, params), result);
67
+ }
68
+
69
+ /** 错误路径:content 写给模型的错误说明 + cancelled:true + error 码。 */
70
+ export function errorResult(message: string, error: AskErrorCode, answers: AskAnswer[] = []): AskToolResult {
71
+ return toolResult(message, { answers, cancelled: true, error });
72
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tests for dialog.ts — 非 TUI 宿主的顺序 select/input 回退。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/ask-user-question/dialog.test.ts
5
+ *
6
+ * DialogUI 是鸭子类型,用脚本化的假 ui 覆盖:单选命中选项、哨兵行转自由输入、
7
+ * 多选编号解析、自由文本逃生、空提交、关掉对话框取消(含保留已答部分)、
8
+ * 宿主返回清单外内容按取消处理。
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { test } from "node:test";
13
+ import { hasDialogUI, parseIndex, runDialogQuestionnaire, type DialogUI } from "./dialog.ts";
14
+ import type { AskParams } from "./types.ts";
15
+
16
+ /** 脚本化假 ui:select/input 按调用次序吐出预设值,undefined = 用户关掉对话框。 */
17
+ function fakeUi(script: { select?: Array<string | undefined>; input?: Array<string | undefined> }): DialogUI & {
18
+ calls: { select: string[]; input: string[] };
19
+ } {
20
+ const calls = { select: [] as string[], input: [] as string[] };
21
+ return {
22
+ calls,
23
+ async select(title, _options) {
24
+ calls.select.push(title);
25
+ return (script.select ?? []).shift();
26
+ },
27
+ async input(title, _placeholder) {
28
+ calls.input.push(title);
29
+ return (script.input ?? []).shift();
30
+ },
31
+ };
32
+ }
33
+
34
+ const single: AskParams = {
35
+ questions: [
36
+ {
37
+ question: "Which library?",
38
+ header: "Lib",
39
+ options: [
40
+ { label: "date-fns", description: "functional" },
41
+ { label: "dayjs", description: "tiny" },
42
+ ],
43
+ },
44
+ ],
45
+ };
46
+
47
+ const multi: AskParams = {
48
+ questions: [{ ...single.questions[0], question: "Which features?", header: "Feat", multiSelect: true }],
49
+ };
50
+
51
+ const twoQuestions: AskParams = {
52
+ questions: [single.questions[0], { ...single.questions[0], question: "Which style?", header: "Style" }],
53
+ };
54
+
55
+ test("hasDialogUI: 结构化判定", () => {
56
+ assert.equal(hasDialogUI({ select: async () => undefined, input: async () => undefined }), true);
57
+ assert.equal(hasDialogUI({ select: async () => undefined }), false);
58
+ assert.equal(hasDialogUI(null), false);
59
+ assert.equal(hasDialogUI(undefined), false);
60
+ });
61
+
62
+ test("parseIndex: 编号解析与越界", () => {
63
+ assert.equal(parseIndex("1", 3), 0);
64
+ assert.equal(parseIndex("3. dayjs — tiny", 3), 2);
65
+ assert.equal(parseIndex("0", 3), null);
66
+ assert.equal(parseIndex("4", 3), null);
67
+ assert.equal(parseIndex("abc", 3), null);
68
+ });
69
+
70
+ test("单选:选第 1 项 → option 答案", async () => {
71
+ const ui = fakeUi({ select: ["1. date-fns — functional"] });
72
+ const result = await runDialogQuestionnaire(ui, single);
73
+ assert.deepEqual(result, {
74
+ answers: [{ questionIndex: 0, question: "Which library?", kind: "option", answer: "date-fns" }],
75
+ cancelled: false,
76
+ });
77
+ });
78
+
79
+ test("单选:选哨兵行 → 转自由输入", async () => {
80
+ const ui = fakeUi({ select: ["3. Type something."], input: ["use luxon instead"] });
81
+ const result = await runDialogQuestionnaire(ui, single);
82
+ assert.equal(result.cancelled, false);
83
+ assert.equal(result.answers[0].kind, "custom");
84
+ assert.equal(result.answers[0].answer, "use luxon instead");
85
+ });
86
+
87
+ test("单选:哨兵行后关掉输入框 → 整份取消", async () => {
88
+ const ui = fakeUi({ select: ["3. Type something."], input: [undefined] });
89
+ const result = await runDialogQuestionnaire(ui, single);
90
+ assert.equal(result.cancelled, true);
91
+ assert.deepEqual(result.answers, []);
92
+ });
93
+
94
+ test("单选:关掉选择框 → 取消", async () => {
95
+ const ui = fakeUi({ select: [undefined] });
96
+ const result = await runDialogQuestionnaire(ui, single);
97
+ assert.equal(result.cancelled, true);
98
+ });
99
+
100
+ test("单选:宿主返回清单外的串 → 按取消处理,不编造答案", async () => {
101
+ const ui = fakeUi({ select: ["99. not in list"] });
102
+ const result = await runDialogQuestionnaire(ui, single);
103
+ assert.equal(result.cancelled, true);
104
+ });
105
+
106
+ test("多选:\"1,3\" → 两个 label(但只有 2 个选项时 3 越界 → 走自由文本)", async () => {
107
+ const three = {
108
+ questions: [
109
+ {
110
+ ...multi.questions[0],
111
+ options: [
112
+ { label: "X", description: "x" },
113
+ { label: "Y", description: "y" },
114
+ { label: "Z", description: "z" },
115
+ ],
116
+ },
117
+ ],
118
+ };
119
+ const ui = fakeUi({ input: ["1,3"] });
120
+ const result = await runDialogQuestionnaire(ui, three);
121
+ assert.equal(result.cancelled, false);
122
+ assert.deepEqual(result.answers[0], {
123
+ questionIndex: 0,
124
+ question: "Which features?",
125
+ kind: "multi",
126
+ answer: null,
127
+ selected: ["X", "Z"],
128
+ });
129
+ });
130
+
131
+ test("多选:带空格与点号的编号也能解析,且去重", async () => {
132
+ const ui = fakeUi({ input: [" 1. , 2 , 2 "] });
133
+ const result = await runDialogQuestionnaire(ui, multi);
134
+ assert.deepEqual(result.answers[0].selected, ["date-fns", "dayjs"]);
135
+ });
136
+
137
+ test("多选:空提交 → selected 为空数组", async () => {
138
+ const ui = fakeUi({ input: [" "] });
139
+ const result = await runDialogQuestionnaire(ui, multi);
140
+ assert.equal(result.answers[0].kind, "multi");
141
+ assert.deepEqual(result.answers[0].selected, []);
142
+ });
143
+
144
+ test("多选:非编号文本 → 原样保留为 custom(自由文本逃生口)", async () => {
145
+ const ui = fakeUi({ input: ["let's just use luxon"] });
146
+ const result = await runDialogQuestionnaire(ui, multi);
147
+ assert.equal(result.answers[0].kind, "custom");
148
+ assert.equal(result.answers[0].answer, "let's just use luxon");
149
+ });
150
+
151
+ test("多选:越界编号(13)混在编号里 → 整体当自由文本", async () => {
152
+ const ui = fakeUi({ input: ["1, 13"] });
153
+ const result = await runDialogQuestionnaire(ui, multi);
154
+ assert.equal(result.answers[0].kind, "custom");
155
+ assert.equal(result.answers[0].answer, "1, 13");
156
+ });
157
+
158
+ test("多题:第二题被关掉 → 取消但保留第一题答案", async () => {
159
+ const ui = fakeUi({ select: ["1. date-fns — functional", undefined] });
160
+ const result = await runDialogQuestionnaire(ui, twoQuestions);
161
+ assert.equal(result.cancelled, true);
162
+ assert.equal(result.answers.length, 1);
163
+ assert.equal(result.answers[0].answer, "date-fns");
164
+ });
165
+
166
+ test("多题:全部回答 → cancelled:false 且按题序", async () => {
167
+ const ui = fakeUi({ select: ["2. dayjs — tiny", "1. date-fns — functional"] });
168
+ const result = await runDialogQuestionnaire(ui, twoQuestions);
169
+ assert.equal(result.cancelled, false);
170
+ assert.deepEqual(
171
+ result.answers.map((a) => a.answer),
172
+ ["dayjs", "date-fns"],
173
+ );
174
+ });
175
+
176
+ test("对话框标题带 header 前缀", async () => {
177
+ const ui = fakeUi({ select: ["1. date-fns — functional"] });
178
+ await runDialogQuestionnaire(ui, single);
179
+ assert.ok(ui.calls.select[0].startsWith("[Lib] Which library?"));
180
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * dialog.ts — 非 TUI 宿主(RPC / ACP,如 VS Code pendant、Zed)的顺序对话框回退。
3
+ *
4
+ * `ctx.ui.custom()` 在这些宿主上渲染不出来,但 select / input 对话框子协议
5
+ * (extension_ui_request/response)是好用的,所以逐题走原生对话框,产出与 TUI
6
+ * 完全相同的 AskResult,喂给同一个 buildResponse 封装。纯函数(鸭子类型的
7
+ * DialogUI),不 import pi —— 单测用假 ui 覆盖。
8
+ *
9
+ * 与 TUI 的取舍:没有 tab 式多题总览(一题一个对话框);多选退化成
10
+ * "输入编号,逗号分隔" 的文本输入;任何非编号输入都当自由回答原样保留
11
+ * (这也是多选的 "Type something." 逃生口)。任一对话框被关掉(resolve
12
+ * undefined)= 整份问卷取消,与 TUI 里按 Esc 同义。
13
+ */
14
+
15
+ import { CUSTOM_ANSWER_LABEL, type AskAnswer, type AskParams, type AskQuestion, type AskResult } from "./types.ts";
16
+
17
+ /** ExtensionUIContext 上本模块需要的切片。结构化类型 + hasDialogUI 运行时闸门。 */
18
+ export type DialogUI = {
19
+ select: (title: string, options: string[]) => Promise<string | undefined>;
20
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
21
+ };
22
+
23
+ export function hasDialogUI(ui: unknown): ui is DialogUI {
24
+ const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
25
+ return typeof u?.select === "function" && typeof u?.input === "function";
26
+ }
27
+
28
+ function formatOptionLine(q: AskQuestion, index: number): string {
29
+ const o = q.options[index];
30
+ return `${index + 1}. ${o.label} — ${o.description}`;
31
+ }
32
+
33
+ /** 把 "2. Label — desc" 或 "2" 这样的选择串解析成 0 基下标;越界 / 非数字 → null。 */
34
+ export function parseIndex(token: string, count: number): number | null {
35
+ const i = Number.parseInt(token, 10) - 1;
36
+ return Number.isFinite(i) && i >= 0 && i < count ? i : null;
37
+ }
38
+
39
+ /** 逐题走原生对话框。undefined(关掉对话框)= 取消整份问卷,保留已答部分。 */
40
+ export async function runDialogQuestionnaire(ui: DialogUI, params: AskParams): Promise<AskResult> {
41
+ const answers: AskAnswer[] = [];
42
+ for (let qi = 0; qi < params.questions.length; qi++) {
43
+ const q = params.questions[qi];
44
+ const header = q.header ? `[${q.header}] ` : "";
45
+ const answer = q.multiSelect ? await askMultiSelect(ui, q, qi, header) : await askSingleSelect(ui, q, qi, header);
46
+ if (answer === undefined) return { answers, cancelled: true };
47
+ answers.push(answer);
48
+ }
49
+ return { answers, cancelled: false };
50
+ }
51
+
52
+ async function askSingleSelect(
53
+ ui: DialogUI,
54
+ q: AskQuestion,
55
+ questionIndex: number,
56
+ header: string,
57
+ ): Promise<AskAnswer | undefined> {
58
+ const options = q.options.map((_o, i) => formatOptionLine(q, i));
59
+ options.push(`${q.options.length + 1}. ${CUSTOM_ANSWER_LABEL}`);
60
+ const chosen = await ui.select(`${header}${q.question}`, options);
61
+ if (chosen == null) return undefined;
62
+ const idx = parseIndex(chosen, options.length);
63
+ // 宿主返回了清单外的东西与关掉对话框无法区分,一律当取消,不编造答案。
64
+ if (idx == null) return undefined;
65
+ if (idx < q.options.length) {
66
+ return { questionIndex, question: q.question, kind: "option", answer: q.options[idx].label };
67
+ }
68
+ const typed = await ui.input(`${header}${q.question}\n\nType your answer:`, "");
69
+ if (typed == null) return undefined;
70
+ return { questionIndex, question: q.question, kind: "custom", answer: typed };
71
+ }
72
+
73
+ const MULTI_INSTRUCTIONS = 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
74
+
75
+ async function askMultiSelect(
76
+ ui: DialogUI,
77
+ q: AskQuestion,
78
+ questionIndex: number,
79
+ header: string,
80
+ ): Promise<AskAnswer | undefined> {
81
+ const list = q.options.map((_o, i) => formatOptionLine(q, i)).join("\n");
82
+ const value = await ui.input(`${header}${q.question}\n\n${list}\n\n${MULTI_INSTRUCTIONS}`, "1,3");
83
+ if (value == null) return undefined;
84
+ const trimmed = value.trim();
85
+ if (trimmed.length === 0) {
86
+ // 空提交 = 一个都不选,与 TUI 里不勾任何项直接 Enter 同义。
87
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
88
+ }
89
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
90
+ const indices = tokens.map((tok) => (/^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null));
91
+ if (indices.every((i): i is number => i != null)) {
92
+ const selected: string[] = [];
93
+ for (const i of indices) {
94
+ const label = q.options[i].label;
95
+ if (!selected.includes(label)) selected.push(label);
96
+ }
97
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
98
+ }
99
+ // 出现任何非编号 token(文字、或 "13" 这种越界编号)= 用户在打字回答,
100
+ // 原样保留为 custom,不静默丢弃。
101
+ return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
102
+ }