@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,18 @@
1
+ {
2
+ "name": "@selesai/question",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "description": "Interactive question tool extension for the agent.",
6
+ "type": "module",
7
+ "pi": {
8
+ "extensions": ["./index.ts"]
9
+ },
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-coding-agent": "*",
12
+ "@earendil-works/pi-tui": "*",
13
+ "typebox": "*"
14
+ },
15
+ "scripts": {
16
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts"
17
+ }
18
+ }
@@ -0,0 +1,498 @@
1
+ // Unified QuestionList — merges MultiSelectList + WrappedSingleSelectList.
2
+ // Single-vs-multi is a SelectionMode flag, not a separate class.
3
+ // Fuzzy search + split-pane preview are single-select-only (matching original behavior).
4
+
5
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Component,
8
+ fuzzyFilter,
9
+ Key,
10
+ Markdown,
11
+ matchesKey,
12
+ truncateToWidth,
13
+ wrapTextWithAnsi,
14
+ } from "@earendil-works/pi-tui";
15
+
16
+ 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
+ import { matchesDown, matchesUp } from "./navigation.ts";
19
+ import { renderSingleSelectRows } from "./row-layout.ts";
20
+ import type { QuestionOption, ResolvedShortcut } from "./types.ts";
21
+ import type { SelectionMode } from "./selection-mode.ts";
22
+
23
+ export class QuestionList implements Component {
24
+ private selectedIndex = 0;
25
+ private checked = new Set<number>();
26
+ private commentEnabled = false;
27
+ private searchQuery = "";
28
+ private maxVisibleRows = 12;
29
+ private cachedWidth?: number;
30
+ private cachedLines?: string[];
31
+
32
+ onCancel?: () => void;
33
+ onSubmit?: (selections: string[], commentEnabled: boolean) => void;
34
+ onEnterFreeform?: () => void;
35
+
36
+ private options: QuestionOption[];
37
+ private selectionMode: SelectionMode;
38
+ private allowFreeform: boolean;
39
+ private allowComment: boolean;
40
+ private theme: Theme;
41
+ private keybindings: KeybindingsManager;
42
+ private commentToggle: ResolvedShortcut;
43
+
44
+ constructor(
45
+ options: QuestionOption[],
46
+ selectionMode: SelectionMode,
47
+ allowFreeform: boolean,
48
+ allowComment: boolean,
49
+ theme: Theme,
50
+ keybindings: KeybindingsManager,
51
+ commentToggle: ResolvedShortcut,
52
+ ) {
53
+ this.options = options;
54
+ this.selectionMode = selectionMode;
55
+ this.allowFreeform = allowFreeform;
56
+ this.allowComment = allowComment;
57
+ this.theme = theme;
58
+ this.keybindings = keybindings;
59
+ this.commentToggle = commentToggle;
60
+ }
61
+
62
+ get isMulti(): boolean {
63
+ return this.selectionMode.multi;
64
+ }
65
+
66
+ isCommentEnabled(): boolean {
67
+ return this.commentEnabled;
68
+ }
69
+
70
+ setMaxVisibleRows(rows: number): void {
71
+ if (this.selectionMode.multi) return; // ponytail: multi-select uses fixed max 10, no-op here
72
+ const next = Math.max(1, Math.floor(rows));
73
+ if (next !== this.maxVisibleRows) {
74
+ this.maxVisibleRows = next;
75
+ this.invalidate();
76
+ }
77
+ }
78
+
79
+ invalidate(): void {
80
+ this.cachedWidth = undefined;
81
+ this.cachedLines = undefined;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Item-index arithmetic (single source of truth)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ private getItemCount(filtered?: QuestionOption[]): number {
89
+ const base = filtered ?? this.options;
90
+ return base.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
91
+ }
92
+
93
+ private getCommentToggleIndex(filtered?: QuestionOption[]): number | null {
94
+ return this.allowComment ? (filtered ?? this.options).length : null;
95
+ }
96
+
97
+ private getFreeformIndex(filtered?: QuestionOption[]): number {
98
+ const base = filtered ?? this.options;
99
+ return base.length + (this.allowComment ? 1 : 0);
100
+ }
101
+
102
+ private isCommentToggleRow(index: number, filtered?: QuestionOption[]): boolean {
103
+ const i = this.getCommentToggleIndex(filtered);
104
+ return i !== null && index === i;
105
+ }
106
+
107
+ private isFreeformRow(index: number, filtered?: QuestionOption[]): boolean {
108
+ return this.allowFreeform && index === this.getFreeformIndex(filtered);
109
+ }
110
+
111
+ private toggleComment(): void {
112
+ if (!this.allowComment) return;
113
+ this.commentEnabled = !this.commentEnabled;
114
+ this.invalidate();
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Single-select: fuzzy search helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ private getFilteredOptions(): QuestionOption[] {
122
+ return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.label} ${option.description ?? ""}`);
123
+ }
124
+
125
+ private setSearchQuery(query: string): void {
126
+ this.searchQuery = query;
127
+ this.selectedIndex = 0;
128
+ this.invalidate();
129
+ }
130
+
131
+ private popSearchChar(): void {
132
+ if (!this.searchQuery) return;
133
+ this.setSearchQuery([...this.searchQuery].slice(0, -1).join(""));
134
+ }
135
+
136
+ /** Printable character extraction (avoids unverified decodeKittyPrintable import). */
137
+ private getPrintableInput(data: string): string | null {
138
+ const chars = [...data];
139
+ if (chars.length !== 1) return null;
140
+ const [character] = chars;
141
+ if (!character) return null;
142
+ const code = character.charCodeAt(0);
143
+ if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return null;
144
+ return character;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Single-select: split-pane preview
149
+ // ---------------------------------------------------------------------------
150
+
151
+ private getSplitPaneWidths(width: number): { left: number; right: number } | null {
152
+ if (width < SPLIT_PANE_MIN_WIDTH) return null;
153
+ const available = width - SPLIT_PANE_SEPARATOR.length;
154
+ if (available < SPLIT_PANE_LEFT_MIN_WIDTH + SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
155
+ const preferredLeft = Math.floor(available * 0.42);
156
+ const left = Math.max(SPLIT_PANE_LEFT_MIN_WIDTH, Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN_WIDTH));
157
+ const right = available - left;
158
+ if (right < SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
159
+ return { left, right };
160
+ }
161
+
162
+ private styleListLine(line: string, width: number, isSelected: boolean): string {
163
+ const trimmed = line.trim();
164
+ if (trimmed.startsWith("(")) return truncateToWidth(this.theme.fg("dim", line), width, "");
165
+ if (isSelected) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
166
+ if (line.startsWith(" ")) return truncateToWidth(this.theme.fg("muted", line), width, "");
167
+ if (line.startsWith("→")) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
168
+ return truncateToWidth(this.theme.fg("text", line), width, "");
169
+ }
170
+
171
+ private buildListLines(width: number, filtered: QuestionOption[], hideDescriptions = false): string[] {
172
+ const theme = this.theme;
173
+ const lines: string[] = [];
174
+ const count = this.getItemCount(filtered);
175
+ const searchValue = this.searchQuery ? theme.fg("text", this.searchQuery) : theme.fg("dim", "type to filter");
176
+ lines.push(truncateToWidth(`${theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
177
+
178
+ if (this.searchQuery && filtered.length === 0) {
179
+ lines.push(truncateToWidth(theme.fg("warning", "No matching options"), width, ""));
180
+ }
181
+
182
+ if (count === 0) {
183
+ if (!this.searchQuery) lines.push(truncateToWidth(theme.fg("warning", "No options"), width, ""));
184
+ return lines.slice(0, this.maxVisibleRows);
185
+ }
186
+
187
+ const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
188
+ const rows = renderSingleSelectRows({
189
+ options: filtered,
190
+ selectedIndex: this.selectedIndex,
191
+ width,
192
+ allowFreeform: this.allowFreeform,
193
+ allowComment: this.allowComment,
194
+ commentEnabled: this.commentEnabled,
195
+ maxRows,
196
+ hideDescriptions,
197
+ });
198
+ for (const row of rows) lines.push(this.styleListLine(row.line, width, row.selected));
199
+ return lines.slice(0, this.maxVisibleRows);
200
+ }
201
+
202
+ private buildPreviewLines(width: number, filtered: QuestionOption[], maxLines: number): string[] {
203
+ if (maxLines <= 0) return [];
204
+ const mdTheme = safeMarkdownTheme();
205
+ let md = "";
206
+
207
+ if (this.isCommentToggleRow(this.selectedIndex, filtered)) {
208
+ md += "## Additional context\n\n";
209
+ md += `Currently: **${this.commentEnabled ? "Enabled" : "Disabled"}**\n\n`;
210
+ md += "Turn this on when the selected option needs extra explanation before the tool submits.\n";
211
+ } else if (this.isFreeformRow(this.selectedIndex, filtered)) {
212
+ md += "## Custom response\n\nOpen the editor to write **any** answer.\n\n*Use this when none of the listed options fit.*\n";
213
+ if (this.searchQuery) md += `\n> Current filter: \`${this.searchQuery}\`\n`;
214
+ } else {
215
+ const selected = filtered[this.selectedIndex];
216
+ if (!selected) {
217
+ md += "*No option selected*\n";
218
+ } else {
219
+ md += `## ${selected.label}\n\n`;
220
+ md += selected.description?.trim() ?? "*No additional details provided for this option.*\n";
221
+ md += `\n---\n\nPress \`Enter\` to select this option.\n`;
222
+ if (this.searchQuery) md += `\n> Filter: \`${this.searchQuery}\`\n`;
223
+ }
224
+ }
225
+
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
+ }
234
+ }
235
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
236
+ if (lines.length <= maxLines) return lines;
237
+ if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
238
+ const visible = lines.slice(0, maxLines - 1);
239
+ visible.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
240
+ return visible;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Input handling
245
+ // ---------------------------------------------------------------------------
246
+
247
+ handleInput(data: string): void {
248
+ if (!this.selectionMode.multi) {
249
+ this.handleSingleSelectInput(data);
250
+ } else {
251
+ this.handleMultiSelectInput(data);
252
+ }
253
+ }
254
+
255
+ private handleSingleSelectInput(data: string): void {
256
+ // Single-select order: escape-clears-search → cancel → comment-toggle → up → down
257
+ // → number → space-on-comment → confirm → backspace → printable
258
+ if (this.searchQuery && matchesKey(data, Key.escape)) {
259
+ this.setSearchQuery("");
260
+ return;
261
+ }
262
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
263
+ this.onCancel?.();
264
+ return;
265
+ }
266
+ if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
267
+ this.toggleComment();
268
+ return;
269
+ }
270
+
271
+ const filtered = this.getFilteredOptions();
272
+ const count = this.getItemCount(filtered);
273
+
274
+ if (matchesUp(data, this.keybindings) && count > 0) {
275
+ this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
276
+ this.invalidate();
277
+ return;
278
+ }
279
+ if (matchesDown(data, this.keybindings) && count > 0) {
280
+ this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
281
+ this.invalidate();
282
+ return;
283
+ }
284
+
285
+ const numMatch = data.match(/^[1-9]$/);
286
+ if (numMatch && filtered.length > 0) {
287
+ const idx = Number.parseInt(numMatch[0], 10) - 1;
288
+ if (idx >= 0 && idx < filtered.length) {
289
+ this.selectedIndex = idx;
290
+ this.invalidate();
291
+ return;
292
+ }
293
+ }
294
+
295
+ if (matchesKey(data, Key.space) && count > 0 && this.isCommentToggleRow(this.selectedIndex, filtered)) {
296
+ this.toggleComment();
297
+ return;
298
+ }
299
+
300
+ if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
301
+ if (this.isCommentToggleRow(this.selectedIndex, filtered)) {
302
+ this.toggleComment();
303
+ return;
304
+ }
305
+ if (this.isFreeformRow(this.selectedIndex, filtered)) {
306
+ this.onEnterFreeform?.();
307
+ return;
308
+ }
309
+ const result = filtered[this.selectedIndex]?.label;
310
+ if (result) this.onSubmit?.([result], this.commentEnabled);
311
+ else this.onCancel?.();
312
+ return;
313
+ }
314
+
315
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
316
+ this.popSearchChar();
317
+ return;
318
+ }
319
+
320
+ const printable = this.getPrintableInput(data);
321
+ if (printable) this.setSearchQuery(this.searchQuery + printable);
322
+ }
323
+
324
+ private handleMultiSelectInput(data: string): void {
325
+ // Multi-select order: cancel → count-check → comment-toggle → up → down
326
+ // → number → space → confirm
327
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
328
+ this.onCancel?.();
329
+ return;
330
+ }
331
+
332
+ const count = this.getItemCount();
333
+ if (count === 0) {
334
+ this.onCancel?.();
335
+ return;
336
+ }
337
+
338
+ if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
339
+ this.toggleComment();
340
+ return;
341
+ }
342
+
343
+ if (matchesUp(data, this.keybindings)) {
344
+ this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
345
+ this.invalidate();
346
+ return;
347
+ }
348
+ if (matchesDown(data, this.keybindings)) {
349
+ this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
350
+ this.invalidate();
351
+ return;
352
+ }
353
+
354
+ const numMatch = data.match(/^[1-9]$/);
355
+ if (numMatch) {
356
+ const idx = Number.parseInt(numMatch[0], 10) - 1;
357
+ if (idx >= 0 && idx < this.options.length) {
358
+ this.selectionMode.toggle(this.checked, idx);
359
+ this.selectedIndex = Math.min(idx, count - 1);
360
+ this.invalidate();
361
+ }
362
+ return;
363
+ }
364
+
365
+ if (matchesKey(data, Key.space)) {
366
+ if (this.isCommentToggleRow(this.selectedIndex)) {
367
+ this.toggleComment();
368
+ return;
369
+ }
370
+ if (this.isFreeformRow(this.selectedIndex)) {
371
+ this.onEnterFreeform?.();
372
+ return;
373
+ }
374
+ if (this.selectedIndex < this.options.length) {
375
+ this.selectionMode.toggle(this.checked, this.selectedIndex);
376
+ this.invalidate();
377
+ }
378
+ return;
379
+ }
380
+
381
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
382
+ if (this.isCommentToggleRow(this.selectedIndex)) {
383
+ this.toggleComment();
384
+ return;
385
+ }
386
+ if (this.isFreeformRow(this.selectedIndex)) {
387
+ this.onEnterFreeform?.();
388
+ return;
389
+ }
390
+
391
+ const result = this.selectionMode.buildResult({
392
+ selectedIndex: this.selectedIndex,
393
+ checked: this.checked,
394
+ options: this.options,
395
+ });
396
+ if (result.length > 0) this.onSubmit?.(result, this.commentEnabled);
397
+ else this.onCancel?.();
398
+ }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Rendering
403
+ // ---------------------------------------------------------------------------
404
+
405
+ render(width: number): string[] {
406
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
407
+
408
+ let lines: string[];
409
+
410
+ if (this.selectionMode.multi) {
411
+ lines = this.renderMulti(width);
412
+ } else {
413
+ lines = this.renderSingle(width);
414
+ }
415
+
416
+ this.cachedWidth = width;
417
+ this.cachedLines = lines;
418
+ return lines;
419
+ }
420
+
421
+ private renderMulti(width: number): string[] {
422
+ const theme = this.theme;
423
+ const count = this.getItemCount();
424
+ const maxVisible = Math.min(count, 10);
425
+
426
+ if (count === 0) {
427
+ return [theme.fg("warning", "No options")];
428
+ }
429
+
430
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
431
+ const endIndex = Math.min(startIndex + maxVisible, count);
432
+ const lines: string[] = [];
433
+
434
+ for (let i = startIndex; i < endIndex; i++) {
435
+ const isSelected = i === this.selectedIndex;
436
+ const prefix = isSelected ? theme.fg("accent", "→") : " ";
437
+
438
+ if (this.isCommentToggleRow(i)) {
439
+ const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
440
+ const label = isSelected
441
+ ? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
442
+ : theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
443
+ lines.push(truncateToWidth(`${prefix} ${checkbox} ${label}`, width, ""));
444
+ continue;
445
+ }
446
+
447
+ if (this.isFreeformRow(i)) {
448
+ const label = theme.fg("text", theme.bold("Type custom answer."));
449
+ const desc = theme.fg("muted", "Enter a custom response");
450
+ lines.push(truncateToWidth(`${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`, width, ""));
451
+ continue;
452
+ }
453
+
454
+ const option = this.options[i];
455
+ if (!option) continue;
456
+
457
+ const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
458
+ const num = theme.fg("dim", `${i + 1}.`);
459
+ const title = isSelected ? theme.fg("accent", theme.bold(option.label)) : theme.fg("text", theme.bold(option.label));
460
+ lines.push(truncateToWidth(`${prefix} ${num} ${checkbox} ${title}`, width, ""));
461
+
462
+ if (option.description) {
463
+ const indent = " ";
464
+ for (const w of wrapTextWithAnsi(option.description, Math.max(10, width - indent.length))) {
465
+ lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
466
+ }
467
+ }
468
+ }
469
+
470
+ if (startIndex > 0 || endIndex < count) {
471
+ lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
472
+ }
473
+
474
+ return lines;
475
+ }
476
+
477
+ private renderSingle(width: number): string[] {
478
+ const filtered = this.getFilteredOptions();
479
+ const count = this.getItemCount(filtered);
480
+ this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
481
+
482
+ const split = this.getSplitPaneWidths(width);
483
+
484
+ if (!split) {
485
+ return this.buildListLines(width, filtered);
486
+ }
487
+
488
+ const listLines = this.buildListLines(split.left, filtered, true);
489
+ const previewLines = this.buildPreviewLines(split.right, filtered, this.maxVisibleRows);
490
+ const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
491
+ const separator = this.theme.fg("dim", SPLIT_PANE_SEPARATOR);
492
+ return Array.from({ length: rowCount }, (_, index) => {
493
+ const left = truncateToWidth(listLines[index] ?? "", split.left, "", true);
494
+ const right = truncateToWidth(previewLines[index] ?? "", split.right, "");
495
+ return `${left}${separator}${right}`;
496
+ });
497
+ }
498
+ }
@@ -0,0 +1,162 @@
1
+ // Single-select row layout (inlined from pi-ask-user/single-select-layout.ts).
2
+
3
+ import { COMMENT_TOGGLE_LABEL, FREEFORM_LABEL } from "./constants.ts";
4
+ import type { AnnotatedRow, ItemBlock, ListItem, QuestionOption, RenderRowsParams } from "./types.ts";
5
+
6
+ export function wrapPlain(text: string, width: number): string[] {
7
+ const normalized = text.replace(/\s+/g, " ").trim();
8
+ if (!normalized) return [""];
9
+ if (width <= 1) return [...normalized];
10
+
11
+ const words = normalized.split(" ");
12
+ const lines: string[] = [];
13
+ let current = "";
14
+
15
+ for (const word of words) {
16
+ if (!current) {
17
+ if (word.length <= width) {
18
+ current = word;
19
+ } else {
20
+ for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
21
+ }
22
+ continue;
23
+ }
24
+ const candidate = `${current} ${word}`;
25
+ if (candidate.length <= width) {
26
+ current = candidate;
27
+ continue;
28
+ }
29
+ lines.push(current);
30
+ if (word.length <= width) {
31
+ current = word;
32
+ } else {
33
+ current = "";
34
+ for (let i = 0; i < word.length; i += width) {
35
+ const chunk = word.slice(i, i + width);
36
+ if (chunk.length === width || i + width < word.length) lines.push(chunk);
37
+ else current = chunk;
38
+ }
39
+ }
40
+ }
41
+ if (current) lines.push(current);
42
+ return lines;
43
+ }
44
+
45
+ export function padLine(prefix: string, content: string): string {
46
+ return `${prefix}${content}`.trimEnd();
47
+ }
48
+
49
+ export function buildItemBlocks(
50
+ options: QuestionOption[],
51
+ width: number,
52
+ allowFreeform: boolean,
53
+ allowComment: boolean,
54
+ commentEnabled: boolean,
55
+ selectedIndex: number,
56
+ hideDescriptions = false,
57
+ ): ItemBlock[] {
58
+ const normalizedWidth = Math.max(12, width);
59
+ const allItems: ListItem[] = options.map((option) => ({ type: "option", option }));
60
+ if (allowComment) {
61
+ allItems.push({
62
+ type: "comment-toggle",
63
+ option: { label: `${commentEnabled ? "[✓]" : "[ ]"} ${COMMENT_TOGGLE_LABEL}` },
64
+ });
65
+ }
66
+ if (allowFreeform) {
67
+ allItems.push({ type: "freeform", option: { label: FREEFORM_LABEL } });
68
+ }
69
+
70
+ return allItems.map((item, itemIndex) => {
71
+ const pointer = itemIndex === selectedIndex ? "→" : " ";
72
+ const lines: string[] = [];
73
+
74
+ if (item.type === "comment-toggle" || item.type === "freeform") {
75
+ const prefix = `${pointer} `;
76
+ const wrapped = wrapPlain(item.option.label, Math.max(8, normalizedWidth - prefix.length));
77
+ wrapped.forEach((line, lineIndex) => {
78
+ lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
79
+ });
80
+ return { itemIndex, lines };
81
+ }
82
+
83
+ const numberPrefix = `${pointer} ${itemIndex + 1}. `;
84
+ const continuationPrefix = " ".repeat(numberPrefix.length);
85
+ const titleLines = wrapPlain(item.option.label, Math.max(8, normalizedWidth - numberPrefix.length));
86
+ titleLines.forEach((line, lineIndex) => {
87
+ lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
88
+ });
89
+
90
+ if (item.option.description && !hideDescriptions) {
91
+ const descriptionPrefix = " ";
92
+ const descriptionLines = wrapPlain(item.option.description, Math.max(8, normalizedWidth - descriptionPrefix.length));
93
+ descriptionLines.forEach((line) => {
94
+ lines.push(padLine(descriptionPrefix, line));
95
+ });
96
+ }
97
+
98
+ return { itemIndex, lines };
99
+ });
100
+ }
101
+
102
+ export function flattenBlocks(blocks: ItemBlock[], selectedIndex: number): AnnotatedRow[] {
103
+ return blocks.flatMap((block) =>
104
+ block.lines.map((line) => ({ line, selected: block.itemIndex === selectedIndex })),
105
+ );
106
+ }
107
+
108
+ export function renderSingleSelectRows({
109
+ options,
110
+ selectedIndex,
111
+ width,
112
+ allowFreeform,
113
+ allowComment = false,
114
+ commentEnabled = false,
115
+ maxRows,
116
+ hideDescriptions,
117
+ }: RenderRowsParams): AnnotatedRow[] {
118
+ const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
119
+ const blocks = buildItemBlocks(options, width, allowFreeform, allowComment, commentEnabled, selectedIndex, hideDescriptions);
120
+ const allRows = flattenBlocks(blocks, selectedIndex);
121
+
122
+ if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
123
+ return allRows;
124
+ }
125
+
126
+ const safeMaxRows = Math.max(1, Math.floor(maxRows));
127
+ const selectedBlock = blocks[selectedIndex] ?? blocks[0];
128
+ if (!selectedBlock) return [];
129
+
130
+ const indicator = ` (${selectedIndex + 1}/${itemCount})`;
131
+ const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
132
+
133
+ if (selectedBlock.lines.length >= availableRows) {
134
+ const visible = selectedBlock.lines.slice(0, availableRows).map((line) => ({ line, selected: true }));
135
+ if (safeMaxRows > 1) visible.push({ line: indicator, selected: false });
136
+ return visible.slice(0, safeMaxRows);
137
+ }
138
+
139
+ let start = selectedIndex;
140
+ let end = selectedIndex + 1;
141
+ let usedRows = selectedBlock.lines.length;
142
+
143
+ while (true) {
144
+ const nextCanFit = end < blocks.length && usedRows + blocks[end]!.lines.length <= availableRows;
145
+ if (nextCanFit) {
146
+ usedRows += blocks[end]!.lines.length;
147
+ end += 1;
148
+ continue;
149
+ }
150
+ const prevCanFit = start > 0 && usedRows + blocks[start - 1]!.lines.length <= availableRows;
151
+ if (prevCanFit) {
152
+ start -= 1;
153
+ usedRows += blocks[start]!.lines.length;
154
+ continue;
155
+ }
156
+ break;
157
+ }
158
+
159
+ const visible = flattenBlocks(blocks.slice(start, end), selectedIndex);
160
+ visible.push({ line: indicator, selected: false });
161
+ return visible.slice(0, safeMaxRows);
162
+ }