@iseoane/agent-depot 0.6.9 → 0.6.10

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.
@@ -1,12 +1,13 @@
1
1
  import { githubTreeLocation } from "../git-source.js";
2
- import { bundleRoot } from "../package-model.js";
2
+ import { selectionKey } from "../package-model.js";
3
3
  import { packageOwnedSkillMatcher } from "../package-owned-skills.js";
4
4
  import { packageVersionLabel } from "./package-rows.js";
5
5
  import { defaultExpanded } from "./tree.js";
6
6
  const DESCRIPTION_LIMIT = 60;
7
7
  export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
8
8
  export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
9
- export const bundleKey = (entry) => `bundle:${entry.sourceId}:${entry.descriptor.host}:${bundleRoot(entry.descriptor.coordinates)}`;
9
+ /** Keyed on the full selection, so two plugins of one marketplace root stay distinct nodes. */
10
+ export const bundleKey = (entry) => `bundle:${entry.sourceId}:${selectionKey(entry.selection)}`;
10
11
  export function truncateDescription(text) {
11
12
  return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
12
13
  }
@@ -30,11 +31,20 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
30
31
  for (const skill of skills)
31
32
  bySource.set(skill.sourceId, [...(bySource.get(skill.sourceId) ?? []), skill]);
32
33
  const bundlesBySource = new Map();
33
- for (const bundle of bundles)
34
+ const seenBundles = new Set();
35
+ for (const bundle of bundles) {
36
+ // Descriptors with identical coordinates are one Package; a duplicate node id would corrupt the rows.
37
+ if (seenBundles.has(bundleKey(bundle)))
38
+ continue;
39
+ seenBundles.add(bundleKey(bundle));
34
40
  bundlesBySource.set(bundle.sourceId, [...(bundlesBySource.get(bundle.sourceId) ?? []), bundle]);
41
+ }
35
42
  const sourcesTree = [...new Set([...bySource.keys(), ...bundlesBySource.keys()])].map((sourceId) => {
36
43
  const members = bySource.get(sourceId) ?? [];
37
44
  const sourceBundles = bundlesBySource.get(sourceId) ?? [];
45
+ const variants = new Map();
46
+ for (const skill of members)
47
+ variants.set(skill.name, [...(variants.get(skill.name) ?? []), skill]);
38
48
  const owned = packageOwnedSkillMatcher(sourceBundles.map((entry) => entry.descriptor).filter((descriptor) => active.has(descriptor)));
39
49
  return {
40
50
  id: `source:${sourceId}`,
@@ -45,10 +55,21 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
45
55
  data: { kind: "group", label: `Packages (${sourceBundles.length})` },
46
56
  children: sourceBundles.map((entry) => ({ id: bundleKey(entry), data: { kind: "bundle", entry } })),
47
57
  }]),
48
- ...members.map((skill) => {
49
- const ownedBy = owned(skill.path);
50
- return { id: skillKey(skill), data: { kind: "skill", skill, ...(ownedBy === undefined ? {} : { ownedBy }) } };
51
- }),
58
+ ...(members.length === 0 ? [] : [{
59
+ id: `skills:${sourceId}`,
60
+ data: { kind: "group", label: `Skills (${members.length})` },
61
+ children: [...variants.values()].map((group) => {
62
+ const variant = group.length > 1;
63
+ const nodes = group.map((skill) => {
64
+ const ownedBy = owned(skill.path);
65
+ return { id: skillKey(skill), data: { kind: "skill", skill, ...(ownedBy === undefined ? {} : { ownedBy }), ...(variant ? { variant } : {}) } };
66
+ });
67
+ // Same-named Skills of one Source differ in content, so each stays its own installable row under one group.
68
+ return variant
69
+ ? { id: `variants:${sourceId}:${group[0].name}`, data: { kind: "group", label: `${group[0].name} (${group.length} variants)` }, children: nodes }
70
+ : nodes[0];
71
+ }),
72
+ }]),
52
73
  ],
53
74
  };
54
75
  });
@@ -86,13 +107,29 @@ export function describeRow(row) {
86
107
  }
87
108
  if (data.kind === "app")
88
109
  return `App ${data.row.entry.recipe?.name ?? data.row.entry.file} ${data.row.inspection.status}`;
89
- return `${data.skill.name} ${truncateDescription(data.skill.description)}`;
110
+ return `${data.skill.name} ${truncateDescription(data.skill.description)}${data.variant === true ? ` (${data.skill.path})` : ""}`;
111
+ }
112
+ /** Skill nodes inside a Source's Skills group, directly or within one of its variant groups. */
113
+ function skillNodesOf(roots) {
114
+ const found = [];
115
+ const visit = (node) => {
116
+ if (node.data.kind === "skill")
117
+ found.push(node);
118
+ else if (node.id.startsWith("variants:"))
119
+ node.children?.forEach(visit);
120
+ };
121
+ for (const root of roots) {
122
+ for (const group of root.children ?? [])
123
+ if (group.id.startsWith("skills:"))
124
+ group.children?.forEach(visit);
125
+ }
126
+ return found;
90
127
  }
91
128
  /** The Skills under the tree's Sources, in display order. */
92
129
  export function leavesOf(roots) {
93
- return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" ? [child.data.skill] : []));
130
+ return skillNodesOf(roots).map((node) => node.data.skill);
94
131
  }
95
132
  /** Skills that can still be installed as loose Skills, excluding active Package-owned rows. */
96
133
  export function looseLeavesOf(roots) {
97
- return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" && child.data.ownedBy === undefined ? [child.data.skill] : []));
134
+ return skillNodesOf(roots).filter((node) => node.data.ownedBy === undefined).map((node) => node.data.skill);
98
135
  }
@@ -17,6 +17,7 @@ import { useCatalogApps, useCatalogFilter, useCatalogSkills, useInstalledSkills
17
17
  import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
18
18
  import { theme } from "./theme.js";
19
19
  import { useTreeNavigation } from "./tree-navigation.js";
20
+ import { invalidateRetention } from "./view-retention.js";
20
21
  import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
21
22
  import { computeWindow, useListHeight } from "./window.js";
22
23
  import { useKeys } from "./keys.js";
@@ -39,7 +40,7 @@ function wrappedLineCount(text, columns) {
39
40
  }
40
41
  return lines;
41
42
  }
42
- export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
43
+ export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight, retention }) {
43
44
  const { columns, rows: terminalRows } = useWindowSize();
44
45
  const env = environment ?? NO_ENVIRONMENT;
45
46
  const [all, setAll] = useState(sourceId === undefined);
@@ -52,12 +53,12 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
52
53
  // Mirrors the action mode synchronously, like the filter, so later keystrokes and the shell never see stale state.
53
54
  const actionRef = useRef(BROWSE);
54
55
  const filter = useCatalogFilter(actionRef, onCapturingChange);
55
- const state = useCatalogSkills(operations, sourceId, all, env, reloadKey);
56
+ const state = useCatalogSkills(operations, sourceId, all, env, reloadKey, retention);
56
57
  const packages = useMemo(() => packageOperationsOf(env), [env]);
57
58
  const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
58
- const appCatalogState = useCatalogApps(apps);
59
+ const appCatalogState = useCatalogApps(apps, retention);
59
60
  const appCatalog = appCatalogState.state;
60
- const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
61
+ const { installed, installedReady, installedStale, reloadInstalled } = useInstalledSkills(operations, env, mounted, retention);
61
62
  const [appMode, setAppMode] = useState();
62
63
  const appModeRef = useRef(undefined);
63
64
  const setAppAction = (mode) => {
@@ -104,6 +105,8 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
104
105
  }
105
106
  }, [loading]);
106
107
  const loadingSince = useLoadingSince(loading, runningSince ?? resumedSince);
108
+ const refreshing = !loading && ((state.status === "ready" && state.stale === true) || (appCatalog.status === "ready" && appCatalog.stale === true) || installedStale);
109
+ const refreshingSince = useLoadingSince(refreshing);
107
110
  const flow = {
108
111
  operations,
109
112
  env,
@@ -112,6 +115,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
112
115
  isMounted: () => mounted.current,
113
116
  keepMarked: marks.setMarked,
114
117
  finish: async (result) => {
118
+ invalidateRetention(retention);
115
119
  await reloadInstalled();
116
120
  if (!mounted.current)
117
121
  return;
@@ -126,6 +130,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
126
130
  isMounted: () => mounted.current,
127
131
  unmark: ids => marks.setMarked(new Set([...marks.markedRef.current].filter(id => !ids.includes(id)))),
128
132
  finish: async (result) => {
133
+ invalidateRetention(retention);
129
134
  await reloadInstalled();
130
135
  if (!mounted.current)
131
136
  return;
@@ -142,6 +147,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
142
147
  setMessage,
143
148
  isMounted: () => mounted.current,
144
149
  finish: async (result) => {
150
+ invalidateRetention(retention);
145
151
  await reloadInstalled();
146
152
  if (!mounted.current)
147
153
  return;
@@ -194,5 +200,5 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
194
200
  // A paged declaration owns the view: rows of the tree above it would not be counted in its height.
195
201
  if (isPackageMode(action))
196
202
  return _jsx(PackagePanel, { mode: action });
197
- return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
203
+ return (_jsxs(Box, { flexDirection: "column", children: [refreshing ? _jsx(Working, { label: "Refreshing catalog (last data shown)\u2026", since: refreshingSince }) : null, state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
198
204
  }
@@ -22,15 +22,18 @@ import { busy } from "./working-model.js";
22
22
  import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
23
23
  import { computeWindow, useListHeight } from "./window.js";
24
24
  import { useKeys } from "./keys.js";
25
+ import { invalidateRetention } from "./view-retention.js";
25
26
  const NO_ENVIRONMENT = {};
26
27
  const LEGEND = "adopted: tracked in place · modified: changed on disk";
27
- export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
28
+ export function InstallationsView({ operations, environment, onCapturingChange, listHeight, retention }) {
28
29
  const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
29
30
  const height = useListHeight(listHeight, 7);
30
31
  const env = environment ?? NO_ENVIRONMENT;
31
32
  const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
32
33
  const packages = useMemo(() => packageOperationsOf(env), [env]);
33
- const [state, setState] = useState({ status: "loading" });
34
+ const [state, setState] = useState(() => retention?.installations ? { status: "ready", data: retention.installations } : { status: "loading" });
35
+ // The data shown comes from an earlier visit until the first reload of this one ends.
36
+ const [stale, setStale] = useState(retention?.installations !== undefined);
34
37
  const [mode, setModeState] = useState(BROWSE);
35
38
  // Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
36
39
  const modeRef = useRef(BROWSE);
@@ -50,22 +53,30 @@ export function InstallationsView({ operations, environment, onCapturingChange,
50
53
  const loadGeneration = useRef(0);
51
54
  const reload = useCallback(async () => {
52
55
  const generation = ++loadGeneration.current;
56
+ const retained = retention?.generation;
53
57
  if (reloads.current++ === 0 && mounted.current)
54
58
  setRefreshingSince(Date.now());
55
59
  try {
56
60
  const data = await loadInstallations(operations, env, apps, packages);
57
- if (mounted.current && generation === loadGeneration.current)
61
+ // A result that began before an invalidation may predate the change, so it is not kept.
62
+ if (retention && retention.generation === retained)
63
+ retention.installations = data;
64
+ if (mounted.current && generation === loadGeneration.current) {
58
65
  setState({ status: "ready", data });
66
+ setStale(false);
67
+ }
59
68
  }
60
69
  catch (error) {
61
- if (mounted.current && generation === loadGeneration.current)
70
+ if (mounted.current && generation === loadGeneration.current) {
62
71
  setState({ status: "error", message: errorText(error) });
72
+ setStale(false);
73
+ }
63
74
  }
64
75
  finally {
65
76
  if (--reloads.current === 0 && mounted.current)
66
77
  setRefreshingSince(undefined);
67
78
  }
68
- }, [operations, env, apps, packages, mounted]);
79
+ }, [operations, env, apps, packages, mounted, retention]);
69
80
  useEffect(() => {
70
81
  void reload();
71
82
  }, [reload]);
@@ -129,6 +140,8 @@ export function InstallationsView({ operations, environment, onCapturingChange,
129
140
  const current = modeRef.current;
130
141
  if ("startedAt" in current && mounted.current)
131
142
  setMode(busy("Refreshing installations…", current));
143
+ // The action changed what is installed, so what was kept no longer describes it.
144
+ invalidateRetention(retention);
132
145
  await reload();
133
146
  };
134
147
  const manage = {
@@ -197,6 +210,6 @@ export function InstallationsView({ operations, environment, onCapturingChange,
197
210
  const markedApps = markedLeaves.filter(node => node.data.kind === "app").length;
198
211
  // The legend explains the markers, so it shows only while a rendered row carries one.
199
212
  const legendNeeded = listed.some((row) => row.node.data.kind === "installation" && markersOf(row.node.data.row) !== "");
200
- return (_jsxs(Box, { flexDirection: "column", children: [refreshingSince !== undefined && mode.kind === "browse" ? _jsx(Working, { label: "Refreshing installations\u2026", since: refreshingSince }) : null, ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, checkingSince !== undefined && refreshingSince === undefined && mode.kind === "browse"
213
+ return (_jsxs(Box, { flexDirection: "column", children: [refreshingSince !== undefined && mode.kind === "browse" ? _jsx(Working, { label: stale ? "Refreshing installations (last data shown)…" : "Refreshing installations…", since: refreshingSince }) : null, ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, checkingSince !== undefined && refreshingSince === undefined && mode.kind === "browse"
201
214
  ? _jsx(Working, { label: "Checking App status\u2026", since: checkingSince }) : null, _jsx(ModePanel, { mode: mode })] }));
202
215
  }
@@ -66,6 +66,6 @@ export function ManagePanel({ mode }) {
66
66
  case "confirm-bulk-exposure":
67
67
  return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.plan.items.map((item) => item.label).join(", "), " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
68
68
  case "busy":
69
- return _jsx(Working, { label: mode.label, since: mode.startedAt });
69
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), (mode.done ?? []).length > 0 ? _jsxs(Text, { color: theme.muted, children: [mode.done.length, " finished"] }) : null] }));
70
70
  }
71
71
  }
@@ -15,7 +15,7 @@ import { useKeys } from "./keys.js";
15
15
  import { previewViewport } from "./preview-window.js";
16
16
  import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
17
17
  import { BorderPanel, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
18
- import { elideMiddle, fitLines, wrapText } from "./text-wrap.js";
18
+ import { clipText, elideMiddle, fitLines, wrapText } from "./text-wrap.js";
19
19
  import { theme } from "./theme.js";
20
20
  import { useMirrored, useMounted } from "./view-state.js";
21
21
  import { computeWindow, useListHeight } from "./window.js";
@@ -53,14 +53,16 @@ function rowsFromExport(plan) {
53
53
  })),
54
54
  ];
55
55
  }
56
+ /** A group header, followed by its children while the group is open; an empty group is not shown. */
57
+ function section(expanded, id, label, depth, children) {
58
+ if (!children.length)
59
+ return [];
60
+ const header = { kind: "group", id, label, depth };
61
+ return expanded.has(id) ? [header, ...children] : [header];
62
+ }
56
63
  function exportDisplayRows(rows, expanded) {
57
64
  const choices = (block) => rows.filter(row => row.block === block);
58
- const group = (id, label, depth, children) => {
59
- if (!children.length)
60
- return [];
61
- const header = { kind: "group", id, label, depth };
62
- return expanded.has(id) ? [header, ...children] : [header];
63
- };
65
+ const group = (id, label, depth, children) => section(expanded, id, label, depth, children);
64
66
  const sourceChildren = [
65
67
  ...group("group:sources", `Source choices (${choices("sources").length})`, 1, choices("sources")),
66
68
  ...group("group:skills", `Skills (${choices("skills").length})`, 1, choices("skills")),
@@ -73,6 +75,30 @@ function exportDisplayRows(rows, expanded) {
73
75
  ...group("group:excluded", `Export notes (${choices("excluded").length})`, 0, choices("excluded")),
74
76
  ];
75
77
  }
78
+ const IMPORT_ADD = "import:add";
79
+ const IMPORT_APPS = "import:apps";
80
+ const importRepoId = (repo) => `import:add:${repo}`;
81
+ /** Additions by Source and App recipes, then what is already there, then what conflicts. */
82
+ function importDisplayRows(rows, expanded) {
83
+ const added = rows.filter(row => row.status === "add");
84
+ const recipes = added.filter(row => row.block === "apps");
85
+ const repos = [...new Set(added.filter(row => row.block !== "apps").map(row => row.source ?? ""))];
86
+ const sources = repos.flatMap(repo => {
87
+ const children = added.filter(row => row.block !== "apps" && (row.source ?? "") === repo);
88
+ return section(expanded, importRepoId(repo), `${repo} (${children.length})`, 1, children);
89
+ });
90
+ const unchanged = rows.filter(row => row.status === "same"), conflicts = rows.filter(row => row.status === "conflict");
91
+ return [
92
+ ...section(expanded, IMPORT_ADD, `To import (${added.length})`, 0, [...sources, ...section(expanded, IMPORT_APPS, `App recipes (${recipes.length})`, 1, recipes)]),
93
+ ...section(expanded, "import:same", `Unchanged (${unchanged.length})`, 0, unchanged),
94
+ ...section(expanded, "import:conflict", `Conflicts (${conflicts.length}) — not selectable`, 0, conflicts),
95
+ ];
96
+ }
97
+ /** Additions and conflicts are open to read; the unchanged items are what the person already has. */
98
+ function importOpenGroups(rows) {
99
+ const repos = rows.filter(row => row.status === "add" && row.block !== "apps").map(row => importRepoId(row.source ?? ""));
100
+ return new Set([IMPORT_ADD, IMPORT_APPS, "import:conflict", ...repos]);
101
+ }
76
102
  function physicalListWindow(rows, rowHeights, selected, height) {
77
103
  if (!rows.length)
78
104
  return { start: 0, end: 0, indicator: undefined, stickyGroup: false };
@@ -144,7 +170,7 @@ function planEntries(plan) {
144
170
  function rowsFromPlan(plan) {
145
171
  return planEntries(plan).map((item, index) => ({
146
172
  id: `${item.block}:${index}`, block: item.block, index, ordinal: index + 1,
147
- label: `${item.status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`,
173
+ label: item.summary, status: item.status, ...(item.group === undefined ? {} : { source: item.group }),
148
174
  selectable: item.status === "add",
149
175
  }));
150
176
  }
@@ -173,6 +199,12 @@ function selectedImportProfile(profile, entries) {
173
199
  return parseProfile({ ...profile, format: replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT,
174
200
  sources, skills, apps, packages, replicas });
175
201
  }
202
+ /** The rows a list stage shows: the grouped export or import tree, otherwise the flat rows. */
203
+ function listRows(mode, rows, expanded) {
204
+ if (mode === "export-list")
205
+ return exportDisplayRows(rows, expanded);
206
+ return mode === "import-list" ? importDisplayRows(rows, expanded) : rows;
207
+ }
176
208
  /** Collect choices, display shared-core previews and confirm portable Profile transfers. */
177
209
  export function ProfileView({ operations, environment = {}, onCapturingChange, listHeight, existsCheck = pathExists }) {
178
210
  const apps = useMemo(() => createAppOperations({
@@ -208,14 +240,17 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
208
240
  const diagnostics = useRef([]);
209
241
  const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
210
242
  const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
211
- const hintCursorRow = (mode === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows)[cursorRef.current];
243
+ const hintCursorRow = listRows(mode, rows, expandedGroupsRef.current)[cursorRef.current];
212
244
  const enterHint = hintCursorRow === undefined ? undefined
213
245
  : "kind" in hintCursorRow ? "Enter open"
214
246
  : hintCursorRow.block === "excluded" ? "Enter note" : undefined;
215
247
  const groupHint = hintCursorRow !== undefined && "kind" in hintCursorRow ? " · ←/→ group" : "";
248
+ // With nothing to choose, a header has no item row to move to, so Enter previews instead of opening it.
249
+ const nothingToImport = !rows.some(row => row.selectable);
250
+ const importEnter = hintCursorRow !== undefined && "kind" in hintCursorRow && !nothingToImport ? "Enter open" : "Enter preview";
216
251
  const listingHint = mode === "export-list"
217
252
  ? `j/k move${groupHint}${enterHint === undefined ? "" : ` · ${enterHint}`} · space toggle · a all · e export · Esc back to menu`
218
- : "j/k move · space toggle · a all · Enter preview · Esc back to menu";
253
+ : `j/k move${groupHint} · space toggle · a all · ${importEnter} · Esc back to menu`;
219
254
  const hintText = `${selected.length} selected · ${listingHint}`;
220
255
  const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
221
256
  const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
@@ -250,11 +285,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
250
285
  const loadRows = (next, nextMode) => {
251
286
  setRows(next);
252
287
  select(next.filter(row => row.selectable));
253
- const open = new Set();
288
+ const open = nextMode === "import-list" ? importOpenGroups(next) : new Set();
254
289
  setExpandedGroups(open);
255
- const firstChoice = nextMode === "export-list"
256
- ? exportDisplayRows(next, open).findIndex(row => !("kind" in row) && row.selectable)
257
- : 0;
290
+ const firstChoice = listRows(nextMode, next, open).findIndex(row => !("kind" in row) && row.selectable);
258
291
  cursorTo(Math.max(firstChoice, 0));
259
292
  setMode(nextMode);
260
293
  };
@@ -483,9 +516,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
483
516
  return;
484
517
  }
485
518
  // export-list and import-list
486
- const visibleRows = current === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows;
487
- const currentRow = visibleRows[cursorRef.current];
488
- const next = moveCursor(cursorRef.current, visibleRows.length, input, key);
519
+ const shownRows = listRows(current, rows, expandedGroupsRef.current);
520
+ const currentRow = shownRows[cursorRef.current];
521
+ const next = moveCursor(cursorRef.current, shownRows.length, input, key);
489
522
  if (next !== undefined) {
490
523
  cursorTo(Math.max(0, next));
491
524
  return;
@@ -503,6 +536,10 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
503
536
  beginExport();
504
537
  return;
505
538
  }
539
+ if (key.return && current === "import-list" && nothingToImport) {
540
+ previewImport();
541
+ return;
542
+ }
506
543
  if (currentRow && "kind" in currentRow && currentRow.kind === "group") {
507
544
  if (key.return || key.rightArrow || key.leftArrow) {
508
545
  const open = new Set(expandedGroupsRef.current);
@@ -531,13 +568,13 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
531
568
  }
532
569
  });
533
570
  const listing = mode === "export-list" || mode === "import-list";
534
- const displayRows = mode === "export-list" ? exportDisplayRows(rows, expandedGroups) : rows;
571
+ const displayRows = listRows(mode, rows, expandedGroups);
535
572
  const rowHeights = displayRows.map(row => {
536
- const prefixWidth = "kind" in row ? stringWidth(`> ${" ".repeat(row.depth * 2)}▸ `) : row.block === "excluded" ? 2
537
- : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
538
- const label = !("kind" in row) && row.block !== "excluded" && !row.selectable
539
- ? `${row.label} (not selectable)` : row.label;
540
- return wrapText(label, Math.max(panelWidth - prefixWidth, 1)).length;
573
+ // A group header is one row, so the sticky header the window repeats costs exactly the row it reserves.
574
+ if ("kind" in row)
575
+ return 1;
576
+ const prefixWidth = row.block === "excluded" ? 2 : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
577
+ return wrapText(row.label, Math.max(panelWidth - prefixWidth, 1)).length;
541
578
  });
542
579
  const listWindow = physicalListWindow(displayRows, rowHeights, cursor, height);
543
580
  const window = listing ? listWindow : computeWindow(lines.length, cursor, height);
@@ -554,10 +591,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
554
591
  const open = expandedGroups.has(row.id);
555
592
  const prefix = `${marker}${" ".repeat(row.depth * 2)}${open ? "▾" : "▸"} `;
556
593
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
557
- const label = fitLines(wrapText(row.label, width), remainingRows, width)
558
- .join(`\n${" ".repeat(stringWidth(prefix))}`);
559
- remainingRows -= label.split("\n").length;
560
- return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, label] }, row.id);
594
+ remainingRows -= 1;
595
+ return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, clipText(row.label, width)] }, row.id);
561
596
  }
562
597
  if (row.block === "excluded") {
563
598
  const width = Math.max(panelWidth - stringWidth(marker), 1);
@@ -566,13 +601,15 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
566
601
  remainingRows -= label.split("\n").length;
567
602
  return _jsxs(Text, { color: theme.value, children: [marker, label] }, row.id);
568
603
  }
569
- const prefix = `${marker}[${selected.some(choice => choice.id === row.id) ? "x" : " "}] ${row.ordinal ?? row.index + 1} `;
570
- const value = `${row.label}${row.selectable ? "" : " (not selectable)"}`;
604
+ // A row the import never changes has no checkbox: unchanged items rest, conflicts warn.
605
+ const box = row.selectable ? `[${selected.some(choice => choice.id === row.id) ? "x" : " "}]` : " ";
606
+ const prefix = `${marker}${box} ${row.ordinal ?? row.index + 1} `;
571
607
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
572
- const label = fitLines(wrapText(value, width), remainingRows, width)
608
+ const label = fitLines(wrapText(row.label, width), remainingRows, width)
573
609
  .join(`\n${" ".repeat(stringWidth(prefix))}`);
574
610
  remainingRows -= label.split("\n").length;
575
- return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: prefix }), _jsx(Text, { color: theme.value, children: label })] }, row.id);
611
+ const color = row.status === "conflict" ? theme.warning : row.status === "same" ? theme.muted : theme.value;
612
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: prefix }), _jsx(Text, { color: color, children: label })] }, row.id);
576
613
  });
577
614
  const nameOutcome = resolveExportFileName(text);
578
615
  const liveDestination = nameOutcome.kind === "ok"
@@ -15,6 +15,25 @@ import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
15
15
  import { previewViewport } from "./preview-window.js";
16
16
  import { theme } from "./theme.js";
17
17
  import { busy } from "./working-model.js";
18
+ /**
19
+ * One group per App name. Invalid recipes, and recipes whose name clashes among those that apply here
20
+ * (they carry an error), stay single rows so nothing is guessed.
21
+ */
22
+ export function groupRecipeRows(rows) {
23
+ const groups = new Map();
24
+ for (const row of rows) {
25
+ const key = row.entry.recipe !== undefined && row.entry.error === undefined ? `name:${row.entry.recipe.name}` : `file:${row.entry.file}`;
26
+ groups.set(key, [...(groups.get(key) ?? []), row]);
27
+ }
28
+ return [...groups.values()].map(found => {
29
+ const members = [...found.filter(row => row.entry.applicable), ...found.filter(row => !row.entry.applicable)];
30
+ return { primary: members[0], members };
31
+ });
32
+ }
33
+ /** `windows · approved · <file> (used here)`, one line per recipe of a grouped App. */
34
+ function recipeMemberLine(row) {
35
+ return `${row.entry.recipe?.platform ?? "all platforms"} · ${row.status} · ${row.entry.file}${row.entry.applicable ? " (used here)" : ""}`;
36
+ }
18
37
  function recipeApprovalChrome(entry) {
19
38
  return {
20
39
  title: "Recipe approval · does not install",
@@ -61,7 +80,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
61
80
  if (mounted.current)
62
81
  setMode({ kind: "browse" });
63
82
  };
64
- const preview = async (entry, status, removeAfterApproval = false) => {
83
+ const preview = async (entry, status, removeAfterApproval = false, members = []) => {
65
84
  setError(undefined);
66
85
  setBusy("Preparing App recipe preview…");
67
86
  try {
@@ -70,7 +89,10 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
70
89
  setMode({
71
90
  kind: "approve",
72
91
  entry, removeAfterApproval, cursor: 0,
73
- lines: appRecipePreviewLines(entry, status, steps),
92
+ lines: [
93
+ ...(members.length > 1 ? [`Recipes named ${appName(entry)}:`, ...members.map(row => ` ${recipeMemberLine(row)}`), ""] : []),
94
+ ...appRecipePreviewLines(entry, status, steps),
95
+ ],
74
96
  });
75
97
  }
76
98
  }
@@ -198,6 +220,14 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
198
220
  return true;
199
221
  }
200
222
  const cancel = () => setMode({ kind: "browse" });
223
+ if (current.kind === "pick") {
224
+ const chosen = /^[1-9]$/u.test(input) ? current.members[Number(input) - 1] : undefined;
225
+ if (chosen)
226
+ void startRemoval(chosen.entry);
227
+ else if (input === "n" || key.escape)
228
+ cancel();
229
+ return true;
230
+ }
201
231
  if (current.kind === "approve") {
202
232
  const viewport = previewViewport(current.lines, current.cursor, terminalRows, terminalColumns, recipeApprovalChrome(current.entry));
203
233
  const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
@@ -265,17 +295,23 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
265
295
  }, cancel);
266
296
  return true;
267
297
  }
268
- const row = rows.find(row => row.entry.file === highlightedFile());
269
- if (!row)
298
+ const group = groupRecipeRows(rows).find(candidate => candidate.primary.entry.file === highlightedFile());
299
+ if (!group)
270
300
  return false;
301
+ const row = group.primary;
271
302
  if (input === "n")
272
303
  startAdd();
273
304
  else if (input === "r")
274
305
  void reload();
275
306
  else if (key.return)
276
- void preview(row.entry, row.status);
277
- else if (input === "d")
278
- void startRemoval(row.entry);
307
+ void preview(row.entry, row.status, false, group.members);
308
+ // A grouped App names several files; the chosen one is the only file the removal flow may touch.
309
+ else if (input === "d") {
310
+ if (group.members.length > 1)
311
+ setMode({ kind: "pick", members: group.members });
312
+ else
313
+ void startRemoval(row.entry);
314
+ }
279
315
  else
280
316
  return false;
281
317
  return true;
@@ -312,7 +348,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
312
348
  const chrome = recipeApprovalChrome(mode.entry);
313
349
  const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
314
350
  return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
315
- })() : null, mode.kind === "uninstall-question" ? (_jsxs(BorderPanel, { title: "Uninstall before recipe removal", children: [_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", "."] }), _jsxs(Text, { children: [_jsx(DecisionPrompt, { children: "Uninstall it before removing the recipe?" }), _jsx(Text, { color: theme.legend, children: "(n cancels removal)" })] })] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (() => {
351
+ })() : null, mode.kind === "pick" ? (_jsxs(BorderPanel, { title: "Remove which recipe?", children: [mode.members.map((row, index) => _jsxs(Text, { children: [index + 1, " ", recipeMemberLine(row)] }, row.entry.file)), _jsx(Text, { color: theme.legend, children: "Press a number to review that one file for removal \u00B7 n or Esc cancels" })] })) : null, mode.kind === "uninstall-question" ? (_jsxs(BorderPanel, { title: "Uninstall before recipe removal", children: [_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", "."] }), _jsxs(Text, { children: [_jsx(DecisionPrompt, { children: "Uninstall it before removing the recipe?" }), _jsx(Text, { color: theme.legend, children: "(n cancels removal)" })] })] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (() => {
316
352
  const chrome = uninstallChrome(mode);
317
353
  const lines = appStepLines(mode.plan);
318
354
  const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from "ink";
3
+ import path from "node:path";
3
4
  import { useCallback, useEffect, useReducer, useRef, useState } from "react";
4
5
  import { initialMode, modeReducer } from "./sources-mode.js";
5
6
  import { rowStyle, theme } from "./theme.js";
6
7
  import { computeWindow, pageStep, useListHeight } from "./window.js";
7
8
  import { useKeys } from "./keys.js";
8
- import { useSourcesRecipes } from "./sources-recipes.js";
9
+ import { groupRecipeRows, useSourcesRecipes } from "./sources-recipes.js";
9
10
  import { BorderPanel, DecisionPrompt, FieldLine, PreviewLines, Working } from "./panel-parts.js";
10
11
  import { previewViewport } from "./preview-window.js";
11
12
  import { movePreviewCursor } from "./mode-keys.js";
@@ -123,14 +124,14 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
123
124
  highlightedFile: () => {
124
125
  const rows = rowsRef.current;
125
126
  const row = rows[resolveIndex(rows, selectionRef.current)];
126
- return row?.kind === "app" ? row.recipe.entry.file : undefined;
127
+ return row?.kind === "app" ? row.group.primary.entry.file : undefined;
127
128
  },
128
129
  terminalRows: terminalRows || 24,
129
130
  terminalColumns: columns || 80,
130
131
  });
131
132
  const rows = [
132
133
  ...sources.map((source) => ({ id: source.id, kind: "skills", source })),
133
- ...recipes.rows.map((recipe) => ({ id: `recipe:${recipe.entry.file}`, kind: "app", recipe })),
134
+ ...groupRecipeRows(recipes.rows).map((group) => ({ id: `recipe:${group.primary.entry.file}`, kind: "app", group })),
134
135
  ];
135
136
  rowsRef.current = rows;
136
137
  const selected = resolveIndex(rows, selection);
@@ -334,13 +335,19 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
334
335
  return _jsx(Box, { flexDirection: "column", children: recipes.panel });
335
336
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: [" ", "ID KIND LOCATION / STATUS"] }), builtin ? _jsxs(Text, { children: [" ", _jsx(Text, { color: theme.value, children: builtin.id }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: "skills" }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.warning, children: "included" })] }) : null, rows.length === 0 && recipes.loadingSince === undefined ? _jsx(Text, { children: "No sources or App recipes \u00B7 n to add" }) : null, rows.slice(window.start, window.end).map((row, offset) => {
336
337
  const index = window.start + offset;
337
- const id = row.kind === "skills" ? row.source.id : row.recipe.entry.recipe?.name ?? row.recipe.entry.file.split(/[\\/]/u).pop();
338
+ const id = row.kind === "skills" ? row.source.id : row.group.primary.entry.recipe?.name ?? row.group.primary.entry.file.split(/[\\/]/u).pop();
338
339
  return (_jsxs(Text, { ...rowStyle(index === selected), children: [index === selected ? "> " : " ", row.kind === "skills" ? (marked.has(row.id) ? "[x] " : "[ ] ") : " ", _jsx(Text, { color: theme.value, children: id }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: row.kind }), _jsx(Text, { color: theme.legend, children: " " }), row.kind === "skills"
339
340
  ? _jsx(Text, { color: theme.value, children: row.source.url })
340
- : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: row.recipe.entry.file }), _jsx(Text, { color: theme.legend, children: " \u00B7 " }), _jsx(Text, { color: row.recipe.status === "approved" ? theme.success : theme.warning, children: row.recipe.status })] })] }, row.id));
341
+ : _jsx(RecipeStatus, { group: row.group })] }, row.id));
341
342
  }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, recipes.loadingSince !== undefined && mode.kind !== "busy" && !recipes.capturing
342
343
  ? _jsx(Working, { label: "Loading App recipes\u2026", since: recipes.loadingSince }) : null, recipes.panel, _jsx(Prompt, { mode: mode }), message ? (_jsx(BorderPanel, { title: message.kind === "error" ? "Source action · failed" : "Source action · complete", children: _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) })) : null] }));
343
344
  }
345
+ /** Status of the recipe that applies here, led by its platform when it names one; other platforms follow, muted. */
346
+ function RecipeStatus({ group }) {
347
+ const { primary, members } = group;
348
+ const platform = primary.entry.recipe?.platform;
349
+ return (_jsxs(_Fragment, { children: [platform === undefined ? null : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: platform }), _jsx(Text, { color: theme.legend, children: " \u00B7 " })] }), primary.entry.error === undefined || primary.entry.recipe === undefined ? null : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: path.basename(primary.entry.file) }), _jsx(Text, { color: theme.legend, children: " \u00B7 " })] }), _jsx(Text, { color: primary.status === "approved" ? theme.success : theme.warning, children: primary.status === "approved" ? "✓ approved" : primary.status }), members.slice(1).map(other => (_jsxs(Text, { color: theme.muted, children: [" ", other.entry.recipe?.platform ?? "all platforms", " (other platform)"] }, other.entry.file)))] }));
350
+ }
344
351
  function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
345
352
  if (mode.kind === "add-kind")
346
353
  return _jsx(Text, { children: "Add: 1 Git Source (skills + Packages) \u00B7 2 App recipe (Esc cancel)" });
@@ -184,6 +184,7 @@ export async function prepareUpdates(data, selected) {
184
184
  continue;
185
185
  const { context } = loaded;
186
186
  const outcome = await previewUpdateCandidates(items, context);
187
+ lines.push(...loaded.projectTarget.map(previewText));
187
188
  lines.push(...outcome.plans.flatMap((plan) => describeUpdatePreview(plan, scope, context.projectRoot, "prompt").map(previewText)));
188
189
  failures.push(...outcome.failures);
189
190
  groups.push({