instant-cli 1.0.33 → 1.0.34

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 (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__tests__/multiSelect.test.ts +236 -0
  3. package/__tests__/select.test.ts +224 -0
  4. package/__tests__/webhooks.test.ts +728 -0
  5. package/dist/commands/webhooks/add.d.ts +9 -0
  6. package/dist/commands/webhooks/add.d.ts.map +1 -0
  7. package/dist/commands/webhooks/add.js +75 -0
  8. package/dist/commands/webhooks/add.js.map +1 -0
  9. package/dist/commands/webhooks/delete.d.ts +6 -0
  10. package/dist/commands/webhooks/delete.d.ts.map +1 -0
  11. package/dist/commands/webhooks/delete.js +17 -0
  12. package/dist/commands/webhooks/delete.js.map +1 -0
  13. package/dist/commands/webhooks/disable.d.ts +7 -0
  14. package/dist/commands/webhooks/disable.d.ts.map +1 -0
  15. package/dist/commands/webhooks/disable.js +18 -0
  16. package/dist/commands/webhooks/disable.js.map +1 -0
  17. package/dist/commands/webhooks/enable.d.ts +6 -0
  18. package/dist/commands/webhooks/enable.d.ts.map +1 -0
  19. package/dist/commands/webhooks/enable.js +18 -0
  20. package/dist/commands/webhooks/enable.js.map +1 -0
  21. package/dist/commands/webhooks/events/list.d.ts +7 -0
  22. package/dist/commands/webhooks/events/list.d.ts.map +1 -0
  23. package/dist/commands/webhooks/events/list.js +31 -0
  24. package/dist/commands/webhooks/events/list.js.map +1 -0
  25. package/dist/commands/webhooks/events/payload.d.ts +8 -0
  26. package/dist/commands/webhooks/events/payload.d.ts.map +1 -0
  27. package/dist/commands/webhooks/events/payload.js +39 -0
  28. package/dist/commands/webhooks/events/payload.js.map +1 -0
  29. package/dist/commands/webhooks/events/resend.d.ts +8 -0
  30. package/dist/commands/webhooks/events/resend.d.ts.map +1 -0
  31. package/dist/commands/webhooks/events/resend.js +43 -0
  32. package/dist/commands/webhooks/events/resend.js.map +1 -0
  33. package/dist/commands/webhooks/list.d.ts +8 -0
  34. package/dist/commands/webhooks/list.d.ts.map +1 -0
  35. package/dist/commands/webhooks/list.js +29 -0
  36. package/dist/commands/webhooks/list.js.map +1 -0
  37. package/dist/commands/webhooks/shared.d.ts +40 -0
  38. package/dist/commands/webhooks/shared.d.ts.map +1 -0
  39. package/dist/commands/webhooks/shared.js +248 -0
  40. package/dist/commands/webhooks/shared.js.map +1 -0
  41. package/dist/commands/webhooks/update.d.ts +10 -0
  42. package/dist/commands/webhooks/update.d.ts.map +1 -0
  43. package/dist/commands/webhooks/update.js +189 -0
  44. package/dist/commands/webhooks/update.js.map +1 -0
  45. package/dist/index.d.ts +45 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +141 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/layer.d.ts +2 -2
  50. package/dist/layer.d.ts.map +1 -1
  51. package/dist/layer.js +30 -1
  52. package/dist/layer.js.map +1 -1
  53. package/dist/lib/webhooks.d.ts +28 -0
  54. package/dist/lib/webhooks.d.ts.map +1 -0
  55. package/dist/lib/webhooks.js +102 -0
  56. package/dist/lib/webhooks.js.map +1 -0
  57. package/dist/ui/index.d.ts +39 -1
  58. package/dist/ui/index.d.ts.map +1 -1
  59. package/dist/ui/index.js +387 -25
  60. package/dist/ui/index.js.map +1 -1
  61. package/dist/ui/lib.d.ts +7 -0
  62. package/dist/ui/lib.d.ts.map +1 -1
  63. package/dist/ui/lib.js +40 -1
  64. package/dist/ui/lib.js.map +1 -1
  65. package/package.json +4 -4
  66. package/src/commands/webhooks/add.ts +111 -0
  67. package/src/commands/webhooks/delete.ts +23 -0
  68. package/src/commands/webhooks/disable.ts +24 -0
  69. package/src/commands/webhooks/enable.ts +24 -0
  70. package/src/commands/webhooks/events/list.ts +38 -0
  71. package/src/commands/webhooks/events/payload.ts +56 -0
  72. package/src/commands/webhooks/events/resend.ts +66 -0
  73. package/src/commands/webhooks/list.ts +41 -0
  74. package/src/commands/webhooks/shared.ts +339 -0
  75. package/src/commands/webhooks/update.ts +276 -0
  76. package/src/index.ts +242 -0
  77. package/src/layer.ts +33 -1
  78. package/src/lib/webhooks.ts +127 -0
  79. package/src/ui/index.ts +465 -32
  80. package/src/ui/lib.ts +41 -1
package/src/ui/index.ts CHANGED
@@ -4,7 +4,12 @@ import stringWidth from 'string-width';
4
4
  import { Prompt, SelectState } from './lib.ts';
5
5
  import type { AnyKey, ModifyOutputFn } from './lib.ts';
6
6
 
7
- export { render, renderUnwrap, setRawModeWindowsFriendly } from './lib.ts';
7
+ export {
8
+ clearPromptTrail,
9
+ render,
10
+ renderUnwrap,
11
+ setRawModeWindowsFriendly,
12
+ } from './lib.ts';
8
13
 
9
14
  export namespace UI {
10
15
  type Status = 'idle' | 'submitted' | 'aborted';
@@ -92,6 +97,7 @@ export namespace UI {
92
97
  options: {
93
98
  value: T;
94
99
  label: string;
100
+ expandableLabel?: string | (() => string | Promise<string>);
95
101
  secondary?: boolean;
96
102
  }[];
97
103
  promptText: string;
@@ -100,25 +106,30 @@ export namespace UI {
100
106
  };
101
107
  export class Select<T> extends Prompt<T> {
102
108
  config(status: 'idle' | 'submitted' | 'aborted'): string {
103
- console.log('config', status);
104
109
  return status;
105
110
  }
106
111
 
107
112
  private readonly data: SelectState<T>;
108
113
  private readonly options: SelectProps<T>['options'];
109
114
  private readonly params: SelectProps<T>;
115
+ private expandedIdx: number | null = null;
116
+ private stickyExpanded = false;
117
+ private readonly expansionCache: Map<number, string> = new Map();
118
+ private readonly expansionLoading: Set<number> = new Set();
110
119
 
111
120
  constructor(params: SelectProps<T>) {
112
121
  super(params.modifyOutput);
113
122
  this.on('attach', (terminal) => terminal.toggleCursor('hide'));
114
- this.on('input', (input) => {
115
- if (input === 'j') {
123
+ this.on('input', (input, key) => {
124
+ if (input === 'j' || (key?.ctrl && key.name === 'n')) {
116
125
  this.data.selectedIdx =
117
126
  (this.data.selectedIdx + 1) % this.options.length;
118
- } else if (input === 'k') {
127
+ this.applyNavigation();
128
+ } else if (input === 'k' || (key?.ctrl && key.name === 'p')) {
119
129
  this.data.selectedIdx =
120
130
  (this.data.selectedIdx - 1 + this.options.length) %
121
131
  this.options.length;
132
+ this.applyNavigation();
122
133
  }
123
134
  this.requestLayout();
124
135
  });
@@ -142,6 +153,82 @@ export namespace UI {
142
153
  }
143
154
 
144
155
  this.data.bind(this as any);
156
+
157
+ this.on('input', (_input, key) => {
158
+ if (key?.name === 'tab') {
159
+ const hasExpandable = this.options.some((o) => o.expandableLabel);
160
+ if (!hasExpandable) return;
161
+ this.stickyExpanded = !this.stickyExpanded;
162
+ if (this.stickyExpanded) {
163
+ this.expandFocused();
164
+ } else {
165
+ this.expandedIdx = null;
166
+ }
167
+ this.requestLayout();
168
+ return;
169
+ }
170
+ if (key?.name === 'up' || key?.name === 'down') {
171
+ this.applyNavigation();
172
+ this.requestLayout();
173
+ }
174
+ });
175
+ }
176
+
177
+ private applyNavigation() {
178
+ if (this.stickyExpanded) {
179
+ this.expandFocused();
180
+ } else {
181
+ this.expandedIdx = null;
182
+ }
183
+ }
184
+
185
+ private expandFocused() {
186
+ const focused = this.data.selectedIdx;
187
+ const focusedOption = this.options[focused];
188
+ const exp = focusedOption?.expandableLabel;
189
+ if (!exp) {
190
+ this.expandedIdx = null;
191
+ return;
192
+ }
193
+ this.expandedIdx = focused;
194
+ if (
195
+ typeof exp === 'function' &&
196
+ !this.expansionCache.has(focused) &&
197
+ !this.expansionLoading.has(focused)
198
+ ) {
199
+ this.expansionLoading.add(focused);
200
+ Promise.resolve()
201
+ .then(() => (exp as () => string | Promise<string>)())
202
+ .then(
203
+ (content) => {
204
+ this.expansionLoading.delete(focused);
205
+ this.expansionCache.set(focused, content);
206
+ if (this.expandedIdx === focused) this.requestLayout();
207
+ },
208
+ (err) => {
209
+ this.expansionLoading.delete(focused);
210
+ this.expansionCache.set(
211
+ focused,
212
+ chalk.red(
213
+ ` Error loading expansion: ${err?.message ?? err}`,
214
+ ),
215
+ );
216
+ if (this.expandedIdx === focused) this.requestLayout();
217
+ },
218
+ );
219
+ }
220
+ }
221
+
222
+ private getExpansionContent(
223
+ option: SelectProps<T>['options'][number],
224
+ idx: number,
225
+ ): string | null {
226
+ const exp = option.expandableLabel;
227
+ if (!exp) return null;
228
+ if (typeof exp === 'string') return exp;
229
+ if (this.expansionLoading.has(idx)) return chalk.dim(' Loading…');
230
+ const cached = this.expansionCache.get(idx);
231
+ return cached ?? chalk.dim(' Loading…');
145
232
  }
146
233
 
147
234
  result(): T {
@@ -154,35 +241,375 @@ export namespace UI {
154
241
  ${chalk.hex('#EA570B').bold('●')} ${this.params.options[this.data.selectedIdx]?.label}`;
155
242
  }
156
243
 
157
- const mainOptionsList = this.options
158
- .filter((option) => !option.secondary)
159
- .map((option, idx) => {
160
- const isSelected = idx === this.data.selectedIdx;
161
- const cursor = isSelected ? chalk.hex('#EA570B').bold('●') : '○';
162
- const label = isSelected
163
- ? chalk.bold(option.label)
164
- : chalk.dim(option.label);
165
-
166
- return `${cursor} ${label}`;
167
- })
168
- .join('\n');
169
-
170
- const secondaryOptionsList = this.options
171
- .filter((option) => option.secondary)
172
- .map((option, idx) => {
173
- const realIdx = idx + this.options.filter((o) => !o.secondary).length;
174
- const isSelected = realIdx === this.data.selectedIdx;
175
- const cursor = isSelected ? chalk.hex('#EA570B').bold('●') : '○';
176
- const label = isSelected
177
- ? chalk.bold(option.label)
178
- : chalk.dim(option.label);
179
-
180
- return `${cursor} ${label}`;
181
- })
182
- .join('\n');
244
+ const renderRow = (
245
+ option: SelectProps<T>['options'][number],
246
+ originalIdx: number,
247
+ ) => {
248
+ const isSelected = originalIdx === this.data.selectedIdx;
249
+ const cursor = isSelected ? chalk.hex('#EA570B').bold('●') : '○';
250
+ const label = isSelected
251
+ ? chalk.bold(option.label)
252
+ : chalk.dim(option.label);
253
+ const expandedContent =
254
+ isSelected && this.expandedIdx === originalIdx
255
+ ? this.getExpansionContent(option, originalIdx)
256
+ : null;
257
+ const expanded = expandedContent !== null ? '\n' + expandedContent : '';
258
+ return `${cursor} ${label}${expanded}`;
259
+ };
260
+
261
+ const rowLineCount = (originalIdx: number) => {
262
+ const opt = this.options[originalIdx]!;
263
+ const labelLines = opt.label.split('\n').length;
264
+ const isFocused = originalIdx === this.data.selectedIdx;
265
+ const expContent =
266
+ isFocused && this.expandedIdx === originalIdx
267
+ ? this.getExpansionContent(opt, originalIdx)
268
+ : null;
269
+ const expLines =
270
+ expContent !== null ? expContent.split('\n').length : 0;
271
+ return labelLines + expLines;
272
+ };
273
+
274
+ const mainEntries: {
275
+ opt: SelectProps<T>['options'][number];
276
+ originalIdx: number;
277
+ }[] = [];
278
+ const secondaryEntries: typeof mainEntries = [];
279
+ this.options.forEach((o, i) => {
280
+ if (o.secondary) secondaryEntries.push({ opt: o, originalIdx: i });
281
+ else mainEntries.push({ opt: o, originalIdx: i });
282
+ });
283
+
284
+ const totalRows = process.stdout.rows ?? 24;
285
+ const secondaryHeight =
286
+ secondaryEntries.length === 0
287
+ ? 0
288
+ : secondaryEntries.reduce(
289
+ (acc, { originalIdx }) => acc + rowLineCount(originalIdx),
290
+ 0,
291
+ ) + 1; // +1 for divider
292
+ const hasExpandable = this.options.some((o) => o.expandableLabel);
293
+ // chrome = prompt(1) + secondaries(+divider) + hint + safety(2)
294
+ const chrome = 1 + secondaryHeight + (hasExpandable ? 1 : 0) + 2;
295
+ const mainBudget = Math.max(3, totalRows - chrome);
296
+
297
+ const focusedMainPos = mainEntries.findIndex(
298
+ (e) => e.originalIdx === this.data.selectedIdx,
299
+ );
300
+ let visiblePositions: number[] = [];
301
+
302
+ if (focusedMainPos >= 0) {
303
+ let used = rowLineCount(mainEntries[focusedMainPos]!.originalIdx);
304
+ visiblePositions = [focusedMainPos];
305
+ let lo = focusedMainPos - 1;
306
+ let hi = focusedMainPos + 1;
307
+ while (lo >= 0 || hi < mainEntries.length) {
308
+ let progressed = false;
309
+ if (lo >= 0) {
310
+ const h = rowLineCount(mainEntries[lo]!.originalIdx);
311
+ if (used + h <= mainBudget) {
312
+ visiblePositions.unshift(lo);
313
+ used += h;
314
+ lo--;
315
+ progressed = true;
316
+ } else {
317
+ lo = -1;
318
+ }
319
+ }
320
+ if (hi < mainEntries.length) {
321
+ const h = rowLineCount(mainEntries[hi]!.originalIdx);
322
+ if (used + h <= mainBudget) {
323
+ visiblePositions.push(hi);
324
+ used += h;
325
+ hi++;
326
+ progressed = true;
327
+ } else {
328
+ hi = mainEntries.length;
329
+ }
330
+ }
331
+ if (!progressed) break;
332
+ }
333
+ } else {
334
+ // Cursor is on a secondary — show as many mains as fit, top-down.
335
+ let used = 0;
336
+ for (let i = 0; i < mainEntries.length; i++) {
337
+ const h = rowLineCount(mainEntries[i]!.originalIdx);
338
+ if (used + h > mainBudget) break;
339
+ visiblePositions.push(i);
340
+ used += h;
341
+ }
342
+ }
343
+
344
+ const firstVisible = visiblePositions[0] ?? 0;
345
+ const lastVisible =
346
+ visiblePositions[visiblePositions.length - 1] ?? mainEntries.length - 1;
347
+ const aboveCount = firstVisible;
348
+ const belowCount = Math.max(0, mainEntries.length - 1 - lastVisible);
349
+
350
+ const mainLines: string[] = [];
351
+ if (aboveCount > 0) mainLines.push(chalk.dim(` ↑ ${aboveCount} more`));
352
+ for (const pos of visiblePositions) {
353
+ mainLines.push(
354
+ renderRow(mainEntries[pos]!.opt, mainEntries[pos]!.originalIdx),
355
+ );
356
+ }
357
+ if (belowCount > 0) mainLines.push(chalk.dim(` ↓ ${belowCount} more`));
358
+ const mainBlock = mainLines.join('\n');
359
+
360
+ const secondaryBlock =
361
+ secondaryEntries.length === 0
362
+ ? ''
363
+ : chalk.gray(
364
+ '\n───────────────── Additional Options ─────────────────\n',
365
+ ) +
366
+ secondaryEntries
367
+ .map((e) => renderRow(e.opt, e.originalIdx))
368
+ .join('\n');
369
+
370
+ const expandHint = hasExpandable
371
+ ? '\n' +
372
+ chalk.dim(
373
+ this.stickyExpanded ? ' (tab to collapse)' : ' (tab to expand)',
374
+ )
375
+ : '';
183
376
 
184
377
  return `${this.params.promptText}
185
- ${mainOptionsList}${secondaryOptionsList.length ? chalk.gray('\n───────────────── Additional Options ─────────────────\n') + secondaryOptionsList : ''}`;
378
+ ${mainBlock}${secondaryBlock}${expandHint}`;
379
+ }
380
+ }
381
+
382
+ export type MultiSelectOption<T> = {
383
+ value: T;
384
+ label: string;
385
+ };
386
+
387
+ type MultiSelectProps<T> = {
388
+ options: MultiSelectOption<T>[];
389
+ promptText: string;
390
+ initialSelected?: T[];
391
+ filter?: (filter: string, option: MultiSelectOption<T>) => boolean;
392
+ minSelected?: number;
393
+ pageSize?: number;
394
+ modifyOutput?: ModifyOutputFn;
395
+ };
396
+
397
+ const DEFAULT_MULTI_SELECT_PAGE_SIZE = 10;
398
+
399
+ const defaultMultiSelectFilter = <T>(
400
+ filter: string,
401
+ option: MultiSelectOption<T>,
402
+ ) => option.label.toLowerCase().includes(filter.toLowerCase());
403
+
404
+ export class MultiSelect<T> extends Prompt<T[]> {
405
+ private filterText = '';
406
+ private cursorIdx = 0;
407
+ private windowStart = 0;
408
+ private selected: Set<number>;
409
+ private errorText: string | undefined;
410
+ private readonly props: MultiSelectProps<T>;
411
+ private readonly pageSize: number;
412
+
413
+ constructor(props: MultiSelectProps<T>) {
414
+ super(props.modifyOutput);
415
+ this.props = props;
416
+ this.pageSize = props.pageSize ?? DEFAULT_MULTI_SELECT_PAGE_SIZE;
417
+ this.selected = new Set(
418
+ (props.initialSelected ?? [])
419
+ .map((v) => props.options.findIndex((o) => o.value === v))
420
+ .filter((i) => i >= 0),
421
+ );
422
+ this.on('attach', (terminal) => {
423
+ terminal.setAllowInteraction(false);
424
+ terminal.toggleCursor('hide');
425
+ });
426
+ this.on('detach', (terminal) => terminal.toggleCursor('show'));
427
+ this.on('input', (input, key) => this.handleKey(input, key));
428
+ }
429
+
430
+ private visibleIndices(): number[] {
431
+ const fn = this.props.filter ?? defaultMultiSelectFilter;
432
+ if (!this.filterText) return this.props.options.map((_, i) => i);
433
+ return this.props.options
434
+ .map((opt, i) => ({ opt, i }))
435
+ .filter(({ opt }) => fn(this.filterText, opt))
436
+ .map(({ i }) => i);
437
+ }
438
+
439
+ private clampCursor(visible: number[]) {
440
+ if (visible.length === 0) {
441
+ this.cursorIdx = 0;
442
+ } else if (this.cursorIdx >= visible.length) {
443
+ this.cursorIdx = visible.length - 1;
444
+ } else if (this.cursorIdx < 0) {
445
+ this.cursorIdx = visible.length - 1;
446
+ }
447
+ }
448
+
449
+ private adjustWindow(visible: number[]) {
450
+ if (visible.length <= this.pageSize) {
451
+ this.windowStart = 0;
452
+ return;
453
+ }
454
+ if (this.cursorIdx < this.windowStart) {
455
+ this.windowStart = this.cursorIdx;
456
+ } else if (this.cursorIdx >= this.windowStart + this.pageSize) {
457
+ this.windowStart = this.cursorIdx - this.pageSize + 1;
458
+ }
459
+ const maxStart = Math.max(0, visible.length - this.pageSize);
460
+ if (this.windowStart > maxStart) this.windowStart = maxStart;
461
+ if (this.windowStart < 0) this.windowStart = 0;
462
+ }
463
+
464
+ private handleKey(input: string | undefined, key: AnyKey) {
465
+ if (key.name === 'escape') {
466
+ return this.terminal?.resolve({ data: undefined, status: 'aborted' });
467
+ }
468
+ if (key.name === 'return') {
469
+ const min = this.props.minSelected ?? 0;
470
+ if (this.selected.size < min) {
471
+ this.errorText =
472
+ min === 1
473
+ ? 'Select at least one option.'
474
+ : `Select at least ${min} options.`;
475
+ this.requestLayout();
476
+ return;
477
+ }
478
+ return this.terminal?.resolve({
479
+ data: this.result(),
480
+ status: 'submitted',
481
+ });
482
+ }
483
+
484
+ const visible = this.visibleIndices();
485
+
486
+ if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
487
+ this.cursorIdx -= 1;
488
+ this.clampCursor(visible);
489
+ this.adjustWindow(visible);
490
+ this.requestLayout();
491
+ return;
492
+ }
493
+ if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
494
+ this.cursorIdx += 1;
495
+ if (visible.length > 0) this.cursorIdx %= visible.length;
496
+ this.adjustWindow(visible);
497
+ this.requestLayout();
498
+ return;
499
+ }
500
+ if (key.name === 'pageup' || (key.ctrl && key.name === 'u')) {
501
+ this.cursorIdx -= this.pageSize;
502
+ if (this.cursorIdx < 0) this.cursorIdx = 0;
503
+ this.adjustWindow(visible);
504
+ this.requestLayout();
505
+ return;
506
+ }
507
+ if (key.name === 'pagedown' || (key.ctrl && key.name === 'd')) {
508
+ this.cursorIdx += this.pageSize;
509
+ if (this.cursorIdx >= visible.length) {
510
+ this.cursorIdx = Math.max(0, visible.length - 1);
511
+ }
512
+ this.adjustWindow(visible);
513
+ this.requestLayout();
514
+ return;
515
+ }
516
+ if (key.name === 'space') {
517
+ if (visible.length === 0) return;
518
+ const optIdx = visible[this.cursorIdx]!;
519
+ if (this.selected.has(optIdx)) {
520
+ this.selected.delete(optIdx);
521
+ } else {
522
+ this.selected.add(optIdx);
523
+ }
524
+ this.errorText = undefined;
525
+ this.requestLayout();
526
+ return;
527
+ }
528
+ if (key.name === 'backspace') {
529
+ if (this.filterText.length > 0) {
530
+ this.filterText = this.filterText.slice(0, -1);
531
+ this.cursorIdx = 0;
532
+ this.windowStart = 0;
533
+ this.requestLayout();
534
+ }
535
+ return;
536
+ }
537
+ if (key.name?.length === 1 && !key.ctrl && !key.meta && input) {
538
+ this.filterText += input;
539
+ this.cursorIdx = 0;
540
+ this.windowStart = 0;
541
+ this.requestLayout();
542
+ }
543
+ }
544
+
545
+ result(): T[] {
546
+ return [...this.selected]
547
+ .sort((a, b) => a - b)
548
+ .map((i) => this.props.options[i]!.value);
549
+ }
550
+
551
+ private renderOption(optIdx: number, isCursor: boolean) {
552
+ const option = this.props.options[optIdx]!;
553
+ const isSelected = this.selected.has(optIdx);
554
+ const checkbox = isSelected ? chalk.green('◉') : '○';
555
+ const cursorMark = isCursor ? chalk.hex('#EA570B').bold('❯') : ' ';
556
+ const label = isCursor ? chalk.bold(option.label) : option.label;
557
+ return `${cursorMark} ${checkbox} ${label}`;
558
+ }
559
+
560
+ render(status: 'idle' | 'submitted' | 'aborted'): string {
561
+ if (status === 'submitted') {
562
+ const labels = [...this.selected]
563
+ .sort((a, b) => a - b)
564
+ .map((i) => this.props.options[i]!.label);
565
+ return `${this.props.promptText}
566
+ ${chalk.hex('#EA570B').bold('●')} ${labels.length === 0 ? chalk.dim('(none)') : labels.join(', ')}`;
567
+ }
568
+
569
+ const visible = this.visibleIndices();
570
+ this.adjustWindow(visible);
571
+ const filterLine = `${chalk.dim('filter:')} ${this.filterText}${chalk.inverse(' ')}`;
572
+ const errorLine = this.errorText ? ` ${chalk.red(this.errorText)}` : '';
573
+
574
+ let optionsBlock: string;
575
+ if (visible.length === 0) {
576
+ optionsBlock = chalk.dim(' (no matches)');
577
+ } else {
578
+ const windowEnd = Math.min(
579
+ this.windowStart + this.pageSize,
580
+ visible.length,
581
+ );
582
+ const aboveCount = this.windowStart;
583
+ const belowCount = visible.length - windowEnd;
584
+ const lines: string[] = [];
585
+ lines.push(
586
+ aboveCount > 0
587
+ ? chalk.dim(` ↑ ${aboveCount} more`)
588
+ : chalk.dim(' '),
589
+ );
590
+ for (let i = this.windowStart; i < windowEnd; i++) {
591
+ lines.push(this.renderOption(visible[i]!, i === this.cursorIdx));
592
+ }
593
+ lines.push(
594
+ belowCount > 0
595
+ ? chalk.dim(` ↓ ${belowCount} more`)
596
+ : chalk.dim(' '),
597
+ );
598
+ optionsBlock = lines.join('\n');
599
+ }
600
+
601
+ const hint = chalk.dim(
602
+ ` ↑↓ or ctrl-n/p navigate · pgup/pgdn or ctrl-u/d page · space toggle · enter submit · esc cancel`,
603
+ );
604
+ const counts = chalk.dim(
605
+ ` ${visible.length} of ${this.props.options.length} shown · ${this.selected.size} selected`,
606
+ );
607
+
608
+ return `${this.props.promptText}${errorLine}
609
+ ${filterLine}
610
+ ${optionsBlock}
611
+ ${counts}
612
+ ${hint}`;
186
613
  }
187
614
  }
188
615
 
@@ -311,6 +738,8 @@ ${inputDisplay}`;
311
738
  const validationResult = this.props.validate(this.value);
312
739
  if (validationResult) {
313
740
  this.errorText = validationResult;
741
+ this.requestLayout();
742
+ return;
314
743
  } else {
315
744
  return this.terminal?.resolve({
316
745
  data: this.value,
@@ -326,12 +755,16 @@ ${inputDisplay}`;
326
755
  }
327
756
  if (keyInfo.name === 'backspace') {
328
757
  this.value = this.value.slice(0, -1);
758
+ this.errorText = undefined;
329
759
  } else if (keyInfo.name?.length === 1) {
330
760
  this.value += input;
761
+ this.errorText = undefined;
331
762
  } else if (keyInfo.name === 'space') {
332
763
  this.value += ' ';
764
+ this.errorText = undefined;
333
765
  } else if (input !== undefined) {
334
766
  this.value += input;
767
+ this.errorText = undefined;
335
768
  }
336
769
  this.requestLayout();
337
770
  });
package/src/ui/lib.ts CHANGED
@@ -169,6 +169,26 @@ type Prompted<T> =
169
169
  status: 'submitted';
170
170
  };
171
171
 
172
+ /**
173
+ * Module-level counter that tracks the cumulative line count of submitted
174
+ * prompts. A command opts in to "erase the trail at the very end" by calling
175
+ * {@link clearPromptTrail} right before printing its final summary.
176
+ */
177
+ let promptTrailLineCount = 0;
178
+
179
+ /**
180
+ * Walk the cursor up by however many lines submitted prompts have written, and
181
+ * erase from there to the end of the screen. Resets the counter. No-op on a
182
+ * non-TTY stdout (e.g. when output is piped). Vanished prompts don't
183
+ * contribute, so calling this only erases the dimmed trail.
184
+ */
185
+ export function clearPromptTrail(): void {
186
+ if (process.stdout.isTTY && promptTrailLineCount > 0) {
187
+ process.stdout.write(cursor.up(promptTrailLineCount) + erase.down());
188
+ }
189
+ promptTrailLineCount = 0;
190
+ }
191
+
172
192
  export class Terminal implements ITerminal {
173
193
  private text = '';
174
194
  private status: 'idle' | 'submitted' | 'aborted' = 'idle';
@@ -254,6 +274,23 @@ export class Terminal implements ITerminal {
254
274
  this.stdin.removeListener('keypress', keypress);
255
275
  if (this.stdin.isTTY) setRawModeWindowsFriendly(this.stdin, false);
256
276
  this.closable.close();
277
+ // Track rows the cursor advanced past so a command can erase the whole
278
+ // prompt trail at the end. Counts each \n plus wraps for lines wider than
279
+ // the terminal — mirrors `clear()`'s formula so wrapped output is still
280
+ // fully cleared. Skip vanished output (whitespace-only after the modifier
281
+ // ran).
282
+ if (this.status === 'submitted' && this.text.trim().length > 0) {
283
+ const cols = this.stdout.columns || 0;
284
+ const lines = this.text.split(/\r?\n/);
285
+ let rows = lines.length - 1; // one cursor-down per newline
286
+ if (cols > 0) {
287
+ for (const line of lines) {
288
+ const w = stringWidth(line);
289
+ if (w > 0) rows += Math.floor((w - 1) / cols);
290
+ }
291
+ }
292
+ promptTrailLineCount += rows;
293
+ }
257
294
  }
258
295
 
259
296
  result(): Promise<{}> {
@@ -271,7 +308,10 @@ export class Terminal implements ITerminal {
271
308
  requestLayout() {
272
309
  const string = this.view.fullRender(this.status);
273
310
  let realString = string;
274
- if (!realString.endsWith('\n')) {
311
+ // Only ensure a trailing newline when there's content. Vanished renders
312
+ // (empty string after modifyOutput) would otherwise inject a phantom line
313
+ // that pushes the next prompt down on each iteration.
314
+ if (realString.length > 0 && !realString.endsWith('\n')) {
275
315
  realString += '\n';
276
316
  }
277
317
  const realText = this.text.endsWith('\n') ? this.text : `${this.text}\n`;