@lidtop/loadout 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -13,7 +13,11 @@ npm install -g @lidtop/loadout
13
13
  loadout
14
14
  ```
15
15
 
16
- Pick your kits, select **Continue**, and confirm. Run `loadout` again to change your selection.
16
+ Pick your kits, select **Review changes**, and confirm. Inside a provider, choose **Back to providers** to keep browsing with your selections preserved. Run `loadout` again to change your selection.
17
+
18
+ The destination selector above the tabs shows **Repository** in cyan (this repository only) and **Global** in magenta (your user configuration across repositories). Active controls and selection markers follow the destination's color. Press **Tab** to switch destinations instantly, or **Shift+Tab** to cycle backward. **←/→** switches between the Kits, Browse, and Installed sections. Each destination keeps its own selections, provider, search, and cursor position, ready to browse as soon as you switch. A **\*** marks destinations with unapplied selections; **Review changes** includes the session's changes across both. Descriptions appear beneath every provider and kit, with dependency or update notes when needed.
19
+
20
+ Press **Esc** at a setup question or review prompt to return to the picker with your selections and position preserved. Press **Esc twice quickly** or **Ctrl+C** to cancel setup.
17
21
 
18
22
  Conflicting project instructions are skipped with a warning; the kit’s skills still install.
19
23
 
package/dist/cli.js CHANGED
@@ -134,7 +134,9 @@ async function generate(catalog, state, opts) {
134
134
  }
135
135
  async function setup() {
136
136
  const { targets, initial } = interactiveTargets();
137
- const configured = await interactive(targets, initial);
137
+ await interactive(targets, initial, { review: reviewSelections });
138
+ }
139
+ async function reviewSelections(configured) {
138
140
  const offline = program.opts().offline;
139
141
  const plans = [];
140
142
  for (const { target, state } of configured) {
@@ -165,7 +167,10 @@ async function setup() {
165
167
  }
166
168
  if (!plans.some(hasChanges))
167
169
  return;
168
- if (await confirmApply()) {
170
+ const scopes = configured
171
+ .filter((_, index) => hasChanges(plans[index]))
172
+ .map(({ target }) => target.label);
173
+ if (await confirmApply(undefined, scopes)) {
169
174
  applyAll(plans);
170
175
  console.log('\nYour loadout is ready.');
171
176
  for (const result of plans) {
@@ -2,9 +2,14 @@ import { checkbox, confirm, select } from '@inquirer/prompts';
2
2
  import { type Catalog, type State } from './schema.js';
3
3
  import { type TargetSelection } from './picker.js';
4
4
  import { type Target } from './targets.js';
5
- export declare function interactive(targets: Target[], initial?: number): Promise<TargetSelection[]>;
5
+ type PromptContext = NonNullable<Parameters<typeof confirm>[1]>;
6
+ export declare function interactive(targets: Target[], initial?: number, options?: {
7
+ context?: PromptContext;
8
+ review?: (selections: TargetSelection[], context?: PromptContext) => Promise<void>;
9
+ }): Promise<TargetSelection[]>;
6
10
  export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1]): Promise<State>;
7
- export declare function confirmApply(): Promise<boolean>;
11
+ export declare function confirmApply(context?: PromptContext, scopes?: string[]): Promise<boolean>;
8
12
  export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1]): Promise<string[]>;
9
13
  export declare function confirmRetry(id: string, error: Error, context?: Parameters<typeof select>[1]): Promise<boolean>;
10
14
  export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1]): Promise<boolean>;
15
+ export {};
@@ -1,21 +1,57 @@
1
1
  import { checkbox, confirm, select } from '@inquirer/prompts';
2
2
  import { configure } from './resolve.js';
3
3
  import { validAnswer } from './schema.js';
4
- import { targetPicker } from './picker.js';
4
+ import { targetPicker, } from './picker.js';
5
5
  import { availableUpdates, updateDescription } from './updates.js';
6
- export async function interactive(targets, initial = 0) {
7
- if (!process.stdin.isTTY || !process.stdout.isTTY)
6
+ import { prepareInput } from './terminal.js';
7
+ class BackToPicker extends Error {
8
+ }
9
+ async function backPrompt(prompt, context = {}) {
10
+ const input = context.input ?? process.stdin;
11
+ prepareInput(input);
12
+ const controller = new AbortController();
13
+ const back = (_text, key) => {
14
+ if (key.name === 'escape')
15
+ controller.abort(new BackToPicker());
16
+ };
17
+ input.on('keypress', back);
18
+ try {
19
+ return await prompt({
20
+ ...context,
21
+ signal: context.signal
22
+ ? AbortSignal.any([context.signal, controller.signal])
23
+ : controller.signal,
24
+ });
25
+ }
26
+ catch (error) {
27
+ if (error instanceof Error && error.cause instanceof BackToPicker)
28
+ throw error.cause;
29
+ throw error;
30
+ }
31
+ finally {
32
+ input.off('keypress', back);
33
+ }
34
+ }
35
+ export async function interactive(targets, initial = 0, options = {}) {
36
+ if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
8
37
  throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
9
- const chosen = await targetPicker({
10
- targets,
11
- initial,
12
- });
13
- const configured = [];
14
- for (const { target, state } of chosen) {
15
- const value = await configureSelection(target, state);
16
- configured.push({ target, state: value });
38
+ const session = {};
39
+ for (;;) {
40
+ const chosen = await targetPicker({ targets, initial, session }, options.context);
41
+ try {
42
+ const configured = [];
43
+ for (const { target, state } of chosen) {
44
+ const value = await configureSelection(target, state, options.context);
45
+ configured.push({ target, state: value });
46
+ }
47
+ await options.review?.(configured, options.context);
48
+ return configured;
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof BackToPicker))
52
+ throw error;
53
+ }
17
54
  }
18
- return configured;
19
55
  }
20
56
  export async function configureSelection(target, state, context) {
21
57
  const change = new Map();
@@ -23,10 +59,10 @@ export async function configureSelection(target, state, context) {
23
59
  if (!change.has(kit)) {
24
60
  const hasSaved = Object.entries(target.catalog.kits.get(kit).questions).some(([name, q]) => validAnswer(q, state.answers[kit]?.[name]));
25
61
  change.set(kit, hasSaved
26
- ? await confirm({
62
+ ? await backPrompt((context) => confirm({
27
63
  message: `${target.label} · ${kit} · Change selection?`,
28
64
  default: false,
29
- }, context)
65
+ }, context), context)
30
66
  : true);
31
67
  }
32
68
  const saved = state.answers[kit]?.[key];
@@ -34,16 +70,21 @@ export async function configureSelection(target, state, context) {
34
70
  return saved;
35
71
  const message = `${target.label} · ${kit} · ${question.message}`;
36
72
  if (question.type === 'boolean')
37
- return confirm({ message, default: typeof value === 'boolean' ? value : false }, context);
38
- return select({
73
+ return backPrompt((context) => confirm({ message, default: typeof value === 'boolean' ? value : false }, context), context);
74
+ return backPrompt((context) => select({
39
75
  message,
40
76
  choices: question.choices.map((v) => ({ name: v, value: v })),
41
77
  default: typeof value === 'string' ? value : undefined,
42
- }, context);
78
+ }, context), context);
43
79
  });
44
80
  }
45
- export async function confirmApply() {
46
- return confirm({ message: 'Apply changes?', default: true });
81
+ export async function confirmApply(context, scopes = []) {
82
+ return backPrompt((context) => confirm({
83
+ message: scopes.length
84
+ ? `Apply changes to ${scopes.join(' and ')}?`
85
+ : 'Apply changes?',
86
+ default: true,
87
+ }, context), context);
47
88
  }
48
89
  export async function selectUpdates(catalog, state, offline = false, context) {
49
90
  const updates = availableUpdates(catalog, state.selected);
@@ -53,7 +94,7 @@ export async function selectUpdates(catalog, state, offline = false, context) {
53
94
  console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
54
95
  return [];
55
96
  }
56
- return checkbox({
97
+ return backPrompt((context) => checkbox({
57
98
  message: 'Catalog updates available · choose kits to update (Enter skips)',
58
99
  choices: updates.map((kit) => ({
59
100
  name: kit.id,
@@ -61,20 +102,20 @@ export async function selectUpdates(catalog, state, offline = false, context) {
61
102
  description: updateDescription(kit),
62
103
  })),
63
104
  required: false,
64
- }, context);
105
+ }, context), context);
65
106
  }
66
107
  export async function confirmRetry(id, error, context) {
67
- return select({
108
+ return backPrompt((context) => select({
68
109
  message: `${id}: ${error.message}`,
69
110
  choices: [
70
111
  { name: 'Retry download', value: true },
71
112
  { name: 'Cancel', value: false },
72
113
  ],
73
- }, context);
114
+ }, context), context);
74
115
  }
75
116
  export async function confirmAdoption(paths, context) {
76
- return confirm({
117
+ return backPrompt((context) => confirm({
77
118
  message: `Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
78
119
  default: true,
79
- }, context);
120
+ }, context), context);
80
121
  }
package/dist/picker.d.ts CHANGED
@@ -6,11 +6,32 @@ export type PickerConfig = {
6
6
  columns?: number;
7
7
  rows?: number;
8
8
  };
9
+ declare const repositorySections: readonly ['Kits', 'Browse', 'Installed'];
10
+ type Section = (typeof repositorySections)[number];
9
11
  export type TargetPickerConfig = {
10
12
  targets: Target[];
11
13
  initial?: number;
12
14
  columns?: number;
13
15
  rows?: number;
16
+ session?: PickerSession;
17
+ };
18
+ type PickerView = {
19
+ section: Section;
20
+ provider: string | undefined;
21
+ providerList: {
22
+ query: string;
23
+ active: number;
24
+ };
25
+ query: string;
26
+ active: number;
27
+ };
28
+ export type PickerSession = {
29
+ snapshot?: PickerView & {
30
+ targetIndex: number;
31
+ visited: number[];
32
+ selections: string[][];
33
+ views: PickerView[];
34
+ };
14
35
  };
15
36
  export type TargetSelection = {
16
37
  target: Target;
@@ -21,6 +42,7 @@ declare const renderPicker: import("@inquirer/type").Prompt<TargetSelection[], {
21
42
  initial?: number;
22
43
  columns?: number;
23
44
  rows?: number;
45
+ session?: PickerSession;
24
46
  } & TargetPickerConfig>;
25
47
  export declare function targetPicker(config: TargetPickerConfig, context?: Parameters<typeof renderPicker>[1]): Promise<TargetSelection[]>;
26
48
  export declare function kitPicker(config: PickerConfig, context?: Parameters<typeof targetPicker>[1]): Promise<string[]>;
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 @@
1
+ Do not commit changes or perform Git operations. Leave Git to the user.
@@ -0,0 +1,6 @@
1
+ schemaVersion: 1
2
+ id: dont-commit
3
+ description: Leave commits and Git operations to the user
4
+ outputs:
5
+ - type: instructions
6
+ source: instructions.md
@@ -5,6 +5,8 @@ description: Create or edit private or shared Loadout kits.
5
5
 
6
6
  Use `.loadout-personal/kits/` for private kits, `.loadout/kits/` for shared kits, or `~/.loadout/kits/` for kits you can enable in any repository. Adapt this example; drop unused questions, outputs, and files.
7
7
 
8
+ State each requirement once as a direct instruction, including its exceptions. Preserve its exact trigger and scope. Omit introductions, summaries, explanations of familiar concepts, and lists of actions a rule already covers. Do not add unrequested rules or steps.
9
+
8
10
  `.loadout-personal/kits/review/kit.yaml`:
9
11
 
10
12
  ```yaml
@@ -52,4 +54,4 @@ loadout enable review --answer review.diagrams=true --answer review.detail=detai
52
54
  loadout disable review
53
55
  ```
54
56
 
55
- Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Continue.
57
+ Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Review changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lidtop/loadout",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Choose and manage agent instruction and skill kits for repositories and your home directory.",
5
5
  "type": "module",
6
6
  "bin": {