@lidtop/loadout 0.3.0 → 0.3.2

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.
package/dist/picker.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { createPrompt, useEffect, useKeypress, useState, isEnterKey, isSpaceKey, } from '@inquirer/core';
2
2
  import { stripVTControlCharacters, styleText } from 'node:util';
3
3
  import stringWidth from 'string-width';
4
+ import path from 'node:path';
4
5
  import { resolveKits, reasons } from './resolve.js';
5
6
  import { kitSource } from './schema.js';
6
7
  import { availableUpdates, hasUpdate } from './updates.js';
7
8
  import { prepareInput } from './terminal.js';
8
9
  import { providerDescriptions } from './curated.js';
9
- const accent = (value) => styleText('cyan', value);
10
10
  const muted = (value) => styleText('dim', value);
11
11
  const bold = (value) => styleText('bold', value);
12
+ const scopeColor = (target, value) => styleText(target.global ? 'magenta' : 'cyan', value);
12
13
  const clean = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
13
14
  function fit(value, width) {
14
15
  const text = clean(value);
@@ -22,7 +23,7 @@ function fit(value, width) {
22
23
  }
23
24
  return width > 0 ? `${result}…` : '';
24
25
  }
25
- function wordmark(width, rows) {
26
+ function wordmark(width, rows, accent) {
26
27
  if (width < 43 || rows < 20)
27
28
  return ['', ` ${accent(bold('LOADOUT'))}`, ''];
28
29
  return [
@@ -44,20 +45,33 @@ const providerPrefixes = {
44
45
  'mattpocock/skills': 'matt-pocock-',
45
46
  'anthropics/skills': 'anthropic-',
46
47
  };
48
+ const initialView = (target) => ({
49
+ section: target.global ||
50
+ ![...(target.catalog?.kits.values() ?? [])].some((kit) => !providerFor(kit))
51
+ ? 'Browse'
52
+ : 'Kits',
53
+ provider: undefined,
54
+ providerList: { query: '', active: 0 },
55
+ query: '',
56
+ active: 0,
57
+ });
47
58
  const emptyState = () => ({
48
59
  schemaVersion: 1,
49
60
  selected: [],
50
61
  answers: {},
51
62
  });
52
63
  const renderPicker = createPrompt((config, done) => {
53
- const [targets, setTargets] = useState(config.targets);
54
- const [targetIndex, setTargetIndex] = useState(config.initial ?? 0);
55
- const [visited, setVisited] = useState([config.initial ?? 0]);
56
- const [selections, setSelections] = useState(config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
57
- target.catalog.kits.get(id)?.ready !== false)));
58
- const [pendingSwitch, setPendingSwitch] = useState(undefined);
64
+ const snapshot = config.session?.snapshot;
65
+ const targets = config.targets;
66
+ const [views, setViews] = useState(snapshot?.views ?? targets.map(initialView));
67
+ const [targetIndex, setTargetIndex] = useState(snapshot?.targetIndex ?? config.initial ?? 0);
68
+ const [visited, setVisited] = useState(snapshot?.visited ?? [config.initial ?? 0]);
69
+ const [selections, setSelections] = useState(snapshot?.selections ??
70
+ config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
71
+ target.catalog.kits.get(id)?.ready !== false)));
59
72
  const [notice, setNotice] = useState('');
60
73
  const target = targets[targetIndex];
74
+ const accent = (value) => scopeColor(target, value);
61
75
  const sections = target.global
62
76
  ? ['Browse', 'Installed']
63
77
  : repositorySections;
@@ -66,17 +80,18 @@ const renderPicker = createPrompt((config, done) => {
66
80
  kits: new Map(),
67
81
  };
68
82
  const selected = selections[targetIndex];
69
- const hasSelectionChanges = JSON.stringify([...selected].sort()) !==
70
- JSON.stringify([...(target.state?.selected ?? [])].sort());
83
+ const pendingTargets = targets.filter((item, index) => JSON.stringify([...selections[index]].sort()) !==
84
+ JSON.stringify([...(item.state?.selected ?? [])].sort()));
71
85
  const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
72
- const [section, setSection] = useState(target.global ||
73
- ![...catalog.kits.values()].some((kit) => !providerFor(kit))
74
- ? 'Browse'
75
- : 'Kits');
76
- const [scopeFocused, setScopeFocused] = useState(false);
77
- const [provider, setProvider] = useState(undefined);
78
- const [query, setQuery] = useState('');
79
- const [active, setActive] = useState(0);
86
+ const [section, setSection] = useState(snapshot?.section ?? views[targetIndex].section);
87
+ const [provider, setProvider] = useState(snapshot?.provider);
88
+ const [providerList, setProviderList] = useState(snapshot?.providerList ?? { query: '', active: 0 });
89
+ const [query, setQuery] = useState(snapshot?.query ?? '');
90
+ const [active, setActive] = useState(snapshot?.active ?? 0);
91
+ useEffect((rl) => {
92
+ if (snapshot?.query)
93
+ rl.write(snapshot.query);
94
+ }, []);
80
95
  const [finished, setFinished] = useState(false);
81
96
  const [size, setSize] = useState({
82
97
  columns: process.stdout.columns || 80,
@@ -176,10 +191,16 @@ const renderPicker = createPrompt((config, done) => {
176
191
  .sort((a, b) => Number(a.ready === false) - Number(b.ready === false))
177
192
  .map((kit) => ({ id: kit.id, description: kit.description, kit }));
178
193
  }
194
+ if (provider)
195
+ entries.push({
196
+ id: '@back',
197
+ action: 'back',
198
+ description: 'Keeps your selections',
199
+ });
179
200
  entries.push({
180
- id: '@continue',
181
- action: 'continue',
182
- description: 'Review selections and apply',
201
+ id: '@review',
202
+ action: 'review',
203
+ description: '',
183
204
  });
184
205
  const cursor = Math.min(active, Math.max(0, entries.length - 1));
185
206
  const focused = entries[cursor];
@@ -193,72 +214,77 @@ const renderPicker = createPrompt((config, done) => {
193
214
  rl.clearLine(0);
194
215
  rl.write(query);
195
216
  };
196
- const switchTarget = (index) => {
197
- setScopeFocused(false);
198
- setTargetIndex(index);
199
- setSection(targets[index].global ||
200
- ![...targets[index].catalog.kits.values()].some((kit) => !providerFor(kit))
201
- ? 'Browse'
202
- : 'Kits');
203
- setVisited([...new Set([...visited, index])]);
217
+ const backToProviders = () => {
204
218
  setProvider(undefined);
205
- setNotice('');
206
- reset();
219
+ setQuery(providerList.query);
220
+ setActive(providerList.active);
221
+ rl.clearLine(0);
222
+ rl.write(providerList.query);
207
223
  };
208
- if (pendingSwitch !== undefined) {
209
- if (key.name === 'y') {
210
- switchTarget(pendingSwitch);
211
- setPendingSwitch(undefined);
212
- }
213
- else if (key.name === 'n' ||
214
- key.name === 'escape' ||
215
- isEnterKey(key)) {
216
- setPendingSwitch(undefined);
224
+ const switchTarget = (index) => {
225
+ if (index === targetIndex) {
217
226
  restoreInput();
227
+ return;
218
228
  }
219
- return;
220
- }
221
- if (scopeFocused && (isEnterKey(key) || isSpaceKey(key))) {
222
- setScopeFocused(false);
223
- const next = (targetIndex + 1) % targets.length;
224
- const other = targets[next];
225
- if (other.error)
226
- setNotice(other.error);
227
- else if (!other.catalog)
228
- setNotice('Cannot load this location.');
229
- else if (hasSelectionChanges) {
230
- setPendingSwitch(next);
229
+ const next = targets[index];
230
+ if (next.error || !next.catalog) {
231
+ setNotice(next.error ?? 'Cannot load this location.');
232
+ restoreInput();
233
+ return;
231
234
  }
232
- else
233
- switchTarget(next);
235
+ setViews(views.map((view, index) => index === targetIndex
236
+ ? { section, provider, providerList, query, active: cursor }
237
+ : view));
238
+ const view = views[index];
239
+ setTargetIndex(index);
240
+ setSection(view.section);
241
+ setProvider(view.provider);
242
+ setProviderList(view.providerList);
243
+ setQuery(view.query);
244
+ setActive(view.active);
245
+ setVisited([...new Set([...visited, index])]);
246
+ setNotice('');
234
247
  rl.clearLine(0);
248
+ rl.write(view.query);
249
+ };
250
+ if (key.name === 'tab') {
251
+ switchTarget((targetIndex + (key.shift ? -1 : 1) + targets.length) %
252
+ targets.length);
235
253
  return;
236
254
  }
237
- if (['tab', 'left', 'right'].includes(key.name)) {
238
- const index = scopeFocused
239
- ? sections.length
240
- : sections.indexOf(section);
241
- const count = sections.length + (targets.length > 1 ? 1 : 0);
242
- const backwards = key.name === 'left' || (key.name === 'tab' && key.shift);
255
+ if (['left', 'right'].includes(key.name)) {
256
+ const index = sections.indexOf(section);
257
+ const count = sections.length;
258
+ const backwards = key.name === 'left';
243
259
  const next = (index + (backwards ? -1 : 1) + count) % count;
244
- setScopeFocused(next === sections.length);
245
- if (next !== sections.length) {
246
- setSection(sections[next]);
247
- setProvider(undefined);
248
- reset();
249
- }
250
- else
251
- restoreInput();
260
+ setSection(sections[next]);
261
+ setProvider(undefined);
262
+ reset();
252
263
  }
253
264
  else if (key.name === 'up' || key.name === 'down') {
254
- setScopeFocused(false);
255
265
  if (entries.length)
256
266
  setActive((cursor + (key.name === 'up' ? -1 : 1) + entries.length) %
257
267
  entries.length);
258
268
  restoreInput();
259
269
  }
260
270
  else if (isEnterKey(key) || isSpaceKey(key)) {
261
- if (focused?.action === 'continue') {
271
+ if (focused?.action === 'back') {
272
+ backToProviders();
273
+ return;
274
+ }
275
+ else if (focused?.action === 'review') {
276
+ if (config.session)
277
+ config.session.snapshot = {
278
+ targetIndex,
279
+ visited,
280
+ selections,
281
+ views,
282
+ section,
283
+ provider,
284
+ providerList,
285
+ query,
286
+ active: cursor,
287
+ };
262
288
  setFinished(true);
263
289
  done(targets.flatMap((item, index) => {
264
290
  if (!item.catalog || !visited.includes(index))
@@ -273,6 +299,7 @@ const renderPicker = createPrompt((config, done) => {
273
299
  }));
274
300
  }
275
301
  else if (browsingProviders && focused) {
302
+ setProviderList({ query, active: cursor });
276
303
  setProvider(focused.id);
277
304
  setActive(0);
278
305
  }
@@ -286,124 +313,115 @@ const renderPicker = createPrompt((config, done) => {
286
313
  rl.write(query);
287
314
  }
288
315
  else if (key.name === 'escape') {
289
- if (scopeFocused) {
290
- setScopeFocused(false);
291
- restoreInput();
292
- }
293
- else {
294
- if (!query && provider)
295
- setProvider(undefined);
316
+ if (!query && provider)
317
+ backToProviders();
318
+ else
296
319
  reset();
297
- }
298
320
  }
299
321
  else if (!['left', 'right', 'home', 'end'].includes(key.name)) {
300
- setScopeFocused(false);
301
322
  setQuery(clean(rl.line));
302
323
  setActive(0);
303
324
  }
304
325
  });
305
- const header = wordmark(width, height);
306
- const other = targets.length > 1
307
- ? targets[(targetIndex + 1) % targets.length]
308
- : undefined;
309
- const switchLabel = other
310
- ? `[ Go to ${other.label}${other.error ? ' !' : ''} ]`
311
- : '';
312
- const scopeLabel = width >= 60 ? `${target.label} · ${target.root}` : target.label;
313
- const scopeText = fit(scopeLabel, width - switchLabel.length - 4);
314
- const scopeLines = targets.length > 1 || target.global
315
- ? [
316
- ` ${bold(scopeText)}${' '.repeat(Math.max(2, width - stringWidth(scopeText) - switchLabel.length - 2))}${scopeFocused ? accent(bold(switchLabel)) : muted(switchLabel)}`,
317
- ...(width < 60 ? [` ${muted(fit(target.root, width - 2))}`] : []),
318
- ]
319
- : [];
320
- const selectionWarning = hasSelectionChanges
321
- ? [
322
- ` ${fit(`Unapplied changes in ${target.label}.`, width - 2)}`,
323
- ' Selections stay in this session.',
324
- ]
325
- : [];
326
- if (pendingSwitch !== undefined)
327
- return [
328
- ...header,
329
- ...scopeLines,
330
- '',
331
- ` ${bold(fit(`Switch to ${targets[pendingSwitch].label}?`, width - 2))}`,
332
- ...selectionWarning,
333
- '',
334
- ` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
335
- '\u001b[?25l',
336
- ].join('\n');
326
+ const scopeLabels = targets.map((item, index) => `[${index === targetIndex ? '●' : '○'} ${clean(item.label)}${pendingTargets.includes(item) ? '*' : ''}${item.error || !item.catalog ? ' !' : ''}]`);
327
+ const baseWidth = scopeLabels.reduce((sum, label) => sum + stringWidth(label), 0) +
328
+ (targets.length - 1) * 2;
329
+ const repositoryIndex = targets.findIndex((item) => !item.global);
330
+ const nameWidth = width - 4 - baseWidth - 3;
331
+ if (repositoryIndex >= 0 && nameWidth >= 3) {
332
+ const repository = targets[repositoryIndex];
333
+ const name = fit(path.basename(repository.root) || repository.root, nameWidth);
334
+ scopeLabels[repositoryIndex] = scopeLabels[repositoryIndex].replace(clean(repository.label), () => `${clean(repository.label)} · ${name}`);
335
+ }
336
+ const segments = targets.map((item, index) => {
337
+ const label = scopeLabels[index];
338
+ const colored = scopeColor(item, label);
339
+ return index === targetIndex
340
+ ? bold(colored)
341
+ : pendingTargets.includes(item)
342
+ ? colored
343
+ : muted(colored);
344
+ });
345
+ const selectorWidth = segments.reduce((sum, item) => sum + stringWidth(item), 0) +
346
+ (segments.length - 1) * 2;
347
+ const scopeLines = selectorWidth <= width - 4
348
+ ? [` ${segments.join(' ')}`]
349
+ : segments.map((item) => ` ${item}`);
337
350
  if (finished)
338
351
  return [
339
- ...header,
352
+ ...wordmark(width, height, accent),
340
353
  ` ${accent('✓')} ${selected.length} selected${requiredCount ? muted(` · ${requiredCount} required`) : ''}\n`,
341
354
  ].join('\n');
342
355
  const updates = availableUpdates(catalog).length;
343
356
  const counts = `${selected.length} selected${requiredCount ? ` · ${requiredCount} required` : ''}`;
357
+ const tabLabel = (name) => name === 'Browse' && provider ? `Browse › ${clean(provider)}` : name;
358
+ const inlineProvider = !!provider && sections.map(tabLabel).join(' ').length + 2 <= width - 2;
344
359
  const tabs = width >= 34
345
- ? ` ${sections.map((name) => (name === section && !scopeFocused ? accent(bold(`[${name}]`)) : muted(name))).join(' ')}`
346
- : ` ${scopeFocused ? muted(section) : accent(bold(`[${section}]`))} ${muted('←→/tab')}`;
360
+ ? ` ${sections
361
+ .map((name) => {
362
+ const label = inlineProvider ? tabLabel(name) : name;
363
+ return name === section
364
+ ? accent(bold(`[${label}]`))
365
+ : muted(label);
366
+ })
367
+ .join(' ')}`
368
+ : ` ${accent(bold(`[${section}]`))}`;
347
369
  const filter = ` ${accent('/')} ${query ? `${fit(query, width - 6)}${accent('▏')}` : muted(browsingProviders ? 'Search providers or kits' : 'Search kits')}`;
348
- const rule = ` ${muted(''.repeat(width - 2))}`;
349
- const detailed = height >= 20;
350
- const hints = scopeFocused
351
- ? [
352
- `space/enter ${other?.error || !other?.catalog ? 'details' : 'switch'}`,
353
- '←→/tab move',
354
- 'esc back',
355
- ]
356
- : [
357
- '↑↓ move',
358
- `space/enter ${focused?.action ? 'select' : browsingProviders ? 'open' : 'toggle'}`,
359
- '←→/tab switch',
360
- `esc ${provider ? 'back' : 'clear'}`,
361
- ];
362
- const helpLines = [];
363
- const separator = width >= 100 ? ' ' : ' · ';
370
+ const escapeHint = query ? 'Esc clear' : provider ? 'Esc back' : '';
371
+ const hints = [
372
+ ...(width >= 60 ? ['↑↓ move'] : []),
373
+ focused?.action
374
+ ? 'Enter select'
375
+ : browsingProviders
376
+ ? 'Space open'
377
+ : 'Space toggle',
378
+ targets.length > 1 ? 'Tab switch scope' : '←→ tabs',
379
+ escapeHint,
380
+ ].filter(Boolean);
381
+ let helpText = '';
364
382
  for (const hint of hints) {
365
- const previous = helpLines.at(-1);
366
- const combined = previous ? `${previous}${separator}${hint}` : hint;
367
- if (previous && stringWidth(combined) <= width - 2)
368
- helpLines[helpLines.length - 1] = combined;
369
- else
370
- helpLines.push(fit(hint, width - 2));
371
- }
372
- const help = helpLines.map((line) => ` ${muted(line)}`);
373
- if (scopeFocused && other) {
374
- const action = other.error || !other.catalog
375
- ? `${other.label} is unavailable`
376
- : `Switch to ${other.label}`;
377
- return [
378
- ...header,
379
- ...scopeLines,
380
- '',
381
- tabs,
382
- rule,
383
- ` ${bold(fit(action, width - 2))}`,
384
- ...(other.error ? [` ${fit(other.error, width - 2)}`] : []),
385
- ` ${muted(fit(other.root, width - 2))}`,
386
- rule,
387
- ...help,
388
- '\u001b[?25l',
389
- ].join('\n');
383
+ const combined = helpText ? `${helpText} · ${hint}` : hint;
384
+ if (stringWidth(combined) <= width - 2)
385
+ helpText = combined;
390
386
  }
387
+ const help = [` ${muted(helpText)}`];
391
388
  const beforeList = [
392
- ...header,
393
389
  ...scopeLines,
394
- ...(scopeLines.length && detailed ? [''] : []),
395
390
  tabs,
396
- ...(provider ? [` ${muted(fit(provider, width - 2))}`] : []),
397
- ...(detailed ? [''] : []),
398
- rule,
391
+ ...(provider && !inlineProvider
392
+ ? [` ${muted(fit(provider, width - 2))}`]
393
+ : []),
399
394
  filter,
400
- ...(detailed ? [''] : []),
401
395
  ];
402
396
  const summary = fit(`${counts}${updates ? ` · ${updates} updates` : ''}`, width - 6);
403
- const bottomRule = ` ${muted(`${'─'.repeat(Math.max(1, width - stringWidth(summary) - 6))} ${summary} ─`)}`;
404
- // Border, detail, spacing, keyboard help, and cursor-control line.
405
- const footerHeight = 5 + help.length;
406
- const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) / (detailed ? 2 : 1))));
397
+ const actions = entries.filter((row) => row.action);
398
+ const kit = focused?.kit;
399
+ const why = kit ? reasons(catalog, selected, kit.id) : [];
400
+ const detail = notice ||
401
+ (kit?.ready === false
402
+ ? 'Edit this kit, then set ready: true in kit.yaml'
403
+ : kit && willUninstall(kit.id)
404
+ ? 'Uninstall on apply. Select again to keep.'
405
+ : why.length
406
+ ? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
407
+ : kit?.requires.length
408
+ ? `Requires ${kit.requires.map(displayName).join(', ')}`
409
+ : kit && hasUpdate(kit)
410
+ ? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
411
+ : '');
412
+ // Keep the contextual row allocated so focus changes never move the footer.
413
+ const footerHeight = 3 + actions.length + help.length;
414
+ // Prefer useful list rows over decorative branding on short terminals.
415
+ const header = wordmark(width, height, accent);
416
+ const headerBudget = height - beforeList.length - footerHeight - (height >= 20 ? 8 : 4);
417
+ if (header.length <= headerBudget)
418
+ beforeList.unshift(...header);
419
+ else if (headerBudget >= 1)
420
+ beforeList.unshift(` ${accent(bold('LOADOUT'))}`);
421
+ const spacing = height - beforeList.length - footerHeight >= 8 ? 1 : 0;
422
+ if (spacing)
423
+ beforeList.push('');
424
+ const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) / 2)));
407
425
  const listEntries = entries.filter((row) => !row.action);
408
426
  const listCursor = Math.min(cursor, Math.max(0, listEntries.length - 1));
409
427
  const start = Math.max(0, Math.min(listCursor - Math.floor(pageSize / 2), listEntries.length - pageSize));
@@ -436,28 +454,24 @@ const renderPicker = createPrompt((config, done) => {
436
454
  ? 'Will uninstall'
437
455
  : hasUpdate(row.kit)
438
456
  ? 'update'
439
- : explicit
440
- ? 'selected'
441
- : required
442
- ? 'required'
443
- : row.kit.pinned
444
- ? 'saved'
445
- : '';
457
+ : required
458
+ ? 'required'
459
+ : row.kit.pinned && !explicit
460
+ ? 'saved'
461
+ : '';
446
462
  const marker = !row.kit
447
463
  ? accent('▸')
448
464
  : explicit
449
465
  ? accent('●')
450
466
  : required
451
467
  ? styleText('yellow', '◆')
452
- : muted('○');
468
+ : muted(accent('○'));
453
469
  const badge = required || willUninstall(row.id) || (row.kit && hasUpdate(row.kit))
454
470
  ? styleText('yellow', label)
455
471
  : muted(label);
456
472
  const name = fit(displayName(row.id), width - label.length - 9);
457
473
  const gap = ' '.repeat(Math.max(2, width - stringWidth(name) - label.length - 6));
458
- lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`);
459
- if (detailed)
460
- lines.push(` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
474
+ lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`, ` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
461
475
  }
462
476
  if (!page.length) {
463
477
  const empty = !catalog.kits.size
@@ -465,48 +479,38 @@ const renderPicker = createPrompt((config, done) => {
465
479
  : query
466
480
  ? 'No matching kits. Esc to clear.'
467
481
  : section === 'Kits'
468
- ? 'No repository kits. Tab to browse.'
482
+ ? 'No repository kits. ←→ to browse.'
469
483
  : section === 'Installed'
470
- ? 'No kits installed. Tab to browse.'
484
+ ? 'No kits installed. ←→ to browse.'
471
485
  : 'No external providers configured.';
472
486
  lines.push(` ${muted(fit(empty, width - 2))}`);
473
487
  }
474
- const kit = focused?.kit;
475
- const why = kit ? reasons(catalog, selected, kit.id) : [];
476
- const detail = focused?.action
477
- ? focused.description
478
- : browsingProviders
479
- ? [
480
- focused?.selectedCount ? `${focused.selectedCount} selected` : '',
481
- focused?.downloadedCount
482
- ? `${focused.downloadedCount} downloaded`
483
- : '',
484
- ]
485
- .filter(Boolean)
486
- .join(' · ') || 'Choose a provider to explore its kits'
487
- : kit?.ready === false
488
- ? 'Edit this kit, then set ready: true in kit.yaml'
489
- : kit && willUninstall(kit.id)
490
- ? 'Uninstall on apply. Select again to keep.'
491
- : kit && hasUpdate(kit)
492
- ? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
493
- : why.length
494
- ? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
495
- : kit?.requires.length
496
- ? `Requires ${kit.requires.map(displayName).join(', ')}`
497
- : kit?.external
498
- ? `Includes ${(kit.pinned ?? kit.external).skills.map((p) => p.split('/').at(-1)).join(', ')}`
499
- : '';
500
488
  const pagination = listEntries.length > page.length
501
489
  ? `${start + 1}–${start + page.length} of ${listEntries.length}`
502
490
  : '';
491
+ const status = fit([summary, pagination].filter(Boolean).join(' · '), width - 6);
492
+ const bottomRule = ` ${muted('─'.repeat(Math.max(1, width - stringWidth(status) - 6)))} ${scopeColor(target, status)} ${muted('─')}`;
503
493
  return [
504
494
  ...beforeList,
505
495
  ...lines,
496
+ ` ${muted(fit(detail, width - 2))}`,
506
497
  bottomRule,
507
- ` ${focused?.action ? accent('›') : ' '} ${focused?.action ? accent(bold('[ Continue ]')) : '[ Continue ]'}`,
508
- ` ${muted(fit(notice || [pagination, detail].filter(Boolean).join(' · '), width - 2))}`,
509
- '',
498
+ ...actions.map((row) => {
499
+ const label = row.action === 'back'
500
+ ? width >= 25
501
+ ? 'Back to providers'
502
+ : 'Back'
503
+ : width >= 22
504
+ ? 'Review changes'
505
+ : 'Review';
506
+ const button = fit(`[ ${label} ]`, width - 4);
507
+ const focus = row.id === focused?.id;
508
+ const helperWidth = width - stringWidth(button) - 6;
509
+ const helper = row.description && helperWidth > 0
510
+ ? ` ${muted(fit(row.description, helperWidth))}`
511
+ : '';
512
+ return ` ${focus ? accent('›') : ' '} ${focus ? accent(bold(button)) : button}${helper}`;
513
+ }),
510
514
  ...help,
511
515
  '\u001b[?25l',
512
516
  ].join('\n');
@@ -0,0 +1,10 @@
1
+ import { type PromptContext } from './interactive.js';
2
+ import { type ReviewTarget, type PreparedTarget } from './review.js';
3
+ type Progress = (scope: number, message: string) => void;
4
+ type Retry = (scope: number, id: string, error: Error) => Promise<boolean>;
5
+ type Config = {
6
+ selections: ReviewTarget[];
7
+ run: (progress: Progress, retry: Retry, signal: AbortSignal) => Promise<PreparedTarget[]>;
8
+ };
9
+ export declare function prepareScreen(config: Config, context?: PromptContext): Promise<PreparedTarget[]>;
10
+ export {};