@selesai/code 0.1.19 → 0.2.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.
Files changed (157) hide show
  1. package/dist/agents/architect.md +4 -3
  2. package/dist/agents/builder.md +5 -4
  3. package/dist/agents/commentator.md +3 -2
  4. package/dist/agents/explorer.md +4 -4
  5. package/dist/agents/recapper.md +4 -3
  6. package/dist/config.d.ts +6 -7
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +7 -9
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +3 -1
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/compaction/compaction.d.ts.map +1 -1
  14. package/dist/core/compaction/compaction.js +18 -24
  15. package/dist/core/compaction/compaction.js.map +1 -1
  16. package/dist/core/extensions/loader.d.ts.map +1 -1
  17. package/dist/core/extensions/loader.js +2 -0
  18. package/dist/core/extensions/loader.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +8 -1
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js.map +1 -1
  22. package/dist/core/skills.d.ts.map +1 -1
  23. package/dist/core/skills.js +25 -11
  24. package/dist/core/skills.js.map +1 -1
  25. package/dist/defaults/models.json +143 -39
  26. package/dist/defaults/settings.json +2 -29
  27. package/dist/extensions/package.json +5 -4
  28. package/dist/extensions/pi-intercom/index.ts +12 -5
  29. package/dist/extensions/pi-powerline-footer/index.ts +4 -1
  30. package/dist/extensions/pi-powerline-footer/tps.ts +286 -0
  31. package/dist/extensions/pi-powerline-footer/welcome.ts +47 -46
  32. package/dist/extensions/pi-subagents/README.md +12 -5
  33. package/dist/extensions/pi-subagents/agents/context-builder.md +2 -2
  34. package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
  35. package/dist/extensions/pi-subagents/package.json +8 -5
  36. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
  37. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
  38. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +1 -1
  39. package/dist/extensions/pi-subagents/src/agents/agents.ts +3 -3
  40. package/dist/extensions/pi-subagents/src/extension/control-notices.ts +1 -1
  41. package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
  42. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +1 -1
  43. package/dist/extensions/pi-subagents/src/extension/index.ts +29 -2
  44. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +2 -2
  45. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +72 -13
  46. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +1 -1
  47. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +1 -1
  48. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +85 -41
  49. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +1 -1
  50. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +1 -1
  51. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +46 -3
  52. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +2 -2
  53. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +1 -3
  54. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +2 -2
  55. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +18 -18
  56. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +24 -24
  57. package/dist/extensions/pi-subagents/src/runs/shared/spawn-timing.ts +46 -0
  58. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +2 -2
  59. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -4
  60. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +1 -1
  61. package/dist/extensions/pi-subagents/src/shared/types.ts +6 -6
  62. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -3
  63. package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -1
  64. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +1 -1
  65. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +1 -1
  66. package/dist/extensions/pi-subagents/src/tui/render.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/spawn-timing.test.ts +45 -0
  68. package/dist/extensions/pi-subagents/tsconfig.runner.json +17 -0
  69. package/dist/extensions/pi-web-agent/package.json +31 -0
  70. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -0
  71. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  72. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -0
  73. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -0
  74. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -0
  75. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +133 -0
  76. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -0
  77. package/dist/extensions/pi-web-agent/src/extension.ts +126 -0
  78. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -0
  79. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -0
  80. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -0
  81. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -0
  82. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -0
  83. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -0
  84. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -0
  85. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -0
  86. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -0
  87. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -0
  88. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -0
  89. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -0
  90. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -0
  91. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -0
  92. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -0
  93. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -0
  94. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -0
  95. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +208 -0
  96. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -0
  97. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -0
  98. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -0
  99. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -0
  100. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -0
  101. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -0
  102. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -0
  103. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -0
  104. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -0
  105. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -0
  106. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -0
  107. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -0
  108. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -0
  109. package/dist/extensions/pi-web-agent/src/types.ts +88 -0
  110. package/dist/extensions/ponytail/index.js +189 -0
  111. package/dist/extensions/ponytail/package.json +8 -0
  112. package/dist/extensions/ponytail/ponytail-config.cjs +122 -0
  113. package/dist/extensions/ponytail/ponytail-instructions.cjs +94 -0
  114. package/dist/extensions/ponytail/test/extension.test.js +167 -0
  115. package/dist/extensions/ponytail/test/helpers.test.js +92 -0
  116. package/dist/extensions/question/constants.ts +38 -0
  117. package/dist/extensions/question/dialog-adapter.ts +74 -0
  118. package/dist/extensions/question/helpers.ts +102 -0
  119. package/dist/extensions/question/index.ts +671 -0
  120. package/dist/extensions/question/navigation.ts +14 -0
  121. package/dist/extensions/question/package.json +18 -0
  122. package/dist/extensions/question/question-list.ts +498 -0
  123. package/dist/extensions/question/row-layout.ts +162 -0
  124. package/dist/extensions/question/schemas.ts +69 -0
  125. package/dist/extensions/question/selection-mode.ts +49 -0
  126. package/dist/extensions/question/shortcuts.ts +53 -0
  127. package/dist/extensions/question/tests/helpers.test.ts +178 -0
  128. package/dist/extensions/question/tests/question-list.test.ts +230 -0
  129. package/dist/extensions/question/tests/row-layout.test.ts +136 -0
  130. package/dist/extensions/question/tests/shortcuts.test.ts +85 -0
  131. package/dist/extensions/question/tests/ui-protocol.test.ts +143 -0
  132. package/dist/extensions/question/tui-adapter.ts +33 -0
  133. package/dist/extensions/question/types.ts +102 -0
  134. package/dist/extensions/question/ui-protocol.ts +46 -0
  135. package/dist/extensions/tokenin-onboarding.ts +265 -2
  136. package/dist/extensions/web-agent-onboarding.ts +222 -0
  137. package/dist/extensions/workflow/adapter.ts +588 -0
  138. package/dist/extensions/workflow/extension.ts +24 -0
  139. package/dist/extensions/workflow/modes/prototype.ts +156 -0
  140. package/dist/extensions/workflow/modes/quick.ts +138 -0
  141. package/dist/extensions/workflow/package.json +17 -0
  142. package/dist/extensions/workflow/state-machine.ts +386 -0
  143. package/dist/index.d.ts +1 -1
  144. package/dist/index.d.ts.map +1 -1
  145. package/dist/index.js +1 -1
  146. package/dist/index.js.map +1 -1
  147. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  148. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  149. package/dist/modes/interactive/interactive-mode.js +57 -13
  150. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  151. package/dist/utils/mime.d.ts.map +1 -1
  152. package/dist/utils/mime.js +24 -1
  153. package/dist/utils/mime.js.map +1 -1
  154. package/docs/docs.json +4 -0
  155. package/docs/workflows.md +206 -0
  156. package/package.json +5 -1
  157. package/README.md +0 -198
@@ -0,0 +1,69 @@
1
+ // Typebox schemas for the question tool parameters.
2
+
3
+ import { Type, type TUnsafe } from "typebox";
4
+
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
+ ]);
12
+
13
+ /** Flat { type: "string", enum: [...] } instead of anyOf — Google function-calling rejects unions. */
14
+ export function StringEnum<const T extends readonly string[]>(
15
+ values: T,
16
+ options?: { description?: string; default?: T[number] },
17
+ ): TUnsafe<T[number]> {
18
+ return Type.Unsafe<T[number]>({
19
+ type: "string",
20
+ enum: [...values],
21
+ ...(options?.description ? { description: options.description } : {}),
22
+ ...(options?.default !== undefined ? { default: options.default } : {}),
23
+ });
24
+ }
25
+
26
+ export const QuestionParamsSchema = Type.Object({
27
+ question: Type.String({ description: "Question to ask the user before continuing" }),
28
+ title: Type.Optional(Type.String({ description: "Short title for the question UI (deprecated alias of context summary)" })),
29
+ context: Type.Optional(
30
+ Type.String({ description: "Relevant context summary shown before the question" }),
31
+ ),
32
+ options: Type.Optional(
33
+ Type.Array(OptionSchema, {
34
+ description: "Optional multiple-choice options. Each option may be a string or { label, description }.",
35
+ }),
36
+ ),
37
+ allowMultiple: Type.Optional(
38
+ Type.Boolean({ description: "Allow selecting multiple options. Default: false" }),
39
+ ),
40
+ allowFreeform: Type.Optional(
41
+ Type.Boolean({
42
+ description: "Allow a custom freeform answer. Defaults to true when no options are provided, false when options exist.",
43
+ }),
44
+ ),
45
+ allowComment: Type.Optional(
46
+ Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
47
+ ),
48
+ displayMode: Type.Optional(
49
+ StringEnum(["overlay", "inline"] as const, {
50
+ description:
51
+ "UI rendering mode. 'overlay' shows a centered modal, 'inline' renders in-place. Default: PI_QUESTION_DISPLAY_MODE env var if set, otherwise 'overlay'.",
52
+ }),
53
+ ),
54
+ overlayToggleKey: Type.Optional(
55
+ Type.String({
56
+ description:
57
+ "Shortcut for hiding/showing the overlay popup (overlay mode only), e.g. 'alt+o'. Pass 'off' to disable. Default: PI_QUESTION_OVERLAY_TOGGLE_KEY env var if set, otherwise 'alt+o'.",
58
+ }),
59
+ ),
60
+ commentToggleKey: Type.Optional(
61
+ Type.String({
62
+ description:
63
+ "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'.",
64
+ }),
65
+ ),
66
+ timeout: Type.Optional(
67
+ Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
68
+ ),
69
+ });
@@ -0,0 +1,49 @@
1
+ // SelectionMode strategy — isolates single-vs-multi selection behavior.
2
+
3
+ import type { QuestionOption } from "./types.ts";
4
+
5
+ export interface QuestionListState {
6
+ selectedIndex: number;
7
+ checked: ReadonlySet<number>;
8
+ options: QuestionOption[];
9
+ }
10
+
11
+ export interface SelectionMode {
12
+ readonly multi: boolean;
13
+ toggle(checked: Set<number>, index: number): void;
14
+ buildResult(state: QuestionListState): string[];
15
+ /** Returns whether to toggle (multi) or just select (single), and the new selectedIndex. */
16
+ handleNumberKey(idx: number, count: number): { toggle: boolean; selectIndex: number };
17
+ }
18
+
19
+ export const SingleSelect: SelectionMode = {
20
+ multi: false,
21
+ toggle() {},
22
+ buildResult(state) {
23
+ const label = state.options[state.selectedIndex]?.label;
24
+ return label ? [label] : [];
25
+ },
26
+ handleNumberKey(idx, count) {
27
+ return { toggle: false, selectIndex: Math.min(idx, count - 1) };
28
+ },
29
+ };
30
+
31
+ export const MultiSelect: SelectionMode = {
32
+ multi: true,
33
+ toggle(checked, index) {
34
+ if (index < 0) return;
35
+ if (checked.has(index)) checked.delete(index);
36
+ else checked.add(index);
37
+ },
38
+ buildResult(state) {
39
+ const selectedTitles = Array.from(state.checked)
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] : [];
45
+ },
46
+ handleNumberKey(idx, count) {
47
+ return { toggle: true, selectIndex: Math.min(idx, count - 1) };
48
+ },
49
+ };
@@ -0,0 +1,53 @@
1
+ // Shortcut resolution for overlay-toggle and comment-toggle keys.
2
+
3
+ import { matchesKey } from "@earendil-works/pi-tui";
4
+
5
+ import { DISABLED_SHORTCUT, SHORTCUT_DISABLE_VALUES } from "./constants.ts";
6
+ import type { ResolvedShortcut, ResolvedShortcuts } from "./types.ts";
7
+ import { DEFAULT_COMMENT_TOGGLE_KEY, DEFAULT_OVERLAY_TOGGLE_KEY } from "./constants.ts";
8
+
9
+ export function normalizeShortcutSpec(value: string | null | undefined): string | null | undefined {
10
+ if (value === undefined) return undefined;
11
+ if (value === null) return null;
12
+ const trimmed = value.trim().toLowerCase();
13
+ if (SHORTCUT_DISABLE_VALUES.has(trimmed)) return null;
14
+ return trimmed;
15
+ }
16
+
17
+ export function isValidShortcutSpec(spec: string): boolean {
18
+ if (!spec) return false;
19
+ if (!/^[a-z0-9+_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]+$/i.test(spec)) return false;
20
+ if (spec.startsWith("+") || spec.endsWith("+")) return false;
21
+ if (spec.includes("++")) return false;
22
+ return true;
23
+ }
24
+
25
+ export function buildShortcut(spec: string): ResolvedShortcut {
26
+ return { disabled: false, spec, matches: (data: string) => matchesKey(data, spec as any) };
27
+ }
28
+
29
+ export function resolveShortcut(
30
+ paramValue: string | null | undefined,
31
+ envValue: string | undefined,
32
+ defaultSpec: string,
33
+ ): ResolvedShortcut {
34
+ for (const raw of [paramValue, envValue, defaultSpec]) {
35
+ const normalized = normalizeShortcutSpec(raw);
36
+ if (normalized === undefined) continue;
37
+ if (normalized === null) return DISABLED_SHORTCUT;
38
+ if (isValidShortcutSpec(normalized)) return buildShortcut(normalized);
39
+ }
40
+ return DISABLED_SHORTCUT;
41
+ }
42
+
43
+ export function resolveShortcuts(
44
+ overlayToggleKey: string | null | undefined,
45
+ overlayEnv: string | undefined,
46
+ commentToggleKey: string | null | undefined,
47
+ commentEnv: string | undefined,
48
+ ): ResolvedShortcuts {
49
+ return {
50
+ overlayToggle: resolveShortcut(overlayToggleKey, overlayEnv, DEFAULT_OVERLAY_TOGGLE_KEY),
51
+ commentToggle: resolveShortcut(commentToggleKey, commentEnv, DEFAULT_COMMENT_TOGGLE_KEY),
52
+ };
53
+ }
@@ -0,0 +1,178 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ normalizeOptions,
6
+ getOptionsFormatError,
7
+ createFreeformResponse,
8
+ createSelectionResponse,
9
+ formatResponseSummary,
10
+ formatOptionsForMessage,
11
+ parseDialogSelections,
12
+ isCancelled,
13
+ oneLine,
14
+ } from "../helpers.ts";
15
+
16
+ test("normalizeOptions: string array", () => {
17
+ assert.deepEqual(normalizeOptions(["a", "b"]), [
18
+ { label: "a" },
19
+ { label: "b" },
20
+ ]);
21
+ });
22
+
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"), {
81
+ kind: "selection",
82
+ selections: ["a"],
83
+ comment: "note",
84
+ });
85
+ });
86
+
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");
101
+ });
102
+
103
+ test("formatResponseSummary: selection without comment", () => {
104
+ assert.equal(formatResponseSummary({ kind: "selection", selections: ["a", "b"] }), "a, b");
105
+ });
106
+
107
+ test("formatResponseSummary: selection with comment", () => {
108
+ assert.equal(
109
+ formatResponseSummary({ kind: "selection", selections: ["a", "b"], comment: "note" }),
110
+ "a, b — note",
111
+ );
112
+ });
113
+
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", () => {
177
+ assert.equal(oneLine(" hello world "), "hello world");
178
+ });
@@ -0,0 +1,230 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
5
+
6
+ import { QuestionList } from "../question-list.ts";
7
+ import { MultiSelect, SingleSelect } from "../selection-mode.ts";
8
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
9
+ import type { ResolvedShortcut } from "../types.ts";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Fakes
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const fakeTheme: Theme = {
16
+ fg: (_name: string, s: string) => s,
17
+ bold: (s: string) => s,
18
+ } as unknown as Theme;
19
+
20
+ function fakeKeybindings(actions: string[] = []): KeybindingsManager {
21
+ return {
22
+ matches: (_data: string, action: string) => actions.includes(action),
23
+ getKeys: (_action: string) => [],
24
+ } as unknown as KeybindingsManager;
25
+ }
26
+
27
+ const enabledShortcut: ResolvedShortcut = {
28
+ disabled: false,
29
+ spec: "ctrl+g",
30
+ matches: (data: string) => matchesKey(data, "ctrl+g"),
31
+ };
32
+
33
+ const disabledShortcut: ResolvedShortcut = {
34
+ disabled: true,
35
+ spec: null,
36
+ matches: ((_data: string) => false) as (data: string) => false,
37
+ };
38
+
39
+ const opts = (labels: string[]) => labels.map((label) => ({ label }));
40
+
41
+ // Terminal data bytes
42
+ const ESC = "\x1b";
43
+ const ENTER = "\r";
44
+ const SPACE = " ";
45
+ const CTRL_J = "\x0a";
46
+ const CTRL_K = "\x0b";
47
+ const UP_ARROW = "\x1b[A";
48
+ const DOWN_ARROW = "\x1b[B";
49
+ const BACKSPACE = "\x7f";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Single-select tests
53
+ // ---------------------------------------------------------------------------
54
+
55
+ test("SingleSelect: navigate down wraps at end", () => {
56
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
57
+ list.handleInput(CTRL_J); // ctrl+j = vim down
58
+ assert.equal((list as any).selectedIndex, 1);
59
+ list.handleInput(CTRL_J);
60
+ assert.equal((list as any).selectedIndex, 2);
61
+ list.handleInput(CTRL_J);
62
+ assert.equal((list as any).selectedIndex, 0); // wraps
63
+ });
64
+
65
+ test("SingleSelect: navigate up wraps at 0", () => {
66
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
67
+ list.handleInput(CTRL_K); // ctrl+k = vim up
68
+ assert.equal((list as any).selectedIndex, 2); // wraps to last
69
+ });
70
+
71
+ test("SingleSelect: confirm on option → onSubmit with [label]", () => {
72
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
73
+ let submitted: [string[], boolean] | null = null;
74
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
75
+ list.handleInput(ENTER);
76
+ assert.deepEqual(submitted, [["a"], false]);
77
+ });
78
+
79
+ test("SingleSelect: confirm on freeform row → onEnterFreeform", () => {
80
+ const list = new QuestionList(opts(["a"]), SingleSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
81
+ let called = false;
82
+ list.onEnterFreeform = () => (called = true);
83
+ // Navigate to freeform row (index 1, since 1 option + 0 comment = freeform at index 1)
84
+ list.handleInput(CTRL_J);
85
+ list.handleInput(ENTER);
86
+ assert.equal(called, true);
87
+ });
88
+
89
+ test("SingleSelect: confirm on comment-toggle → toggles comment, no submit", () => {
90
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
91
+ let submitted = false;
92
+ list.onSubmit = () => (submitted = true);
93
+ // Navigate to comment-toggle row (index 1)
94
+ list.handleInput(CTRL_J);
95
+ list.handleInput(ENTER);
96
+ assert.equal(list.isCommentEnabled(), true);
97
+ assert.equal(submitted, false);
98
+ });
99
+
100
+ test("SingleSelect: number key selects (no toggle)", () => {
101
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
102
+ list.handleInput("3");
103
+ assert.equal((list as any).selectedIndex, 2);
104
+ // No toggle in single-select
105
+ assert.equal((list as any).checked.size, 0);
106
+ });
107
+
108
+ test("SingleSelect: escape with search query → clears search", () => {
109
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
110
+ list.handleInput("a"); // type into search
111
+ assert.equal((list as any).searchQuery, "a");
112
+ list.handleInput(ESC);
113
+ assert.equal((list as any).searchQuery, "");
114
+ });
115
+
116
+ test("SingleSelect: escape without search → not handled by list (cancel is via keybinding)", () => {
117
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.cancel"]), disabledShortcut);
118
+ let cancelled = false;
119
+ list.onCancel = () => (cancelled = true);
120
+ // In single-select, escape without search query is not handled by the list at all.
121
+ // The keybinding check for cancel comes AFTER the escape-clears-search check.
122
+ // Since searchQuery is empty, escape falls through to cancel keybinding check.
123
+ // With fakeKeybindings matching "tui.select.cancel", it WILL cancel.
124
+ list.handleInput(ESC);
125
+ assert.equal(cancelled, true);
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Multi-select tests
130
+ // ---------------------------------------------------------------------------
131
+
132
+ test("MultiSelect: space on option → toggles checked", () => {
133
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
134
+ list.handleInput(SPACE);
135
+ assert.equal((list as any).checked.has(0), true);
136
+ list.handleInput(SPACE);
137
+ assert.equal((list as any).checked.has(0), false);
138
+ });
139
+
140
+ test("MultiSelect: number key toggles + selects", () => {
141
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
142
+ list.handleInput("2");
143
+ assert.equal((list as any).checked.has(1), true);
144
+ assert.equal((list as any).selectedIndex, 1);
145
+ });
146
+
147
+ test("MultiSelect: confirm with checked items → onSubmit with sorted labels", () => {
148
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
149
+ let submitted: [string[], boolean] | null = null;
150
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
151
+ // Check items 2 and 1 (out of order)
152
+ list.handleInput("2");
153
+ list.handleInput("1");
154
+ list.handleInput(ENTER);
155
+ assert.deepEqual(submitted, [["a", "b"], false]); // sorted by index
156
+ });
157
+
158
+ test("MultiSelect: confirm with nothing checked → fallback to highlighted label", () => {
159
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
160
+ let submitted: [string[], boolean] | null = null;
161
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
162
+ list.handleInput(ENTER);
163
+ assert.deepEqual(submitted, [["a"], false]); // highlighted fallback
164
+ });
165
+
166
+ test("MultiSelect: confirm with no options → onCancel", () => {
167
+ const list = new QuestionList([], MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
168
+ let cancelled = false;
169
+ list.onCancel = () => (cancelled = true);
170
+ list.handleInput(ENTER);
171
+ assert.equal(cancelled, true);
172
+ });
173
+
174
+ test("MultiSelect: confirm on comment-toggle → toggles comment", () => {
175
+ const list = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
176
+ let submitted = false;
177
+ list.onSubmit = () => (submitted = true);
178
+ // Navigate to comment-toggle (index 1)
179
+ list.handleInput(CTRL_J);
180
+ list.handleInput(ENTER);
181
+ assert.equal(list.isCommentEnabled(), true);
182
+ assert.equal(submitted, false);
183
+ });
184
+
185
+ test("MultiSelect: confirm on freeform → onEnterFreeform", () => {
186
+ const list = new QuestionList(opts(["a"]), MultiSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
187
+ let called = false;
188
+ list.onEnterFreeform = () => (called = true);
189
+ // Navigate to freeform row (index 1)
190
+ list.handleInput(CTRL_J);
191
+ list.handleInput(ENTER);
192
+ assert.equal(called, true);
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Comment toggle tests (both modes)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ test("Comment toggle via shortcut: toggles commentEnabled", () => {
200
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), enabledShortcut);
201
+ assert.equal(list.isCommentEnabled(), false);
202
+ list.handleInput(CTRL_K); // ctrl+k = our comment toggle key... wait, spec is "ctrl+g"
203
+ // Actually the shortcut spec is "ctrl+g", so we need to send ctrl+g data
204
+ const CTRL_G = "\x07";
205
+ list.handleInput(CTRL_G);
206
+ assert.equal(list.isCommentEnabled(), true);
207
+ list.handleInput(CTRL_G);
208
+ assert.equal(list.isCommentEnabled(), false);
209
+ });
210
+
211
+ test("Comment toggle disabled: does nothing", () => {
212
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
213
+ const CTRL_G = "\x07";
214
+ list.handleInput(CTRL_G);
215
+ assert.equal(list.isCommentEnabled(), false);
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // onSubmit includes commentEnabled flag
220
+ // ---------------------------------------------------------------------------
221
+
222
+ test("onSubmit passes commentEnabled=true when comment is enabled", () => {
223
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), enabledShortcut);
224
+ let submitted: [string[], boolean] | null = null;
225
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
226
+ const CTRL_G = "\x07";
227
+ list.handleInput(CTRL_G); // enable comment
228
+ list.handleInput(ENTER); // confirm
229
+ assert.deepEqual(submitted, [["a"], true]);
230
+ });