@iseoane/agent-depot 0.6.0 → 0.6.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.
@@ -2,17 +2,21 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { Box, Text, useWindowSize } from "ink";
4
4
  import stringWidth from "string-width";
5
- import { useEffect, useMemo, useRef, useState } from "react";
5
+ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import { createAppOperations } from "../app-flow.js";
7
7
  import { buildProfileExport, describeProfileExportExclusion, describeProfilePackage, describeProfileReplica, describeProfileSkill, profileExportPreview, SKILL_EXPORT_EXPLANATION, writeProfileExport, } from "../profile-export.js";
8
8
  import { applyProfileImport, buildProfileImport, } from "../profile-import.js";
9
9
  import { PROFILE_FORMAT, PROFILE_FORMAT_V2, parseProfile } from "../profile.js";
10
10
  import { selectionKey } from "../package-model.js";
11
11
  import { packageOperationsOf } from "./environment.js";
12
+ import { DEFAULT_PROFILE_FILE_NAME, exportDestination, fileNameNotice, pathExists, pickerStartDirectory, resolveExportFileName, } from "./file-picker-model.js";
13
+ import { FilePicker } from "./file-picker.js";
12
14
  import { useKeys } from "./keys.js";
13
15
  import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
14
16
  import { BorderPanel, PreviewConfirm, PreviewLines } from "./panel-parts.js";
17
+ import { fitLines, wrapText } from "./text-wrap.js";
15
18
  import { theme } from "./theme.js";
19
+ import { useMirrored, useMounted } from "./view-state.js";
16
20
  import { computeWindow, useListHeight } from "./window.js";
17
21
  function rowsFromExport(plan) {
18
22
  return [
@@ -67,41 +71,6 @@ function exportDisplayRows(rows, expanded) {
67
71
  ...group("group:excluded", `Export notes (${choices("excluded").length})`, 0, choices("excluded")),
68
72
  ];
69
73
  }
70
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
71
- function wrapListText(value, width) {
72
- const lines = [];
73
- let line = "";
74
- let lineWidth = 0;
75
- for (const { segment } of graphemes.segment(value)) {
76
- const segmentWidth = stringWidth(segment);
77
- if (line && lineWidth + segmentWidth > width) {
78
- lines.push(line);
79
- line = "";
80
- lineWidth = 0;
81
- }
82
- line += segment;
83
- lineWidth += segmentWidth;
84
- }
85
- return [...lines, line];
86
- }
87
- function fitListLines(lines, maxRows, width) {
88
- if (maxRows <= 0)
89
- return [];
90
- if (lines.length <= maxRows)
91
- return lines;
92
- const visible = lines.slice(0, maxRows);
93
- let last = "";
94
- let used = 0;
95
- for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
96
- const segmentWidth = stringWidth(segment);
97
- if (used + segmentWidth > width - 1)
98
- break;
99
- last += segment;
100
- used += segmentWidth;
101
- }
102
- visible[maxRows - 1] = `${last}…`;
103
- return visible;
104
- }
105
74
  function physicalListWindow(rows, rowHeights, selected, height) {
106
75
  if (!rows.length)
107
76
  return { start: 0, end: 0, indicator: undefined, stickyGroup: false };
@@ -171,68 +140,49 @@ function selectedImportProfile(profile, entries) {
171
140
  return parseProfile({ ...profile, format: replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT,
172
141
  sources, skills, apps, packages, replicas });
173
142
  }
174
- /** A plan whose items are the chosen subset, split back into the block the writer consumes. */
175
- function chosenPlan(plan, selected) {
176
- const entries = selectedPlanEntries(plan, selected);
177
- return {
178
- ...plan,
179
- items: entries.filter((item) => item.block !== "packages"),
180
- packages: entries.filter((item) => item.block === "packages"),
181
- };
182
- }
183
143
  /** Collect choices, display shared-core previews and confirm portable Profile transfers. */
184
- export function ProfileView({ operations, environment = {}, onCapturingChange, listHeight }) {
144
+ export function ProfileView({ operations, environment = {}, onCapturingChange, listHeight, existsCheck = pathExists }) {
185
145
  const apps = useMemo(() => createAppOperations({
186
146
  homeDirectory: environment.homeDirectory, ...environment.appEnvironment,
187
147
  }), [environment]);
188
148
  const packages = useMemo(() => packageOperationsOf(environment), [environment]);
189
- const [mode, setModeState] = useState("browse");
190
- const modeRef = useRef("browse");
191
- const mounted = useRef(true);
192
- useEffect(() => {
193
- mounted.current = true;
194
- return () => { mounted.current = false; };
195
- }, []);
196
- const setMode = (next) => {
197
- modeRef.current = next;
149
+ const startDirectory = useMemo(() => pickerStartDirectory(environment), [environment]);
150
+ const mounted = useMounted();
151
+ const [mode, modeRef, setModeState] = useMirrored("browse");
152
+ const setMode = useCallback((next) => {
198
153
  onCapturingChange?.(next !== "browse" && next !== "results");
199
154
  setModeState(next);
200
- };
155
+ }, [onCapturingChange, setModeState]);
201
156
  const [rows, setRows] = useState([]);
202
- const [expandedGroups, setExpandedGroupsState] = useState(new Set());
203
- const expandedGroupsRef = useRef(new Set());
204
- const setExpandedGroups = (next) => {
205
- expandedGroupsRef.current = next;
206
- setExpandedGroupsState(next);
207
- };
208
- const [selected, setSelectedState] = useState([]);
209
- const selectedRef = useRef([]);
210
- const select = (next) => {
211
- selectedRef.current = next;
212
- setSelectedState(next);
213
- };
214
- const [cursor, setCursorState] = useState(0);
215
- const cursorRef = useRef(0);
216
- const cursorTo = (next) => {
217
- cursorRef.current = next;
218
- setCursorState(next);
219
- };
220
- const [text, setText] = useState("");
221
- const typed = useRef("");
157
+ const [expandedGroups, expandedGroupsRef, setExpandedGroups] = useMirrored(new Set());
158
+ const [selected, selectedRef, select] = useMirrored([]);
159
+ const [cursor, cursorRef, cursorTo] = useMirrored(0);
160
+ const [text, typed, setText] = useMirrored("");
161
+ const [nameError, setNameError] = useState(undefined);
162
+ const [importNotice, setImportNotice] = useState(undefined);
163
+ const [exportDirectory, setExportDirectory] = useState(undefined);
164
+ const [importDirectory, setImportDirectory] = useState(undefined);
222
165
  const [lines, setLines] = useState([]);
223
166
  const detailReturnId = useRef(undefined);
167
+ const confirmRequest = useRef(undefined);
168
+ const nameRequest = useRef(0);
169
+ const importRequest = useRef(0);
224
170
  const [profile, setProfile] = useState();
225
171
  const [plan, setPlan] = useState();
226
172
  const diagnostics = useRef([]);
227
- const action = useRef("export");
228
173
  const { columns: terminalColumns } = useWindowSize();
229
174
  const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
175
+ const hintCursorRow = (mode === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows)[cursorRef.current];
176
+ const enterHint = hintCursorRow === undefined ? undefined
177
+ : "kind" in hintCursorRow ? "Enter open"
178
+ : hintCursorRow.block === "excluded" ? "Enter note" : undefined;
179
+ const groupHint = hintCursorRow !== undefined && "kind" in hintCursorRow ? " · ←/→ group" : "";
230
180
  const listingHint = mode === "export-list"
231
- ? "j/k · Enter group/preview · ←/→ group · space · a all · Esc"
232
- : "j/k move · space toggle · a all · Enter preview · Esc";
233
- const renderedListingHint = mode === "export-list" ? `${listingHint} · preview shows full text` : listingHint;
181
+ ? `j/k move${groupHint}${enterHint === undefined ? "" : ` · ${enterHint}`} · space toggle · a all · e export · Esc back to menu`
182
+ : "j/k move · space toggle · a all · Enter preview · Esc back to menu";
183
+ const hintText = `${selected.length} selected · ${listingHint}`;
234
184
  const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
235
- const hintRows = Math.max(Math.ceil(stringWidth(renderedListingHint) / panelWidth), 1);
185
+ const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
236
186
  const height = useListHeight(listHeight, 4 + titleRows + hintRows);
237
187
  const showLines = (next, nextMode) => {
238
188
  setLines(next);
@@ -250,11 +200,6 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
250
200
  }
251
201
  }
252
202
  };
253
- const prompt = (next) => {
254
- typed.current = "";
255
- setText("");
256
- setMode(next);
257
- };
258
203
  const loadRows = (next, nextMode) => {
259
204
  setRows(next);
260
205
  select(next.filter(row => row.selectable));
@@ -286,13 +231,122 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
286
231
  }),
287
232
  };
288
233
  };
234
+ const beginExport = () => {
235
+ if (!hasChosenResource(chosenProfile())) {
236
+ showLines(["Nothing selected; nothing written."], "results");
237
+ return;
238
+ }
239
+ setExportDirectory(undefined);
240
+ setNameError(undefined);
241
+ setMode("export-folder");
242
+ };
243
+ const abandonNameCheck = () => { nameRequest.current += 1; };
244
+ const chooseExportDirectory = (directory) => {
245
+ abandonNameCheck();
246
+ setExportDirectory(directory);
247
+ setNameError(undefined);
248
+ if (typed.current === "")
249
+ setText(DEFAULT_PROFILE_FILE_NAME);
250
+ setMode("export-name");
251
+ };
252
+ const submitExportName = () => {
253
+ const outcome = resolveExportFileName(typed.current);
254
+ if (outcome.kind === "invalid") {
255
+ setNameError(outcome.message);
256
+ return;
257
+ }
258
+ const directory = exportDirectory ?? startDirectory;
259
+ const fileName = outcome.fileName;
260
+ const finalPath = exportDestination(directory, fileName);
261
+ const id = ++nameRequest.current;
262
+ void (async () => {
263
+ let exists;
264
+ try {
265
+ exists = await existsCheck(finalPath);
266
+ }
267
+ catch (error) {
268
+ if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
269
+ return;
270
+ setNameError(`Error: ${error instanceof Error ? error.message : String(error)}`);
271
+ return;
272
+ }
273
+ const current = resolveExportFileName(typed.current);
274
+ if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
275
+ return;
276
+ if (current.kind !== "ok" || current.fileName !== fileName)
277
+ return;
278
+ if (exists) {
279
+ setNameError(`${finalPath} already exists; choose another name`);
280
+ return;
281
+ }
282
+ confirmRequest.current = { kind: "export", file: finalPath };
283
+ setNameError(undefined);
284
+ showLines([`Destination: ${finalPath}`, ...profileExportPreview(chosenProfile(), diagnostics.current)], "confirm");
285
+ })();
286
+ };
287
+ const confirmApply = () => {
288
+ const request = confirmRequest.current;
289
+ if (request === undefined)
290
+ return;
291
+ void run(async () => {
292
+ let result;
293
+ if (request.kind === "export") {
294
+ await writeProfileExport(request.file, chosenProfile());
295
+ result = [`Exported profile to ${request.file}`];
296
+ }
297
+ else {
298
+ const outcomes = await applyProfileImport(request.plan, operations, apps, true, () => undefined, { homeDirectory: environment.homeDirectory, packageOperations: packages, ...environment.installationOptions });
299
+ result = outcomes.map(outcome => `${outcome.status}: ${outcome.item.label}${outcome.detail ? `; ${outcome.detail}` : ""}`);
300
+ }
301
+ if (mounted.current) {
302
+ showLines(result.length ? result : ["Nothing selected; nothing written."], "results");
303
+ }
304
+ });
305
+ };
306
+ const previewImport = () => {
307
+ void run(async () => {
308
+ // Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
309
+ const entries = selectedPlanEntries(plan, selectedRef.current);
310
+ const subset = selectedImportProfile(profile, entries);
311
+ const imported = await buildProfileImport(subset, operations, apps, {}, packages);
312
+ if (!mounted.current)
313
+ return;
314
+ confirmRequest.current = { kind: "import", plan: imported };
315
+ showLines(imported.preview, "confirm");
316
+ });
317
+ };
318
+ const chooseImportFile = (selection) => {
319
+ if (selection.kind !== "file")
320
+ return;
321
+ setImportDirectory(selection.directory);
322
+ const id = ++importRequest.current;
323
+ setMode("busy");
324
+ void (async () => {
325
+ try {
326
+ const importedProfile = parseProfile(JSON.parse(await readFile(selection.file, "utf8")));
327
+ const imported = await buildProfileImport(importedProfile, operations, apps, {}, packages);
328
+ if (!mounted.current || importRequest.current !== id)
329
+ return;
330
+ setProfile(importedProfile);
331
+ setPlan(imported);
332
+ setImportNotice(undefined);
333
+ loadRows(rowsFromPlan(imported), "import-list");
334
+ }
335
+ catch (error) {
336
+ if (!mounted.current || importRequest.current !== id)
337
+ return;
338
+ setImportNotice(`Error: ${error instanceof Error ? error.message : String(error)}`);
339
+ setMode("import-browse");
340
+ }
341
+ })();
342
+ };
289
343
  useKeys((input, key) => {
290
344
  const current = modeRef.current;
291
- if (current === "busy")
345
+ // The FilePicker owns its keys while it is mounted, and async work ignores every key.
346
+ if (current === "export-folder" || current === "import-browse" || current === "busy")
292
347
  return;
293
348
  if (current === "browse" || current === "results") {
294
349
  if (input === "e") {
295
- action.current = "export";
296
350
  void run(async () => {
297
351
  const exported = await buildProfileExport(operations, apps, {
298
352
  homeDirectory: environment.homeDirectory, packageOperations: packages,
@@ -305,8 +359,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
305
359
  });
306
360
  }
307
361
  else if (input === "i") {
308
- action.current = "import";
309
- prompt("import-path");
362
+ setImportNotice(undefined);
363
+ setMode("import-browse");
310
364
  }
311
365
  else if (current === "results") {
312
366
  const next = moveCursor(cursorRef.current, lines.length, input, key);
@@ -315,35 +369,32 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
315
369
  }
316
370
  return;
317
371
  }
318
- if (current === "export-path" || current === "import-path") {
372
+ if (current === "export-name") {
319
373
  if (key.escape) {
320
- setMode("browse");
374
+ abandonNameCheck();
375
+ setMode("export-folder");
376
+ return;
377
+ }
378
+ if (key.ctrl && input === "u") {
379
+ abandonNameCheck();
380
+ setText("");
381
+ setNameError(undefined);
321
382
  return;
322
383
  }
323
384
  if (key.return) {
324
- if (!typed.current.trim())
325
- return;
326
- if (current === "export-path") {
327
- showLines(profileExportPreview(chosenProfile(), diagnostics.current), "confirm");
328
- }
329
- else
330
- void run(async () => {
331
- const importedProfile = parseProfile(JSON.parse(await readFile(typed.current, "utf8")));
332
- const imported = await buildProfileImport(importedProfile, operations, apps, {}, packages);
333
- if (!mounted.current)
334
- return;
335
- setProfile(importedProfile);
336
- setPlan(imported);
337
- loadRows(rowsFromPlan(imported), "import-list");
338
- });
385
+ submitExportName();
386
+ return;
339
387
  }
340
- else if (key.backspace || key.delete) {
341
- typed.current = typed.current.slice(0, -1);
342
- setText(typed.current);
388
+ if (key.backspace || key.delete) {
389
+ abandonNameCheck();
390
+ setText(typed.current.slice(0, -1));
391
+ setNameError(undefined);
392
+ return;
343
393
  }
344
- else if (input && !key.ctrl && !key.meta) {
345
- typed.current += input;
346
- setText(typed.current);
394
+ if (input && !key.ctrl && !key.meta) {
395
+ abandonNameCheck();
396
+ setText(typed.current + input);
397
+ setNameError(undefined);
347
398
  }
348
399
  return;
349
400
  }
@@ -362,23 +413,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
362
413
  return;
363
414
  }
364
415
  if (current === "confirm") {
365
- answerYesNo(input, key, () => {
366
- void run(async () => {
367
- let result;
368
- if (action.current === "export") {
369
- await writeProfileExport(typed.current, chosenProfile());
370
- result = [`Exported profile to ${typed.current}`];
371
- }
372
- else {
373
- const confirmed = chosenPlan(plan, selectedRef.current);
374
- const outcomes = await applyProfileImport(confirmed, operations, apps, true, () => undefined, { homeDirectory: environment.homeDirectory, packageOperations: packages, ...environment.installationOptions });
375
- result = outcomes.map(outcome => `${outcome.status}: ${outcome.item.label}${outcome.detail ? `; ${outcome.detail}` : ""}`);
376
- }
377
- if (mounted.current) {
378
- showLines(result.length ? result : ["Nothing selected; nothing written."], "results");
379
- }
380
- });
381
- }, () => setMode("browse"));
416
+ answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
382
417
  if (input === "y" || input === "n" || key.escape)
383
418
  return;
384
419
  const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
@@ -390,56 +425,52 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
390
425
  setMode("browse");
391
426
  return;
392
427
  }
428
+ // export-list and import-list
393
429
  const visibleRows = current === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows;
394
430
  const currentRow = visibleRows[cursorRef.current];
395
431
  const next = moveCursor(cursorRef.current, visibleRows.length, input, key);
396
- if (next !== undefined)
432
+ if (next !== undefined) {
397
433
  cursorTo(Math.max(0, next));
398
- else if (current === "export-list" && currentRow && "kind" in currentRow && currentRow.kind === "group" &&
399
- (key.return || key.rightArrow || key.leftArrow)) {
400
- if (key.return && !hasChosenResource(chosenProfile())) {
401
- showLines(["Nothing selected; nothing written."], "results");
402
- return;
403
- }
404
- const open = new Set(expandedGroupsRef.current);
405
- if (key.return) {
406
- if (open.has(currentRow.id))
407
- open.delete(currentRow.id);
408
- else
409
- open.add(currentRow.id);
410
- }
411
- else if (key.rightArrow)
412
- open.add(currentRow.id);
413
- else
414
- open.delete(currentRow.id);
415
- setExpandedGroups(open);
434
+ return;
416
435
  }
417
- else if (input === " " && currentRow && !("kind" in currentRow) && currentRow.selectable) {
418
- select(toggleChoice(rows, selectedRef.current, currentRow));
436
+ if (input === " ") {
437
+ if (currentRow && !("kind" in currentRow) && currentRow.selectable)
438
+ select(toggleChoice(rows, selectedRef.current, currentRow));
439
+ return;
419
440
  }
420
- else if (input === "a")
441
+ if (input === "a") {
421
442
  select(selectedRef.current.length ? [] : rows.filter(row => row.selectable));
422
- else if (current === "export-list" && key.return && currentRow && !("kind" in currentRow) && currentRow.block === "excluded") {
423
- detailReturnId.current = currentRow.id;
424
- showLines(wrapListText(currentRow.label, Math.max((terminalColumns || 80) - 8, 1)), "detail");
443
+ return;
444
+ }
445
+ if (current === "export-list" && input === "e") {
446
+ beginExport();
447
+ return;
425
448
  }
426
- else if (key.return) {
427
- if (current === "export-list") {
428
- if (!hasChosenResource(chosenProfile())) {
429
- showLines(["Nothing selected; nothing written."], "results");
449
+ if (currentRow && "kind" in currentRow && currentRow.kind === "group") {
450
+ if (key.return || key.rightArrow || key.leftArrow) {
451
+ const open = new Set(expandedGroupsRef.current);
452
+ if (key.return) {
453
+ if (open.has(currentRow.id))
454
+ open.delete(currentRow.id);
455
+ else
456
+ open.add(currentRow.id);
430
457
  }
458
+ else if (key.rightArrow)
459
+ open.add(currentRow.id);
431
460
  else
432
- prompt("export-path");
461
+ open.delete(currentRow.id);
462
+ setExpandedGroups(open);
433
463
  }
434
- else
435
- void run(async () => {
436
- // Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
437
- const entries = selectedPlanEntries(plan, selectedRef.current);
438
- const subset = selectedImportProfile(profile, entries);
439
- const preview = await buildProfileImport(subset, operations, apps, {}, packages);
440
- if (mounted.current)
441
- showLines(preview.preview, "confirm");
442
- });
464
+ return;
465
+ }
466
+ if (key.return && currentRow && !("kind" in currentRow) && currentRow.block === "excluded") {
467
+ detailReturnId.current = currentRow.id;
468
+ showLines(wrapText(currentRow.label, Math.max((terminalColumns || 80) - 8, 1)), "detail");
469
+ return;
470
+ }
471
+ if (key.return && current === "import-list") {
472
+ previewImport();
473
+ return;
443
474
  }
444
475
  });
445
476
  const listing = mode === "export-list" || mode === "import-list";
@@ -449,7 +480,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
449
480
  : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
450
481
  const label = !("kind" in row) && row.block !== "excluded" && !row.selectable
451
482
  ? `${row.label} (not selectable)` : row.label;
452
- return wrapListText(label, Math.max(panelWidth - prefixWidth, 1)).length;
483
+ return wrapText(label, Math.max(panelWidth - prefixWidth, 1)).length;
453
484
  });
454
485
  const listWindow = physicalListWindow(displayRows, rowHeights, cursor, height);
455
486
  const window = listing ? listWindow : computeWindow(lines.length, cursor, height);
@@ -466,14 +497,14 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
466
497
  const open = expandedGroups.has(row.id);
467
498
  const prefix = `${marker}${" ".repeat(row.depth * 2)}${open ? "▾" : "▸"} `;
468
499
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
469
- const label = fitListLines(wrapListText(row.label, width), remainingRows, width)
500
+ const label = fitLines(wrapText(row.label, width), remainingRows, width)
470
501
  .join(`\n${" ".repeat(stringWidth(prefix))}`);
471
502
  remainingRows -= label.split("\n").length;
472
503
  return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, label] }, row.id);
473
504
  }
474
505
  if (row.block === "excluded") {
475
506
  const width = Math.max(panelWidth - stringWidth(marker), 1);
476
- const label = fitListLines(wrapListText(row.label, width), remainingRows, width)
507
+ const label = fitLines(wrapText(row.label, width), remainingRows, width)
477
508
  .join(`\n${" ".repeat(stringWidth(marker))}`);
478
509
  remainingRows -= label.split("\n").length;
479
510
  return _jsxs(Text, { color: theme.value, children: [marker, label] }, row.id);
@@ -481,10 +512,18 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
481
512
  const prefix = `${marker}[${selected.some(choice => choice.id === row.id) ? "x" : " "}] ${row.ordinal ?? row.index + 1} `;
482
513
  const value = `${row.label}${row.selectable ? "" : " (not selectable)"}`;
483
514
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
484
- const label = fitListLines(wrapListText(value, width), remainingRows, width)
515
+ const label = fitLines(wrapText(value, width), remainingRows, width)
485
516
  .join(`\n${" ".repeat(stringWidth(prefix))}`);
486
517
  remainingRows -= label.split("\n").length;
487
518
  return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: prefix }), _jsx(Text, { color: theme.value, children: label })] }, row.id);
488
519
  });
489
- return _jsxs(Box, { flexDirection: "column", children: [listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: renderedListingHint }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode !== "confirm" && mode !== "detail" && !listing ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, question: "Apply Profile? (y/n) \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
520
+ const nameOutcome = resolveExportFileName(text);
521
+ const liveDestination = nameOutcome.kind === "ok"
522
+ ? exportDestination(exportDirectory ?? startDirectory, nameOutcome.fileName) : undefined;
523
+ const nameNote = nameOutcome.kind === "ok" ? fileNameNotice(nameOutcome.fileName) : undefined;
524
+ return _jsxs(Box, { flexDirection: "column", children: [mode === "export-folder" ? _jsx(FilePicker, { purpose: "directory", listHeight: listHeight, startDirectory: exportDirectory ?? startDirectory, onCancel: () => setMode("export-list"), onChoose: selection => { if (selection.kind === "directory")
525
+ chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Folder: " }), _jsx(Text, { color: theme.value, children: exportDirectory ?? startDirectory })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Name: " }), _jsxs(Text, { color: theme.value, children: [text, "_"] })] }), liveDestination === undefined ? null : _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Destination: " }), _jsx(Text, { color: theme.value, children: liveDestination })] }), nameNote === undefined ? null : _jsx(Text, { color: theme.warning, children: nameNote }), nameOutcome.kind === "ok" && nameOutcome.appendedExtension
526
+ ? _jsxs(Text, { color: theme.muted, children: ["A name without an extension is saved as ", nameOutcome.fileName, "."] }) : null, nameError === undefined ? null : _jsx(Text, { color: theme.error, children: nameError }), _jsx(Text, { color: theme.legend, children: "Type to edit \u00B7 Ctrl+U clear \u00B7 Enter continue \u00B7 Esc back to folder" })] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, question: confirmRequest.current?.kind === "export"
527
+ ? "Save/Export Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"
528
+ : "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line" }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
490
529
  }
@@ -0,0 +1,53 @@
1
+ import stringWidth from "string-width";
2
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ /** Split text on grapheme boundaries so wide and combining characters wrap at `width` columns. */
4
+ export function wrapText(value, width) {
5
+ const lines = [];
6
+ let line = "";
7
+ let lineWidth = 0;
8
+ for (const { segment } of graphemes.segment(value)) {
9
+ const segmentWidth = stringWidth(segment);
10
+ if (line && lineWidth + segmentWidth > width) {
11
+ lines.push(line);
12
+ line = "";
13
+ lineWidth = 0;
14
+ }
15
+ line += segment;
16
+ lineWidth += segmentWidth;
17
+ }
18
+ return [...lines, line];
19
+ }
20
+ /** `lines` capped to `maxRows`, with the last visible row shortened to `width` plus an ellipsis. */
21
+ export function fitLines(lines, maxRows, width) {
22
+ if (maxRows <= 0)
23
+ return [];
24
+ if (lines.length <= maxRows)
25
+ return lines;
26
+ const visible = lines.slice(0, maxRows);
27
+ let last = "";
28
+ let used = 0;
29
+ for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
30
+ const segmentWidth = stringWidth(segment);
31
+ if (used + segmentWidth > width - 1)
32
+ break;
33
+ last += segment;
34
+ used += segmentWidth;
35
+ }
36
+ visible[maxRows - 1] = `${last}…`;
37
+ return visible;
38
+ }
39
+ /** One line shortened to `width` columns plus an ellipsis, for single-row labels. */
40
+ export function clipText(value, width) {
41
+ if (stringWidth(value) <= width)
42
+ return value;
43
+ let last = "";
44
+ let used = 0;
45
+ for (const { segment } of graphemes.segment(value)) {
46
+ const segmentWidth = stringWidth(segment);
47
+ if (used + segmentWidth > Math.max(width - 1, 0))
48
+ break;
49
+ last += segment;
50
+ used += segmentWidth;
51
+ }
52
+ return `${last}…`;
53
+ }
@@ -4,7 +4,7 @@ import { headerTabRowCount } from "./header-layout.js";
4
4
  export const FALLBACK_TERMINAL_ROWS = 24;
5
5
  /** Header borders, title, one tab row and footer; extra tab rows are added per terminal width. */
6
6
  export const APP_CHROME_ROWS = 5;
7
- const MINIMUM_ROWS = 3;
7
+ const DEFAULT_MINIMUM_ROWS = 3;
8
8
  /** The slice of a list that fits in `height` rows while keeping `selected` visible (roughly centred). */
9
9
  export function computeWindow(total, selected, height) {
10
10
  const size = Math.max(Math.floor(height), 1);
@@ -23,10 +23,10 @@ export function pageStep(height) {
23
23
  * Rows available to a list: the terminal height minus the shell chrome and the
24
24
  * view's own `reserved` lines. `override` fixes the height for tests.
25
25
  */
26
- export function useListHeight(override, reserved) {
26
+ export function useListHeight(override, reserved, minimumRows = DEFAULT_MINIMUM_ROWS) {
27
27
  const { rows, columns } = useWindowSize();
28
28
  if (override !== undefined)
29
29
  return Math.max(override, 1);
30
30
  const chromeRows = APP_CHROME_ROWS + headerTabRowCount(Math.max(columns - 4, 1)) - 1;
31
- return Math.max((rows || FALLBACK_TERMINAL_ROWS) - chromeRows - reserved, MINIMUM_ROWS);
31
+ return Math.max((rows || FALLBACK_TERMINAL_ROWS) - chromeRows - reserved, minimumRows);
32
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",
@@ -28,8 +28,6 @@
28
28
  "!dist/src/**/*.map",
29
29
  "resources/pi",
30
30
  "skills",
31
- "!skills/**/test_*.py",
32
- "!skills/**/testdata",
33
31
  "README.md",
34
32
  "README.es.md",
35
33
  "LICENSE",
@@ -84,7 +82,8 @@
84
82
  "audit:all": "pnpm audit:dead-code && pnpm audit:dupes && pnpm audit:health",
85
83
  "test:e2e": "pnpm build && node --import ./dist/tests/isolated-env.js --test --test-timeout=120000 \"dist/tests/e2e/*.e2e.test.js\"",
86
84
  "prepublishOnly": "pnpm build && pnpm test",
87
- "test:pack": "pnpm build && node scripts/pack-smoke.mjs"
85
+ "test:pack": "pnpm build && node scripts/pack-smoke.mjs",
86
+ "test:skills": "node scripts/test-skills.mjs"
88
87
  },
89
88
  "dependencies": {
90
89
  "ink": "^7.1.1",
@@ -44,8 +44,10 @@ Cada cita del juez se verifica mecánicamente contra la transcripción por subca
44
44
 
45
45
  ## Tests
46
46
 
47
+ Desde la raíz del repositorio Agent Depot, ejecuta:
48
+
47
49
  ```bash
48
- python3 -m unittest discover -s scripts -p 'test_*.py'
50
+ pnpm test:skills
49
51
  ```
50
52
 
51
53
  Sin dependencias: stdlib de Python 3.9+.