@selesai/code 0.8.4 → 0.8.6

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 (147) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +46 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/agent-browser.test.ts +178 -2
  7. package/dist/extensions/agent-browser.ts +2 -0
  8. package/dist/extensions/copy-turn.test.ts +229 -0
  9. package/dist/extensions/copy-turn.ts +11 -0
  10. package/dist/extensions/grep-app/index.test.ts +356 -1
  11. package/dist/extensions/grep-app/index.ts +6 -1
  12. package/dist/extensions/handoff-new.test.ts +351 -161
  13. package/dist/extensions/handoff-new.ts +3 -0
  14. package/dist/extensions/inline-skills.test.ts +93 -0
  15. package/dist/extensions/inline-skills.ts +3 -0
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  17. package/dist/extensions/pi-subagents/README.md +2 -0
  18. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  19. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  20. package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
  21. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  22. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  23. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  27. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  28. package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
  29. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  30. package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
  31. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  32. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  33. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  35. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  36. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  37. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  40. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  42. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  43. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
  44. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  45. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  46. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  48. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  49. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  50. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  51. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  53. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  54. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  55. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  56. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  57. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  58. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  59. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  60. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
  61. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  62. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  63. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  64. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  65. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  66. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  67. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  68. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  69. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  70. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  71. package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
  72. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  73. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  74. package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
  75. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  76. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  77. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  78. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  79. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  80. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  81. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  82. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  83. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  84. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  85. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  86. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  87. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  88. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  89. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  90. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  91. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  92. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  93. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  94. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  95. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  96. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  97. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  98. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  99. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  100. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  101. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  102. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  103. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  104. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  105. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  106. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  107. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  108. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  109. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  110. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  111. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  112. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  113. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  114. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  115. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  116. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  117. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  118. package/dist/extensions/ponytail/index.js +11 -0
  119. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  120. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  121. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  122. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  123. package/dist/extensions/question/index.ts +18 -0
  124. package/dist/extensions/question/question-list.ts +22 -0
  125. package/dist/extensions/question/tests/batch.test.ts +103 -65
  126. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  127. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  128. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  129. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  130. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  131. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  132. package/dist/extensions/rtk.test.ts +180 -1
  133. package/dist/extensions/tokenin-onboarding.ts +3 -0
  134. package/dist/extensions/undo.test.ts +720 -0
  135. package/dist/extensions/undo.ts +6 -0
  136. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  137. package/dist/extensions/workflow/extension.ts +3 -3
  138. package/dist/extensions/workflow/modes.ts +41 -19
  139. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  140. package/dist/modes/interactive/interactive-mode.js +48 -1
  141. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  142. package/docs/plans/workflow-autoloop-reference.md +1 -2
  143. package/docs/plans/workflow-handoff-carryover.md +108 -0
  144. package/docs/quickstart.md +27 -43
  145. package/docs/settings.md +4 -0
  146. package/docs/usage.md +1 -0
  147. package/package.json +5 -1
@@ -1,7 +1,6 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
1
+ import { describe, expect, it } from "vitest";
3
2
 
4
- import { Key, matchesKey } from "@earendil-works/pi-tui";
3
+ import { matchesKey } from "@earendil-works/pi-tui";
5
4
 
6
5
  import { QuestionList } from "../question-list.ts";
7
6
  import { MultiSelect, SingleSelect } from "../selection-mode.ts";
@@ -44,209 +43,512 @@ const ENTER = "\r";
44
43
  const SPACE = " ";
45
44
  const CTRL_J = "\x0a";
46
45
  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: no options with freeform → opens custom response", () => {
90
- const list = new QuestionList([], SingleSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
91
- let called = false;
92
- list.onEnterFreeform = () => (called = true);
93
- list.handleInput(ENTER);
94
- assert.equal(called, true);
95
- });
96
-
97
- test("SingleSelect: confirm on comment-toggle → toggles comment, no submit", () => {
98
- const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
99
- let submitted = false;
100
- list.onSubmit = () => (submitted = true);
101
- // Navigate to comment-toggle row (index 1)
102
- list.handleInput(CTRL_J);
103
- list.handleInput(ENTER);
104
- assert.equal(list.isCommentEnabled(), true);
105
- assert.equal(submitted, false);
106
- });
107
-
108
- test("SingleSelect: number key selects (no toggle)", () => {
109
- const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
110
- list.handleInput("3");
111
- assert.equal((list as any).selectedIndex, 2);
112
- // No toggle in single-select
113
- assert.equal((list as any).checked.size, 0);
114
- });
115
-
116
- test("SingleSelect: escape with search query → clears search", () => {
117
- const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
118
- list.handleInput("a"); // type into search
119
- assert.equal((list as any).searchQuery, "a");
120
- list.handleInput(ESC);
121
- assert.equal((list as any).searchQuery, "");
122
- });
123
-
124
- test("SingleSelect: escape without search → not handled by list (cancel is via keybinding)", () => {
125
- const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.cancel"]), disabledShortcut);
126
- let cancelled = false;
127
- list.onCancel = () => (cancelled = true);
128
- // In single-select, escape without search query is not handled by the list at all.
129
- // The keybinding check for cancel comes AFTER the escape-clears-search check.
130
- // Since searchQuery is empty, escape falls through to cancel keybinding check.
131
- // With fakeKeybindings matching "tui.select.cancel", it WILL cancel.
132
- list.handleInput(ESC);
133
- assert.equal(cancelled, true);
134
- });
135
-
136
- // ---------------------------------------------------------------------------
137
- // Multi-select tests
138
- // ---------------------------------------------------------------------------
139
-
140
- test("MultiSelect: space on option → toggles checked", () => {
141
- const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
142
- list.handleInput(SPACE);
143
- assert.equal((list as any).checked.has(0), true);
144
- list.handleInput(SPACE);
145
- assert.equal((list as any).checked.has(0), false);
46
+ const CTRL_G = "\x07";
47
+
48
+ describe("QuestionList single-select", () => {
49
+ it("navigate down wraps at end", () => {
50
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
51
+ list.handleInput(CTRL_J); // ctrl+j = vim down
52
+ expect((list as any).selectedIndex).toBe(1);
53
+ list.handleInput(CTRL_J);
54
+ expect((list as any).selectedIndex).toBe(2);
55
+ list.handleInput(CTRL_J);
56
+ expect((list as any).selectedIndex).toBe(0); // wraps
57
+ });
58
+
59
+ it("navigate up wraps at 0", () => {
60
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
61
+ list.handleInput(CTRL_K); // ctrl+k = vim up
62
+ expect((list as any).selectedIndex).toBe(2); // wraps to last
63
+ });
64
+
65
+ it("confirm on option → onSubmit with [label]", () => {
66
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
67
+ let submitted: [string[], boolean] | null = null;
68
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
69
+ list.handleInput(ENTER);
70
+ expect(submitted).toEqual([["a"], false]);
71
+ });
72
+
73
+ it("confirm on freeform row → onEnterFreeform", () => {
74
+ const list = new QuestionList(opts(["a"]), SingleSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
75
+ let called = false;
76
+ list.onEnterFreeform = () => (called = true);
77
+ // Navigate to freeform row (index 1, since 1 option + 0 comment = freeform at index 1)
78
+ list.handleInput(CTRL_J);
79
+ list.handleInput(ENTER);
80
+ expect(called).toBe(true);
81
+ });
82
+
83
+ it("no options with freeform → opens custom response", () => {
84
+ const list = new QuestionList([], SingleSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
85
+ let called = false;
86
+ list.onEnterFreeform = () => (called = true);
87
+ list.handleInput(ENTER);
88
+ expect(called).toBe(true);
89
+ });
90
+
91
+ it("confirm on comment-toggle → toggles comment, no submit", () => {
92
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
93
+ let submitted = false;
94
+ list.onSubmit = () => (submitted = true);
95
+ // Navigate to comment-toggle row (index 1)
96
+ list.handleInput(CTRL_J);
97
+ list.handleInput(ENTER);
98
+ expect(list.isCommentEnabled()).toBe(true);
99
+ expect(submitted).toBe(false);
100
+ });
101
+
102
+ it("number key selects (no toggle)", () => {
103
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
104
+ list.handleInput("3");
105
+ expect((list as any).selectedIndex).toBe(2);
106
+ // No toggle in single-select
107
+ expect((list as any).checked.size).toBe(0);
108
+ });
109
+
110
+ it("escape with search query → clears search", () => {
111
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
112
+ list.handleInput("a"); // type into search
113
+ expect((list as any).searchQuery).toBe("a");
114
+ list.handleInput(ESC);
115
+ expect((list as any).searchQuery).toBe("");
116
+ });
117
+
118
+ it("escape without search → cancel via keybinding", () => {
119
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.cancel"]), disabledShortcut);
120
+ let cancelled = false;
121
+ list.onCancel = () => (cancelled = true);
122
+ list.handleInput(ESC);
123
+ expect(cancelled).toBe(true);
124
+ });
125
+
126
+ it("backspace pops search characters", () => {
127
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
128
+ list.handleInput("a");
129
+ list.handleInput("b");
130
+ expect((list as any).searchQuery).toBe("ab");
131
+ list.handleInput("\x7f");
132
+ expect((list as any).searchQuery).toBe("a");
133
+ list.handleInput("\x7f");
134
+ expect((list as any).searchQuery).toBe("");
135
+ });
136
+
137
+ it("control and C1 characters are filtered out of search input", () => {
138
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
139
+ // Control chars and C1 range are rejected (not added to the search query).
140
+ list.handleInput("\u0001"); // code 1 < 32
141
+ expect((list as any).searchQuery).toBe("");
142
+ list.handleInput("\u0085"); // C1 control (code 0x85 in 0x80..0x9f)
143
+ expect((list as any).searchQuery).toBe("");
144
+ // Multi-char paste is rejected too.
145
+ list.handleInput("ab");
146
+ expect((list as any).searchQuery).toBe("");
147
+ // Printable chars are still accepted.
148
+ list.handleInput("a");
149
+ expect((list as any).searchQuery).toBe("a");
150
+ });
151
+
152
+ it("filters options by search and shows no-match row", () => {
153
+ const list = new QuestionList(opts(["alpha", "beta"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
154
+ list.handleInput("z");
155
+ list.handleInput("z");
156
+ list.handleInput("z");
157
+ const lines = list.render(80);
158
+ expect(lines.some((l) => l.includes("No matching options"))).toBe(true);
159
+ });
160
+
161
+ it("renders split-pane preview at wide widths", () => {
162
+ const list = new QuestionList(
163
+ [{ value: "a", label: "Alpha", description: "The alpha option" }],
164
+ SingleSelect,
165
+ true,
166
+ true,
167
+ fakeTheme,
168
+ fakeKeybindings(),
169
+ disabledShortcut,
170
+ );
171
+ const lines = list.render(120);
172
+ expect(lines.some((l) => l.includes("│"))).toBe(true);
173
+ expect(lines.some((l) => l.includes("Alpha"))).toBe(true);
174
+ });
175
+
176
+ it("renders freeform and comment previews in split pane", () => {
177
+ const list = new QuestionList(opts(["a"]), SingleSelect, true, true, fakeTheme, fakeKeybindings(), disabledShortcut);
178
+ // Navigate to comment-toggle row (index 1)
179
+ list.handleInput(CTRL_J);
180
+ const commentLines = list.render(120);
181
+ expect(commentLines.some((l) => l.includes("Additional context"))).toBe(true);
182
+ // Navigate to freeform row (index 2)
183
+ list.handleInput(CTRL_J);
184
+ const freeformLines = list.render(120);
185
+ expect(freeformLines.some((l) => l.includes("Custom response"))).toBe(true);
186
+ });
187
+
188
+ it("setMaxVisibleRows clamps and invalidates", () => {
189
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
190
+ list.setMaxVisibleRows(2);
191
+ expect((list as any).maxVisibleRows).toBe(2);
192
+ list.setMaxVisibleRows(0);
193
+ expect((list as any).maxVisibleRows).toBe(1);
194
+ list.setMaxVisibleRows(2.7);
195
+ expect((list as any).maxVisibleRows).toBe(2);
196
+ });
197
+
198
+ it("getCheckedValues returns [] in single-select", () => {
199
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
200
+ expect(list.getCheckedValues()).toEqual([]);
201
+ });
146
202
  });
147
203
 
148
- test("MultiSelect: number key toggles + selects", () => {
149
- const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
150
- list.handleInput("2");
151
- assert.equal((list as any).checked.has(1), true);
152
- assert.equal((list as any).selectedIndex, 1);
204
+ describe("QuestionList multi-select", () => {
205
+ it("space on option → toggles checked", () => {
206
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
207
+ list.handleInput(SPACE);
208
+ expect((list as any).checked.has(0)).toBe(true);
209
+ list.handleInput(SPACE);
210
+ expect((list as any).checked.has(0)).toBe(false);
211
+ });
212
+
213
+ it("number key toggles + selects", () => {
214
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
215
+ list.handleInput("2");
216
+ expect((list as any).checked.has(1)).toBe(true);
217
+ expect((list as any).selectedIndex).toBe(1);
218
+ });
219
+
220
+ it("confirm with checked items → onSubmit with sorted labels", () => {
221
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
222
+ let submitted: [string[], boolean] | null = null;
223
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
224
+ // Check items 2 and 1 (out of order)
225
+ list.handleInput("2");
226
+ list.handleInput("1");
227
+ list.handleInput(ENTER);
228
+ expect(submitted).toEqual([["a", "b"], false]); // sorted by index
229
+ });
230
+
231
+ it("restores saved multi-select choices and comment state", () => {
232
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
233
+ list.restoreSelection(["a", "c"], "reason");
234
+ expect([...((list as any).checked)]).toEqual([0, 2]);
235
+ expect((list as any).selectedIndex).toBe(0);
236
+ expect(list.isCommentEnabled()).toBe(true);
237
+ });
238
+
239
+ it("restores saved single-select cursor", () => {
240
+ const list = new QuestionList(opts(["a", "b", "c"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
241
+ list.restoreSelection(["b"]);
242
+ expect((list as any).selectedIndex).toBe(1);
243
+ });
244
+
245
+ it("confirm with nothing checked → empty selection", () => {
246
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
247
+ let submitted: [string[], boolean] | null = null;
248
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
249
+ list.handleInput(ENTER);
250
+ expect(submitted).toEqual([[], false]);
251
+ });
252
+
253
+ it("MultiSelect: toggle with a negative index is a no-op", () => {
254
+ const checked = new Set<number>();
255
+ MultiSelect.toggle(checked, -1);
256
+ expect(checked.size).toBe(0);
257
+ });
258
+
259
+ it("MultiSelect: buildResult skips missing options", () => {
260
+ const result = MultiSelect.buildResult({
261
+ selectedIndex: 0,
262
+ checked: new Set([0, 5]),
263
+ options: [{ value: "a", label: "A" }],
264
+ });
265
+ expect(result).toEqual(["a"]);
266
+ });
267
+
268
+ it("SingleSelect: buildResult returns empty for a missing option", () => {
269
+ const result = SingleSelect.buildResult({
270
+ selectedIndex: 0,
271
+ checked: new Set(),
272
+ options: [],
273
+ });
274
+ expect(result).toEqual([]);
275
+ });
276
+
277
+ it("SingleSelect: handleNumberKey clamps the index", () => {
278
+ expect(SingleSelect.handleNumberKey(5, 3)).toEqual({ toggle: false, selectIndex: 2 });
279
+ });
280
+
281
+ it("MultiSelect: handleNumberKey clamps the index", () => {
282
+ expect(MultiSelect.handleNumberKey(5, 3)).toEqual({ toggle: true, selectIndex: 2 });
283
+ });
284
+
285
+ it("confirm with no options → onCancel", () => {
286
+ const list = new QuestionList([], MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
287
+ let cancelled = false;
288
+ list.onCancel = () => (cancelled = true);
289
+ list.handleInput(ENTER);
290
+ expect(cancelled).toBe(true);
291
+ });
292
+
293
+ it("confirm on comment-toggle → toggles comment", () => {
294
+ const list = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
295
+ let submitted = false;
296
+ list.onSubmit = () => (submitted = true);
297
+ // Navigate to comment-toggle (index 1)
298
+ list.handleInput(CTRL_J);
299
+ list.handleInput(ENTER);
300
+ expect(list.isCommentEnabled()).toBe(true);
301
+ expect(submitted).toBe(false);
302
+ });
303
+
304
+ it("confirm on freeform → onEnterFreeform", () => {
305
+ const list = new QuestionList(opts(["a"]), MultiSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
306
+ let called = false;
307
+ list.onEnterFreeform = () => (called = true);
308
+ // Navigate to freeform row (index 1)
309
+ list.handleInput(CTRL_J);
310
+ list.handleInput(ENTER);
311
+ expect(called).toBe(true);
312
+ });
313
+
314
+ it("space on freeform row → onEnterFreeform", () => {
315
+ const list = new QuestionList(opts(["a"]), MultiSelect, true, false, fakeTheme, fakeKeybindings(), disabledShortcut);
316
+ let called = false;
317
+ list.onEnterFreeform = () => (called = true);
318
+ list.handleInput(CTRL_J);
319
+ list.handleInput(SPACE);
320
+ expect(called).toBe(true);
321
+ });
322
+
323
+ it("renders multi rows with checkboxes and descriptions", () => {
324
+ const list = new QuestionList(
325
+ [{ value: "a", label: "Alpha", description: "desc" }],
326
+ MultiSelect,
327
+ true,
328
+ true,
329
+ fakeTheme,
330
+ fakeKeybindings(),
331
+ disabledShortcut,
332
+ );
333
+ const lines = list.render(80);
334
+ expect(lines.some((l) => l.includes("Alpha"))).toBe(true);
335
+ expect(lines.some((l) => l.includes("desc"))).toBe(true);
336
+ expect(lines.some((l) => l.includes("Type custom answer"))).toBe(true);
337
+ expect(lines.some((l) => l.includes("Add extra context"))).toBe(true);
338
+ });
339
+
340
+ it("renders no-options row", () => {
341
+ const list = new QuestionList([], MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
342
+ expect(list.render(80)).toEqual(["No options"]);
343
+ });
344
+
345
+ it("getCheckedValues returns checked values sorted", () => {
346
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
347
+ list.handleInput("3");
348
+ list.handleInput("1");
349
+ expect(list.getCheckedValues()).toEqual(["a", "c"]);
350
+ });
351
+
352
+ it("setMaxVisibleRows is a no-op in multi-select", () => {
353
+ const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
354
+ list.setMaxVisibleRows(2);
355
+ expect((list as any).maxVisibleRows).toBe(12);
356
+ });
153
357
  });
154
358
 
155
- test("MultiSelect: confirm with checked items → onSubmit with sorted labels", () => {
156
- const list = new QuestionList(opts(["a", "b", "c"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
157
- let submitted: [string[], boolean] | null = null;
158
- list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
159
- // Check items 2 and 1 (out of order)
160
- list.handleInput("2");
161
- list.handleInput("1");
162
- list.handleInput(ENTER);
163
- assert.deepEqual(submitted, [["a", "b"], false]); // sorted by index
164
- });
165
-
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", () => {
181
- const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
182
- let submitted: [string[], boolean] | null = null;
183
- list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
184
- list.handleInput(ENTER);
185
- assert.deepEqual(submitted, [[], false]);
186
- });
187
-
188
- test("MultiSelect: confirm with no options → onCancel", () => {
189
- const list = new QuestionList([], MultiSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
190
- let cancelled = false;
191
- list.onCancel = () => (cancelled = true);
192
- list.handleInput(ENTER);
193
- assert.equal(cancelled, true);
194
- });
195
-
196
- test("MultiSelect: confirm on comment-toggle → toggles comment", () => {
197
- const list = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
198
- let submitted = false;
199
- list.onSubmit = () => (submitted = true);
200
- // Navigate to comment-toggle (index 1)
201
- list.handleInput(CTRL_J);
202
- list.handleInput(ENTER);
203
- assert.equal(list.isCommentEnabled(), true);
204
- assert.equal(submitted, false);
205
- });
206
-
207
- test("MultiSelect: confirm on freeform → onEnterFreeform", () => {
208
- const list = new QuestionList(opts(["a"]), MultiSelect, true, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
209
- let called = false;
210
- list.onEnterFreeform = () => (called = true);
211
- // Navigate to freeform row (index 1)
212
- list.handleInput(CTRL_J);
213
- list.handleInput(ENTER);
214
- assert.equal(called, true);
215
- });
216
-
217
- // ---------------------------------------------------------------------------
218
- // Comment toggle tests (both modes)
219
- // ---------------------------------------------------------------------------
220
-
221
- test("Comment toggle via shortcut: toggles commentEnabled", () => {
222
- const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), enabledShortcut);
223
- assert.equal(list.isCommentEnabled(), false);
224
- list.handleInput(CTRL_K); // ctrl+k = our comment toggle key... wait, spec is "ctrl+g"
225
- // Actually the shortcut spec is "ctrl+g", so we need to send ctrl+g data
226
- const CTRL_G = "\x07";
227
- list.handleInput(CTRL_G);
228
- assert.equal(list.isCommentEnabled(), true);
229
- list.handleInput(CTRL_G);
230
- assert.equal(list.isCommentEnabled(), false);
231
- });
232
-
233
- test("Comment toggle disabled: does nothing", () => {
234
- const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
235
- const CTRL_G = "\x07";
236
- list.handleInput(CTRL_G);
237
- assert.equal(list.isCommentEnabled(), false);
238
- });
239
-
240
- // ---------------------------------------------------------------------------
241
- // onSubmit includes commentEnabled flag
242
- // ---------------------------------------------------------------------------
243
-
244
- test("onSubmit passes commentEnabled=true when comment is enabled", () => {
245
- const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), enabledShortcut);
246
- let submitted: [string[], boolean] | null = null;
247
- list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
248
- const CTRL_G = "\x07";
249
- list.handleInput(CTRL_G); // enable comment
250
- list.handleInput(ENTER); // confirm
251
- assert.deepEqual(submitted, [["a"], true]);
359
+ describe("QuestionList comment toggle", () => {
360
+ it("toggles commentEnabled via shortcut", () => {
361
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), enabledShortcut);
362
+ expect(list.isCommentEnabled()).toBe(false);
363
+ list.handleInput(CTRL_G);
364
+ expect(list.isCommentEnabled()).toBe(true);
365
+ list.handleInput(CTRL_G);
366
+ expect(list.isCommentEnabled()).toBe(false);
367
+ });
368
+
369
+ it("disabled shortcut does nothing", () => {
370
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
371
+ list.handleInput(CTRL_G);
372
+ expect(list.isCommentEnabled()).toBe(false);
373
+ });
374
+
375
+ it("space on comment-toggle row toggles in single-select", () => {
376
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
377
+ list.handleInput(CTRL_J); // to comment-toggle row
378
+ list.handleInput(SPACE);
379
+ expect(list.isCommentEnabled()).toBe(true);
380
+ });
381
+
382
+ it("onSubmit passes commentEnabled=true when comment is enabled", () => {
383
+ const list = new QuestionList(opts(["a", "b"]), SingleSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.confirm"]), enabledShortcut);
384
+ let submitted: [string[], boolean] | null = null;
385
+ list.onSubmit = (selections, commentEnabled) => (submitted = [selections, commentEnabled]);
386
+ list.handleInput(CTRL_G); // enable comment
387
+ list.handleInput(ENTER); // confirm
388
+ expect(submitted).toEqual([["a"], true]);
389
+ });
390
+
391
+ it("isMulti reflects the selection mode", () => {
392
+ const single = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
393
+ expect(single.isMulti).toBe(false);
394
+ const multi = new QuestionList(opts(["a"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
395
+ expect(multi.isMulti).toBe(true);
396
+ });
397
+
398
+ it("toggleComment is a no-op when comments are not allowed", () => {
399
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), enabledShortcut);
400
+ list.handleInput(CTRL_G);
401
+ expect(list.isCommentEnabled()).toBe(false);
402
+ });
403
+
404
+ it("popSearchChar is a no-op with an empty query", () => {
405
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
406
+ list.handleInput("\x7f");
407
+ expect((list as any).searchQuery).toBe("");
408
+ });
409
+
410
+ it("getPrintableInput rejects multi-char and control data", () => {
411
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
412
+ // Multi-char data → not printable.
413
+ list.handleInput("ab");
414
+ expect((list as any).searchQuery).toBe("");
415
+ // Control char → not printable.
416
+ list.handleInput("\x1b");
417
+ expect((list as any).searchQuery).toBe("");
418
+ // DEL → not printable.
419
+ list.handleInput("\x7f");
420
+ expect((list as any).searchQuery).toBe("");
421
+ });
422
+
423
+ it("getSplitPaneWidths returns null for narrow widths", () => {
424
+ const list = new QuestionList(opts(["a"]), SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
425
+ // Width below SPLIT_PANE_MIN_WIDTH (84) → no split.
426
+ const lines = list.render(80);
427
+ expect(lines.some((l) => l.includes("│"))).toBe(false);
428
+ });
429
+
430
+ it("styleListLine styles indicator, selected, description, and arrow lines", () => {
431
+ const list = new QuestionList(
432
+ [{ value: "a", label: "Alpha", description: "desc" }],
433
+ SingleSelect,
434
+ true,
435
+ true,
436
+ fakeTheme,
437
+ fakeKeybindings(),
438
+ disabledShortcut,
439
+ );
440
+ // Render with a search query that matches nothing → indicator + no-match.
441
+ list.handleInput("z");
442
+ const lines = list.render(80);
443
+ expect(lines.some((l) => l.includes("No matching options"))).toBe(true);
444
+ });
445
+
446
+ it("buildListLines shows No options when count is zero without search", () => {
447
+ const list = new QuestionList([], SingleSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
448
+ const lines = list.render(80);
449
+ expect(lines.some((l) => l.includes("No options"))).toBe(true);
450
+ });
451
+
452
+ it("buildPreviewLines handles maxLines zero and truncation", () => {
453
+ const list = new QuestionList(
454
+ [{ value: "a", label: "Alpha", description: "A very long description that will wrap and truncate in the preview pane" }],
455
+ SingleSelect,
456
+ false,
457
+ false,
458
+ fakeTheme,
459
+ fakeKeybindings(),
460
+ disabledShortcut,
461
+ );
462
+ // Wide width → split pane with preview.
463
+ const lines = list.render(120);
464
+ expect(lines.some((l) => l.includes("Alpha"))).toBe(true);
465
+ });
466
+
467
+ it("buildPreviewLines shows no-option-selected and filter text", () => {
468
+ const list = new QuestionList(
469
+ [{ value: "a", label: "Alpha" }],
470
+ SingleSelect,
471
+ false,
472
+ false,
473
+ fakeTheme,
474
+ fakeKeybindings(),
475
+ disabledShortcut,
476
+ );
477
+ // Search that filters out all options → no option selected in preview.
478
+ list.handleInput("z");
479
+ list.handleInput("z");
480
+ list.handleInput("z");
481
+ const lines = list.render(120);
482
+ expect(lines.some((l) => l.includes("No option selected"))).toBe(true);
483
+ });
484
+
485
+ it("single-select confirm with no options does nothing", () => {
486
+ const list = new QuestionList([], SingleSelect, false, false, fakeTheme, fakeKeybindings(["tui.select.confirm"]), disabledShortcut);
487
+ let cancelled = false;
488
+ list.onCancel = () => (cancelled = true);
489
+ list.handleInput(ENTER);
490
+ expect(cancelled).toBe(false);
491
+ });
492
+
493
+ it("multi-select cancel and comment toggle paths", () => {
494
+ const list = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(["tui.select.cancel"]), enabledShortcut);
495
+ let cancelled = false;
496
+ list.onCancel = () => (cancelled = true);
497
+ list.handleInput(ESC);
498
+ expect(cancelled).toBe(true);
499
+
500
+ // Comment toggle via shortcut in multi-select.
501
+ const list2 = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(), enabledShortcut);
502
+ list2.handleInput(CTRL_G);
503
+ expect(list2.isCommentEnabled()).toBe(true);
504
+ });
505
+
506
+ it("multi-select up/down wrap and space on comment toggle", () => {
507
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
508
+ // Up from 0 wraps to last.
509
+ list.handleInput(CTRL_K);
510
+ expect((list as any).selectedIndex).toBe(2); // comment toggle row
511
+ // Space on comment toggle row toggles.
512
+ list.handleInput(SPACE);
513
+ expect(list.isCommentEnabled()).toBe(true);
514
+ });
515
+
516
+ it("multi-select render caches lines", () => {
517
+ const list = new QuestionList(opts(["a", "b"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
518
+ const first = list.render(80);
519
+ const second = list.render(80);
520
+ expect(second).toEqual(first);
521
+ });
522
+
523
+ it("multi-select render skips missing options and shows scroll indicator", () => {
524
+ const list = new QuestionList(opts(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]), MultiSelect, false, false, fakeTheme, fakeKeybindings(), disabledShortcut);
525
+ // Navigate to the end to trigger the scroll indicator.
526
+ for (let i = 0; i < 20; i++) list.handleInput(CTRL_J);
527
+ const lines = list.render(80);
528
+ expect(lines.some((l) => l.includes("("))).toBe(true);
529
+ });
530
+
531
+ it("multi-select render shows freeform and comment rows", () => {
532
+ const list = new QuestionList(opts(["a"]), MultiSelect, true, true, fakeTheme, fakeKeybindings(), disabledShortcut);
533
+ const lines = list.render(80);
534
+ expect(lines.some((l) => l.includes("Type custom answer"))).toBe(true);
535
+ expect(lines.some((l) => l.includes("Add extra context"))).toBe(true);
536
+ });
537
+
538
+ it("multi-select space on freeform row opens the editor", () => {
539
+ const list = new QuestionList(opts(["a"]), MultiSelect, true, false, fakeTheme, fakeKeybindings(), disabledShortcut);
540
+ let called = false;
541
+ list.onEnterFreeform = () => (called = true);
542
+ list.handleInput(CTRL_J); // to freeform row
543
+ list.handleInput(SPACE);
544
+ expect(called).toBe(true);
545
+ });
546
+
547
+ it("multi-select number key on comment toggle row toggles", () => {
548
+ const list = new QuestionList(opts(["a"]), MultiSelect, false, true, fakeTheme, fakeKeybindings(), disabledShortcut);
549
+ // Navigate to the comment toggle row (index 1) and press space.
550
+ list.handleInput(CTRL_J);
551
+ list.handleInput(SPACE);
552
+ expect(list.isCommentEnabled()).toBe(true);
553
+ });
252
554
  });