@selesai/code 0.5.21 → 0.5.23

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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/core/agent-session-skill-block.test.d.ts +2 -0
  3. package/dist/core/agent-session-skill-block.test.d.ts.map +1 -0
  4. package/dist/core/agent-session-skill-block.test.js +20 -0
  5. package/dist/core/agent-session-skill-block.test.js.map +1 -0
  6. package/dist/core/agent-session.d.ts +7 -4
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +22 -12
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/skills.d.ts.map +1 -1
  11. package/dist/core/skills.js +1 -1
  12. package/dist/core/skills.js.map +1 -1
  13. package/dist/data/capabilities.d.ts +48 -0
  14. package/dist/data/capabilities.d.ts.map +1 -0
  15. package/dist/data/capabilities.js +282 -0
  16. package/dist/data/capabilities.js.map +1 -0
  17. package/dist/data/navigation.d.ts +3 -0
  18. package/dist/data/navigation.d.ts.map +1 -0
  19. package/dist/data/navigation.js +90 -0
  20. package/dist/data/navigation.js.map +1 -0
  21. package/dist/extensions/inline-skills.test.ts +96 -0
  22. package/dist/extensions/inline-skills.ts +92 -0
  23. package/dist/extensions/package.json +1 -0
  24. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -0
  25. package/dist/extensions/pi-powerline-footer/tps.ts +172 -113
  26. package/dist/extensions/pi-subagents/src/tui/fleet.ts +0 -4
  27. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -1
  28. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +1 -1
  29. package/dist/extensions/question/batch.ts +89 -25
  30. package/dist/extensions/question/constants.ts +1 -1
  31. package/dist/extensions/question/helpers.ts +34 -78
  32. package/dist/extensions/question/index.ts +327 -564
  33. package/dist/extensions/question/package.json +2 -1
  34. package/dist/extensions/question/question-list.ts +21 -14
  35. package/dist/extensions/question/row-layout.ts +2 -2
  36. package/dist/extensions/question/schemas.ts +33 -47
  37. package/dist/extensions/question/selection-mode.ts +5 -7
  38. package/dist/extensions/question/tests/batch.test.ts +65 -16
  39. package/dist/extensions/question/tests/helpers.test.ts +31 -156
  40. package/dist/extensions/question/tests/question-list.test.ts +17 -3
  41. package/dist/extensions/question/tests/row-layout.test.ts +4 -4
  42. package/dist/extensions/question/tests/schemas.test.ts +30 -0
  43. package/dist/extensions/question/tests/wizard.test.ts +99 -0
  44. package/dist/extensions/question/types.ts +65 -66
  45. package/dist/extensions/workflow/adapter.ts +20 -77
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +10 -10
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/dist/skills/workflow-creation/SKILL.md +2 -2
  54. package/docs/skills.md +8 -0
  55. package/docs/workflows.md +4 -4
  56. package/package.json +1 -1
@@ -9,10 +9,11 @@
9
9
  },
10
10
  "peerDependencies": {
11
11
  "@selesai/code": "*",
12
+ "@earendil-works/pi-ai": "*",
12
13
  "@earendil-works/pi-tui": "*",
13
14
  "typebox": "*"
14
15
  },
15
16
  "scripts": {
16
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
17
+ "test": "tsx --test tests/**/*.test.ts"
17
18
  }
18
19
  }
@@ -7,14 +7,12 @@ import {
7
7
  type Component,
8
8
  fuzzyFilter,
9
9
  Key,
10
- Markdown,
11
10
  matchesKey,
12
11
  truncateToWidth,
13
12
  wrapTextWithAnsi,
14
13
  } from "@earendil-works/pi-tui";
15
14
 
16
15
  import { COMMENT_TOGGLE_LABEL, SPLIT_PANE_LEFT_MIN_WIDTH, SPLIT_PANE_MIN_WIDTH, SPLIT_PANE_RIGHT_MIN_WIDTH, SPLIT_PANE_SEPARATOR } from "./constants.ts";
17
- import { safeMarkdownTheme } from "./helpers.ts";
18
16
  import { matchesDown, matchesUp } from "./navigation.ts";
19
17
  import { renderSingleSelectRows } from "./row-layout.ts";
20
18
  import type { QuestionOption, ResolvedShortcut } from "./types.ts";
@@ -67,6 +65,22 @@ export class QuestionList implements Component {
67
65
  return this.commentEnabled;
68
66
  }
69
67
 
68
+ restoreSelection(values: readonly string[], comment?: string): void {
69
+ const indexes = values
70
+ .map((value) => this.options.findIndex((option) => option.value === value))
71
+ .filter((index) => index >= 0);
72
+ this.checked = new Set(indexes);
73
+ this.selectedIndex = indexes[0] ?? 0;
74
+ this.commentEnabled = Boolean(comment);
75
+ this.searchQuery = "";
76
+ this.invalidate();
77
+ }
78
+
79
+ getCheckedValues(): string[] {
80
+ if (!this.selectionMode.multi) return [];
81
+ return this.selectionMode.buildResult({ selectedIndex: this.selectedIndex, checked: this.checked, options: this.options });
82
+ }
83
+
70
84
  setMaxVisibleRows(rows: number): void {
71
85
  if (this.selectionMode.multi) return; // ponytail: multi-select uses fixed max 10, no-op here
72
86
  const next = Math.max(1, Math.floor(rows));
@@ -201,7 +215,6 @@ export class QuestionList implements Component {
201
215
 
202
216
  private buildPreviewLines(width: number, filtered: QuestionOption[], maxLines: number): string[] {
203
217
  if (maxLines <= 0) return [];
204
- const mdTheme = safeMarkdownTheme();
205
218
  let md = "";
206
219
 
207
220
  if (this.isCommentToggleRow(this.selectedIndex, filtered)) {
@@ -223,14 +236,9 @@ export class QuestionList implements Component {
223
236
  }
224
237
  }
225
238
 
226
- let lines: string[];
227
- if (mdTheme) {
228
- lines = new Markdown(md.trim(), 0, 0, mdTheme).render(width);
229
- } else {
230
- lines = [];
231
- for (const line of wrapTextWithAnsi(md.trim(), Math.max(10, width))) {
232
- lines.push(truncateToWidth(line, width, ""));
233
- }
239
+ const lines: string[] = [];
240
+ for (const line of wrapTextWithAnsi(md.trim(), Math.max(10, width))) {
241
+ lines.push(truncateToWidth(line, width, ""));
234
242
  }
235
243
  while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
236
244
  if (lines.length <= maxLines) return lines;
@@ -306,7 +314,7 @@ export class QuestionList implements Component {
306
314
  this.onEnterFreeform?.();
307
315
  return;
308
316
  }
309
- const result = filtered[this.selectedIndex]?.label;
317
+ const result = filtered[this.selectedIndex]?.value;
310
318
  if (result) this.onSubmit?.([result], this.commentEnabled);
311
319
  else this.onCancel?.();
312
320
  return;
@@ -393,8 +401,7 @@ export class QuestionList implements Component {
393
401
  checked: this.checked,
394
402
  options: this.options,
395
403
  });
396
- if (result.length > 0) this.onSubmit?.(result, this.commentEnabled);
397
- else this.onCancel?.();
404
+ this.onSubmit?.(result, this.commentEnabled);
398
405
  }
399
406
  }
400
407
 
@@ -60,11 +60,11 @@ export function buildItemBlocks(
60
60
  if (allowComment) {
61
61
  allItems.push({
62
62
  type: "comment-toggle",
63
- option: { label: `${commentEnabled ? "[✓]" : "[ ]"} ${COMMENT_TOGGLE_LABEL}` },
63
+ option: { value: "__comment__", label: `${commentEnabled ? "[✓]" : "[ ]"} ${COMMENT_TOGGLE_LABEL}` },
64
64
  });
65
65
  }
66
66
  if (allowFreeform) {
67
- allItems.push({ type: "freeform", option: { label: FREEFORM_LABEL } });
67
+ allItems.push({ type: "freeform", option: { value: "__other__", label: FREEFORM_LABEL } });
68
68
  }
69
69
 
70
70
  return allItems.map((item, itemIndex) => {
@@ -1,57 +1,43 @@
1
- // Typebox schemas for the question tool parameters.
1
+ // Strict, batch-first TypeBox schema for the question tool.
2
2
 
3
+ import { StringEnum } from "@earendil-works/pi-ai";
3
4
  import { Type } from "typebox";
4
5
 
5
- const OptionSchema = Type.Union([
6
- Type.String({ description: "Option label" }),
7
- Type.Object({
8
- label: Type.String({ description: "Option label shown to the user" }),
9
- description: Type.Optional(Type.String({ description: "Optional help text shown under the option" })),
10
- }),
11
- ]);
6
+ const OptionSchema = Type.Object({
7
+ value: Type.String({ description: "Stable machine value returned when selected" }),
8
+ label: Type.String({ description: "Human-readable option label" }),
9
+ description: Type.Optional(Type.String({ description: "Optional help text shown under the option" })),
10
+ }, { additionalProperties: false });
12
11
 
13
- const QuestionFields = {
14
- question: Type.String({ description: "Question to ask the user before continuing" }),
15
- title: Type.Optional(Type.String({ description: "Short title for the question UI (deprecated alias for context summary)" })),
16
- context: Type.Optional(Type.String({ description: "Relevant context summary shown before the question" })),
17
- options: Type.Optional(
18
- Type.Array(OptionSchema, {
19
- description: "Optional multiple-choice options. Each option may be a string or { label, description }.",
20
- }),
21
- ),
22
- allowMultiple: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options. Default: false" })),
23
- allowFreeform: Type.Optional(
24
- Type.Boolean({
25
- description: "Allow a custom freeform answer. Defaults to true when no options are provided, false when options exist.",
26
- }),
27
- ),
28
- allowComment: Type.Optional(Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" })),
29
- commentToggleKey: Type.Optional(
30
- Type.String({
31
- description:
32
- "Shortcut for toggling the optional comment/extra-context row when allowComment is true, e.g. 'ctrl+g'. Pass 'off' to disable. Default: PI_QUESTION_COMMENT_TOGGLE_KEY env var if set, otherwise 'ctrl+g'.",
33
- }),
34
- ),
12
+ const QuestionBase = {
13
+ id: Type.Optional(Type.String({ description: "Unique answer id. Missing ids become q1, q2, and so on." })),
14
+ label: Type.Optional(Type.String({ description: "Short page label. Defaults to Q1, Q2, and so on." })),
15
+ question: Type.String({ description: "Question shown to the user" }),
16
+ context: Type.Optional(Type.String({ description: "Optional plain-text context shown before the question" })),
35
17
  };
36
18
 
37
- const SingleQuestionParamsSchema = Type.Object({
38
- ...QuestionFields,
39
- timeout: Type.Optional(Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." })),
40
- });
19
+ const SelectQuestionSchema = Type.Object({
20
+ ...QuestionBase,
21
+ type: StringEnum(["select"] as const),
22
+ options: Type.Array(OptionSchema, { minItems: 1, description: "Available choices" }),
23
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow a custom Other text answer" })),
24
+ }, { additionalProperties: false });
41
25
 
42
- const BatchQuestionSchema = Type.Object({
43
- id: Type.Optional(Type.String({ description: "Unique answer id. Defaults to q1, q2, and so on." })),
44
- label: Type.Optional(Type.String({ description: "Short page label. Defaults to Q1, Q2, and so on." })),
45
- ...QuestionFields,
46
- });
26
+ const MultiSelectQuestionSchema = Type.Object({
27
+ ...QuestionBase,
28
+ type: StringEnum(["multiselect"] as const),
29
+ options: Type.Array(OptionSchema, { minItems: 1, description: "Available choices" }),
30
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow custom Other text alongside selected values" })),
31
+ }, { additionalProperties: false });
47
32
 
48
- const BatchQuestionParamsSchema = Type.Object({
49
- questions: Type.Array(BatchQuestionSchema, {
33
+ const TextQuestionSchema = Type.Object({
34
+ ...QuestionBase,
35
+ type: StringEnum(["text"] as const),
36
+ }, { additionalProperties: false });
37
+
38
+ export const QuestionParamsSchema = Type.Object({
39
+ questions: Type.Array(Type.Union([SelectQuestionSchema, MultiSelectQuestionSchema, TextQuestionSchema]), {
50
40
  minItems: 1,
51
- description:
52
- "Questions to answer in one paged form. User can move between pages with Tab/Shift+Tab or Left/Right, then press Enter on the final review page to submit all answers.",
41
+ description: "One or more typed questions shown as a paged wizard followed by atomic review and submission.",
53
42
  }),
54
- timeout: Type.Optional(Type.Number({ description: "Auto-dismiss the whole batch after N milliseconds." })),
55
- });
56
-
57
- export const QuestionParamsSchema = Type.Union([SingleQuestionParamsSchema, BatchQuestionParamsSchema]);
43
+ }, { additionalProperties: false });
@@ -20,8 +20,8 @@ export const SingleSelect: SelectionMode = {
20
20
  multi: false,
21
21
  toggle() {},
22
22
  buildResult(state) {
23
- const label = state.options[state.selectedIndex]?.label;
24
- return label ? [label] : [];
23
+ const value = state.options[state.selectedIndex]?.value;
24
+ return value ? [value] : [];
25
25
  },
26
26
  handleNumberKey(idx, count) {
27
27
  return { toggle: false, selectIndex: Math.min(idx, count - 1) };
@@ -36,12 +36,10 @@ export const MultiSelect: SelectionMode = {
36
36
  else checked.add(index);
37
37
  },
38
38
  buildResult(state) {
39
- const selectedTitles = Array.from(state.checked)
39
+ return Array.from(state.checked)
40
40
  .sort((a, b) => a - b)
41
- .map((i) => state.options[i]?.label)
42
- .filter((t): t is string => !!t);
43
- const fallback = state.options[state.selectedIndex]?.label;
44
- return selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
41
+ .map((i) => state.options[i]?.value)
42
+ .filter((value): value is string => !!value);
45
43
  },
46
44
  handleNumberKey(idx, count) {
47
45
  return { toggle: true, selectIndex: Math.min(idx, count - 1) };
@@ -1,26 +1,75 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { prepareBatchQuestions } from "../batch.ts";
4
+ import { buildQuestionAnswers, prepareQuestions, saveDraftQuestion } from "../batch.ts";
5
+ import type { DraftQuestionState } from "../types.ts";
5
6
 
6
- test("prepareBatchQuestions: normalizes defaults and generated ids", () => {
7
- const result = prepareBatchQuestions([
8
- { question: "Pick a color", options: ["Red", "Blue"] },
9
- { id: "notes", label: "Notes", question: "Anything else?" },
7
+ test("prepareQuestions: normalizes typed questions and generated ids", () => {
8
+ const result = prepareQuestions([
9
+ { type: "select", question: "Pick a color", options: [{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }] },
10
+ { type: "text", id: "notes", label: "Notes", question: "Anything else?" },
10
11
  ]);
11
12
  assert.ok("questions" in result);
12
13
  if (!("questions" in result)) return;
13
- assert.deepEqual(
14
- result.questions.map(({ id, label, question, options, allowFreeform }) => ({ id, label, question, options, allowFreeform })),
15
- [
16
- { id: "q1", label: "Q1", question: "Pick a color", options: [{ label: "Red" }, { label: "Blue" }], allowFreeform: false },
17
- { id: "notes", label: "Notes", question: "Anything else?", options: [], allowFreeform: true },
18
- ],
19
- );
14
+ assert.deepEqual(result.questions, [
15
+ {
16
+ id: "q1",
17
+ label: "Q1",
18
+ question: "Pick a color",
19
+ context: undefined,
20
+ type: "select",
21
+ options: [
22
+ { value: "red", label: "Red", description: undefined },
23
+ { value: "blue", label: "Blue", description: undefined },
24
+ ],
25
+ allowOther: false,
26
+ },
27
+ {
28
+ id: "notes",
29
+ label: "Notes",
30
+ question: "Anything else?",
31
+ context: undefined,
32
+ type: "text",
33
+ options: [],
34
+ allowOther: false,
35
+ },
36
+ ]);
37
+ });
38
+
39
+ test("prepareQuestions: rejects empty batches, generated-id collisions, and duplicate option values", () => {
40
+ assert.deepEqual(prepareQuestions([]), { error: "questions must contain at least one question." });
41
+ assert.match((prepareQuestions([
42
+ { type: "text", id: "q2", question: "One" },
43
+ { type: "text", question: "Two" },
44
+ ]) as { error: string }).error, /duplicate id: q2/);
45
+ assert.match((prepareQuestions([{
46
+ type: "select",
47
+ question: "Choose",
48
+ options: [{ value: "same", label: "A" }, { value: "same", label: "B" }],
49
+ }]) as { error: string }).error, /duplicate value: same/);
50
+ });
51
+
52
+ test("saveDraftQuestion: clearing an answer while navigating back leaves it unanswered", () => {
53
+ const states = new Map<string, DraftQuestionState>([["q1", { status: "answered", response: { kind: "text", text: "old" } }]]);
54
+ saveDraftQuestion(states, "q1", null, false);
55
+ assert.equal(states.has("q1"), false);
20
56
  });
21
57
 
22
- test("prepareBatchQuestions: rejects empty batches, duplicate ids, and impossible questions", () => {
23
- assert.deepEqual(prepareBatchQuestions([]), { error: "questions must contain at least one question." });
24
- assert.match((prepareBatchQuestions([{ id: "same", question: "One" }, { id: "same", question: "Two" }]) as { error: string }).error, /duplicate id/);
25
- assert.match((prepareBatchQuestions([{ question: "Choose", allowFreeform: false }]) as { error: string }).error, /options are empty/);
58
+ test("buildQuestionAnswers: preserves answered, skipped, and unvisited states", () => {
59
+ const prepared = prepareQuestions([
60
+ { type: "text", id: "a", question: "A" },
61
+ { type: "text", id: "b", question: "B" },
62
+ { type: "text", id: "c", question: "C" },
63
+ ]);
64
+ assert.ok("questions" in prepared);
65
+ if (!("questions" in prepared)) return;
66
+ const states = new Map<string, DraftQuestionState>([
67
+ ["a", { status: "answered", response: { kind: "text", text: "answer" } }],
68
+ ["b", { status: "skipped" }],
69
+ ]);
70
+ assert.deepEqual(buildQuestionAnswers(prepared.questions, states), [
71
+ { id: "a", status: "answered", response: { kind: "text", text: "answer" } },
72
+ { id: "b", status: "skipped" },
73
+ { id: "c", status: "unanswered" },
74
+ ]);
26
75
  });
@@ -2,177 +2,52 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
4
  import {
5
- normalizeOptions,
6
- getOptionsFormatError,
7
- createFreeformResponse,
8
5
  createSelectionResponse,
6
+ createTextResponse,
7
+ formatQuestionResponse,
9
8
  formatResponseSummary,
10
- formatOptionsForMessage,
11
- parseDialogSelections,
12
- isCancelled,
13
9
  oneLine,
10
+ previewText,
14
11
  } from "../helpers.ts";
12
+ import type { PreparedQuestion } from "../types.ts";
15
13
 
16
- test("normalizeOptions: string array", () => {
17
- assert.deepEqual(normalizeOptions(["a", "b"]), [
18
- { label: "a" },
19
- { label: "b" },
20
- ]);
14
+ test("createTextResponse trims edges and rejects blank text", () => {
15
+ assert.equal(createTextResponse(" "), null);
16
+ assert.deepEqual(createTextResponse(" hello\nworld "), { kind: "text", text: "hello\nworld" });
21
17
  });
22
18
 
23
- test("normalizeOptions: object array", () => {
24
- assert.deepEqual(
25
- normalizeOptions([{ label: "a", description: "desc" }]),
26
- [{ label: "a", description: "desc" }],
27
- );
28
- });
29
-
30
- test("normalizeOptions: mixed string and object", () => {
31
- assert.deepEqual(normalizeOptions(["a", { label: "b", description: "d" }]), [
32
- { label: "a" },
33
- { label: "b", description: "d" },
34
- ]);
35
- });
36
-
37
- test("normalizeOptions: empty/undefined", () => {
38
- assert.deepEqual(normalizeOptions(undefined), []);
39
- assert.deepEqual(normalizeOptions([]), []);
40
- });
41
-
42
- test("normalizeOptions: whitespace-only labels filtered", () => {
43
- assert.deepEqual(normalizeOptions([" ", "a", ""]), [{ label: "a" }]);
44
- });
45
-
46
- test("getOptionsFormatError: rejects JSON-encoded option arrays", () => {
47
- assert.match(getOptionsFormatError(['[{"label":"Yes","description":"desc"}]']) ?? "", /JSON-encoded string/);
48
- });
49
-
50
- test("getOptionsFormatError: allows normal string labels", () => {
51
- assert.equal(getOptionsFormatError(["Yes", "No"]), null);
52
- });
53
-
54
- test("createFreeformResponse: null/undefined/empty → null", () => {
55
- assert.equal(createFreeformResponse(null), null);
56
- assert.equal(createFreeformResponse(undefined), null);
57
- assert.equal(createFreeformResponse(""), null);
58
- assert.equal(createFreeformResponse(" "), null);
59
- });
60
-
61
- test("createFreeformResponse: non-empty → { kind: freeform }", () => {
62
- assert.deepEqual(createFreeformResponse("hello"), { kind: "freeform", text: "hello" });
63
- assert.deepEqual(createFreeformResponse(" trim "), { kind: "freeform", text: "trim" });
64
- });
65
-
66
- test("createSelectionResponse: empty → null", () => {
67
- assert.equal(createSelectionResponse([]), null);
68
- assert.equal(createSelectionResponse(["", " "]), null);
69
- });
70
-
71
- test("createSelectionResponse: single", () => {
72
- assert.deepEqual(createSelectionResponse(["a"]), { kind: "selection", selections: ["a"] });
73
- });
74
-
75
- test("createSelectionResponse: multiple with trim", () => {
76
- assert.deepEqual(createSelectionResponse([" a ", "b"]), { kind: "selection", selections: ["a", "b"] });
77
- });
78
-
79
- test("createSelectionResponse: with comment", () => {
80
- assert.deepEqual(createSelectionResponse(["a"], "note"), {
19
+ test("createSelectionResponse supports values plus Other and rejects empty", () => {
20
+ assert.equal(createSelectionResponse([], " "), null);
21
+ assert.deepEqual(createSelectionResponse([" a ", "b"], " other "), {
81
22
  kind: "selection",
82
- selections: ["a"],
83
- comment: "note",
23
+ values: ["a", "b"],
24
+ otherText: "other",
84
25
  });
85
26
  });
86
27
 
87
- test("createSelectionResponse: comment whitespace trimmed", () => {
88
- assert.deepEqual(createSelectionResponse(["a"], " note "), {
89
- kind: "selection",
90
- selections: ["a"],
91
- comment: "note",
92
- });
93
- });
94
-
95
- test("createSelectionResponse: empty comment → no comment field", () => {
96
- assert.deepEqual(createSelectionResponse(["a"], " "), { kind: "selection", selections: ["a"] });
97
- });
98
-
99
- test("formatResponseSummary: freeform → text", () => {
100
- assert.equal(formatResponseSummary({ kind: "freeform", text: "hello" }), "hello");
28
+ test("formatResponseSummary returns machine values", () => {
29
+ assert.equal(formatResponseSummary({ kind: "selection", values: ["a", "b"], otherText: "c" }), "a, b, c");
30
+ assert.equal(formatResponseSummary({ kind: "text", text: "hello" }), "hello");
101
31
  });
102
32
 
103
- test("formatResponseSummary: selection without comment", () => {
104
- assert.equal(formatResponseSummary({ kind: "selection", selections: ["a", "b"] }), "a, b");
33
+ test("formatQuestionResponse maps stable values to labels", () => {
34
+ const question: PreparedQuestion = {
35
+ id: "q1",
36
+ label: "Q1",
37
+ question: "Pick",
38
+ type: "multiselect",
39
+ options: [{ value: "a", label: "Alpha" }, { value: "b", label: "Beta" }],
40
+ allowOther: true,
41
+ };
42
+ assert.equal(formatQuestionResponse(question, { kind: "selection", values: ["b"], otherText: "Gamma" }), "Beta, Other: Gamma");
105
43
  });
106
44
 
107
- test("formatResponseSummary: selection with comment", () => {
108
- assert.equal(
109
- formatResponseSummary({ kind: "selection", selections: ["a", "b"], comment: "note" }),
110
- "a, b — note",
111
- );
45
+ test("previewText bounds multiline summaries", () => {
46
+ assert.equal(previewText("one\ntwo\nthree", 160, 2), "one\ntwo…");
47
+ assert.equal(previewText("a".repeat(200), 10, 2), `${"a".repeat(9)}…`);
112
48
  });
113
49
 
114
- test("formatOptionsForMessage: 0 options → empty string", () => {
115
- assert.equal(formatOptionsForMessage([]), "");
116
- });
117
-
118
- test("formatOptionsForMessage: 1 option", () => {
119
- assert.equal(formatOptionsForMessage([{ label: "a" }]), "1. a");
120
- });
121
-
122
- test("formatOptionsForMessage: with description", () => {
123
- assert.equal(formatOptionsForMessage([{ label: "a", description: "desc" }]), "1. a — desc");
124
- });
125
-
126
- test("formatOptionsForMessage: multiple", () => {
127
- assert.equal(
128
- formatOptionsForMessage([{ label: "a" }, { label: "b", description: "d" }]),
129
- "1. a\n2. b — d",
130
- );
131
- });
132
-
133
- test("parseDialogSelections: basic CSV", () => {
134
- assert.deepEqual(parseDialogSelections("a,b,c"), ["a", "b", "c"]);
135
- });
136
-
137
- test("parseDialogSelections: whitespace trimmed", () => {
138
- assert.deepEqual(parseDialogSelections(" a , b "), ["a", "b"]);
139
- });
140
-
141
- test("parseDialogSelections: empty → []", () => {
142
- assert.deepEqual(parseDialogSelections(""), []);
143
- });
144
-
145
- test("parseDialogSelections: empty entries filtered", () => {
146
- assert.deepEqual(parseDialogSelections("a,,b"), ["a", "b"]);
147
- });
148
-
149
- test("isCancelled: null/undefined → true", () => {
150
- assert.equal(isCancelled(null), true);
151
- assert.equal(isCancelled(undefined), true);
152
- });
153
-
154
- test("isCancelled: falsy non-null → false", () => {
155
- assert.equal(isCancelled(""), false);
156
- assert.equal(isCancelled(0), false);
157
- assert.equal(isCancelled(false), false);
158
- });
159
-
160
- test("oneLine: undefined → empty string", () => {
161
- assert.equal(oneLine(undefined), "");
162
- assert.equal(oneLine(null), "");
163
- });
164
-
165
- test("oneLine: short → unchanged", () => {
166
- assert.equal(oneLine("hello"), "hello");
167
- });
168
-
169
- test("oneLine: long → truncated with …", () => {
170
- const long = "a".repeat(120);
171
- const result = oneLine(long, 10);
172
- assert.equal(result.length, 10);
173
- assert.equal(result.endsWith("…"), true);
174
- });
175
-
176
- test("oneLine: whitespace collapsed", () => {
50
+ test("oneLine collapses whitespace and truncates", () => {
177
51
  assert.equal(oneLine(" hello world "), "hello world");
178
- });
52
+ assert.equal(oneLine("a".repeat(20), 10), `${"a".repeat(9)}…`);
53
+ });
@@ -36,7 +36,7 @@ const disabledShortcut: ResolvedShortcut = {
36
36
  matches: ((_data: string) => false) as (data: string) => false,
37
37
  };
38
38
 
39
- const opts = (labels: string[]) => labels.map((label) => ({ label }));
39
+ const opts = (labels: string[]) => labels.map((label) => ({ value: label, label }));
40
40
 
41
41
  // Terminal data bytes
42
42
  const ESC = "\x1b";
@@ -163,12 +163,26 @@ test("MultiSelect: confirm with checked items → onSubmit with sorted labels",
163
163
  assert.deepEqual(submitted, [["a", "b"], false]); // sorted by index
164
164
  });
165
165
 
166
- test("MultiSelect: confirm with nothing checked → fallback to highlighted label", () => {
166
+ test("QuestionList: restores saved multi-select choices and comment state", () => {
167
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
168
+ list.restoreSelection(["a", "c"], "reason");
169
+ assert.deepEqual([...((list as any).checked)], [0, 2]);
170
+ assert.equal((list as any).selectedIndex, 0);
171
+ assert.equal(list.isCommentEnabled(), true);
172
+ });
173
+
174
+ test("QuestionList: restores saved single-select cursor", () => {
175
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
176
+ list.restoreSelection(["b"]);
177
+ assert.equal((list as any).selectedIndex, 1);
178
+ });
179
+
180
+ test("MultiSelect: confirm with nothing checked → empty selection", () => {
167
181
  const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
168
182
  let submitted: [string[], boolean] | null = null;
169
183
  list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
170
184
  list.handleInput(ENTER);
171
- assert.deepEqual(submitted, [["a"], false]); // highlighted fallback
185
+ assert.deepEqual(submitted, [[], false]);
172
186
  });
173
187
 
174
188
  test("MultiSelect: confirm with no options → onCancel", () => {
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
4
4
  import { wrapPlain, buildItemBlocks, flattenBlocks, renderSingleSelectRows } from "../row-layout.ts";
5
5
  import type { ItemBlock, QuestionOption } from "../types.ts";
6
6
 
7
- const opts = (labels: string[]): QuestionOption[] => labels.map((label) => ({ label }));
7
+ const opts = (labels: string[]): QuestionOption[] => labels.map((label) => ({ value: label, label }));
8
8
 
9
9
  test("wrapPlain: empty → ['']", () => {
10
10
  assert.deepEqual(wrapPlain("", 20), [""]);
@@ -45,7 +45,7 @@ test("buildItemBlocks: selected=1 → arrow pointer on block 1", () => {
45
45
 
46
46
  test("buildItemBlocks: with description → description lines included", () => {
47
47
  const blocks = buildItemBlocks(
48
- [{ label: "a", description: "some desc" }],
48
+ [{ value: "a", label: "a", description: "some desc" }],
49
49
  80,
50
50
  false,
51
51
  false,
@@ -58,7 +58,7 @@ test("buildItemBlocks: with description → description lines included", () => {
58
58
 
59
59
  test("buildItemBlocks: hideDescriptions=true → no description lines", () => {
60
60
  const blocks = buildItemBlocks(
61
- [{ label: "a", description: "some desc" }],
61
+ [{ value: "a", label: "a", description: "some desc" }],
62
62
  80,
63
63
  false,
64
64
  false,
@@ -125,7 +125,7 @@ test("renderSingleSelectRows: maxRows truncation with indicator", () => {
125
125
 
126
126
  test("renderSingleSelectRows: selected block larger than available → only selected + indicator", () => {
127
127
  const rows = renderSingleSelectRows({
128
- options: [{ label: "a".repeat(100) }],
128
+ options: [{ value: "a", label: "a".repeat(100) }],
129
129
  selectedIndex: 0,
130
130
  width: 10,
131
131
  allowFreeform: false,
@@ -0,0 +1,30 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { Value } from "typebox/value";
5
+
6
+ import { QuestionParamsSchema } from "../schemas.ts";
7
+
8
+ const valid = {
9
+ questions: [{ type: "select", question: "Pick", options: [{ value: "yes", label: "Yes" }] }],
10
+ };
11
+
12
+ test("QuestionParamsSchema accepts typed batch requests", () => {
13
+ assert.equal(Value.Check(QuestionParamsSchema, valid), true);
14
+ });
15
+
16
+ test("QuestionParamsSchema rejects legacy and mistyped fields", () => {
17
+ assert.equal(Value.Check(QuestionParamsSchema, { ...valid, timeout: 1000 }), false);
18
+ assert.equal(Value.Check(QuestionParamsSchema, {
19
+ questions: [{ ...valid.questions[0], allowMultiple: true }],
20
+ }), false);
21
+ assert.equal(Value.Check(QuestionParamsSchema, {
22
+ questions: [{ ...valid.questions[0], allowComment: true }],
23
+ }), false);
24
+ assert.equal(Value.Check(QuestionParamsSchema, {
25
+ questions: [{ type: "text", question: "Explain", options: [] }],
26
+ }), false);
27
+ assert.equal(Value.Check(QuestionParamsSchema, {
28
+ questions: [{ ...valid.questions[0], options: [{ value: "yes", label: "Yes", extra: true }] }],
29
+ }), false);
30
+ });