@iseoane/agent-depot 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.es.md +51 -29
  3. package/README.md +44 -25
  4. package/dist/src/app-flow.js +56 -24
  5. package/dist/src/profile-export.js +72 -9
  6. package/dist/src/profile-import.js +3 -2
  7. package/dist/src/profile.js +71 -7
  8. package/dist/src/tui/app-actions.js +82 -33
  9. package/dist/src/tui/app.js +19 -14
  10. package/dist/src/tui/catalog-browse.js +6 -1
  11. package/dist/src/tui/catalog-panel.js +11 -11
  12. package/dist/src/tui/catalog-state.js +28 -0
  13. package/dist/src/tui/catalog-tree.js +22 -5
  14. package/dist/src/tui/catalog-view.js +59 -16
  15. package/dist/src/tui/header-layout.js +33 -0
  16. package/dist/src/tui/installations-panel.js +3 -3
  17. package/dist/src/tui/installations-rows.js +20 -5
  18. package/dist/src/tui/installations-tree.js +4 -7
  19. package/dist/src/tui/installations-view.js +15 -6
  20. package/dist/src/tui/installations.js +9 -7
  21. package/dist/src/tui/manage-panel.js +7 -6
  22. package/dist/src/tui/mode-keys.js +9 -0
  23. package/dist/src/tui/package-rows.js +17 -5
  24. package/dist/src/tui/panel-parts.js +38 -6
  25. package/dist/src/tui/preview-window.js +48 -0
  26. package/dist/src/tui/profile-view.js +261 -42
  27. package/dist/src/tui/sources-mode.js +3 -1
  28. package/dist/src/tui/sources-recipes.js +50 -19
  29. package/dist/src/tui/sources-view.js +50 -14
  30. package/dist/src/tui/theme.js +14 -7
  31. package/dist/src/tui/updates-flow.js +2 -2
  32. package/dist/src/tui/updates-keys.js +34 -5
  33. package/dist/src/tui/updates-model.js +2 -0
  34. package/dist/src/tui/updates-panel.js +40 -15
  35. package/dist/src/tui/updates-preview.js +65 -0
  36. package/dist/src/tui/updates-view.js +17 -2
  37. package/dist/src/tui/updates.js +14 -5
  38. package/dist/src/tui/window.js +6 -4
  39. package/package.json +4 -3
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from "ink";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { createAppOperations } from "../app-flow.js";
4
5
  import { BROWSE } from "./catalog-actions.js";
5
6
  import { handleCatalogBrowseKey } from "./catalog-browse.js";
6
7
  import { filterSkills } from "./catalog-filter.js";
7
8
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
9
  import { isInstalled } from "./catalog-installs.js";
10
+ import { AppPanel, handleAppKey, startAppAction } from "./app-actions.js";
9
11
  import { ActionPanel, CatalogHeader, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
10
12
  import { packageOwnedSkillNotice } from "./catalog-ownership.js";
11
13
  import { packageOperationsOf } from "./environment.js";
12
14
  import { handlePackageKey, isPackageMode, startPackageAction } from "./package-actions.js";
13
15
  import { ListFooter } from "./panel-parts.js";
14
- import { useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
16
+ import { useCatalogApps, useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
15
17
  import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
16
18
  import { theme } from "./theme.js";
17
19
  import { useTreeNavigation } from "./tree-navigation.js";
@@ -38,7 +40,7 @@ function wrappedLineCount(text, columns) {
38
40
  return lines;
39
41
  }
40
42
  export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
41
- const { columns } = useWindowSize();
43
+ const { columns, rows: terminalRows } = useWindowSize();
42
44
  const env = environment ?? NO_ENVIRONMENT;
43
45
  const [all, setAll] = useState(sourceId === undefined);
44
46
  const mounted = useMounted();
@@ -52,7 +54,17 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
52
54
  const filter = useCatalogFilter(actionRef, onCapturingChange);
53
55
  const state = useCatalogSkills(operations, sourceId, all, env, reloadKey);
54
56
  const packages = useMemo(() => packageOperationsOf(env), [env]);
57
+ const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
58
+ const appCatalogState = useCatalogApps(apps);
59
+ const appCatalog = appCatalogState.state;
55
60
  const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
61
+ const [appMode, setAppMode] = useState();
62
+ const appModeRef = useRef(undefined);
63
+ const setAppAction = (mode) => {
64
+ appModeRef.current = mode;
65
+ onCapturingChange?.(mode !== undefined || filter.filterRef.current.editing || actionRef.current.kind !== "browse");
66
+ setAppMode(mode);
67
+ };
56
68
  const setAction = (next) => {
57
69
  actionRef.current = next;
58
70
  onCapturingChange?.(filter.filterRef.current.editing || next.kind !== "browse");
@@ -61,7 +73,13 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
61
73
  const skills = state.status === "ready" ? state.skills : [];
62
74
  const installable = useMemo(() => skills.filter((skill) => !isInstalled(skill, installed)), [state, installed]);
63
75
  const visible = useMemo(() => filterSkills(installable, filter.filter.query), [installable, filter.filter.query]);
64
- const roots = useMemo(() => buildTree(visible, state.status === "ready" ? state.sources : [], state.status === "ready" ? state.bundles : [], state.status === "ready" ? state.activeBundles : []), [visible, state]);
76
+ const visibleApps = useMemo(() => {
77
+ if (appCatalog.status !== "ready")
78
+ return [];
79
+ const query = filter.filter.query.trim().toLowerCase();
80
+ return query === "" ? appCatalog.rows : appCatalog.rows.filter(row => (row.entry.recipe?.name ?? row.entry.file).toLowerCase().includes(query));
81
+ }, [appCatalog, filter.filter.query]);
82
+ const roots = useMemo(() => buildTree(visible, state.status === "ready" ? state.sources : [], state.status === "ready" ? state.bundles : [], state.status === "ready" ? state.activeBundles : [], visibleApps), [visible, state, visibleApps]);
65
83
  const navigation = useTreeNavigation(roots, (nodes) => openByDefault(nodes, filter.queryRef.current));
66
84
  // Discovering again starts the list over.
67
85
  const { restart } = navigation;
@@ -86,6 +104,23 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
86
104
  setAction(BROWSE);
87
105
  },
88
106
  };
107
+ const appManage = {
108
+ setMode: setAppAction,
109
+ browse: () => setAppAction(undefined),
110
+ setMessage,
111
+ isMounted: () => mounted.current,
112
+ unmark: ids => marks.setMarked(new Set([...marks.markedRef.current].filter(id => !ids.includes(id)))),
113
+ finish: async (result) => {
114
+ await reloadInstalled();
115
+ if (!mounted.current)
116
+ return;
117
+ await appCatalogState.reload();
118
+ if (!mounted.current)
119
+ return;
120
+ setMessage(result);
121
+ setAppAction(undefined);
122
+ },
123
+ };
89
124
  const packageHost = {
90
125
  setMode: setAction,
91
126
  browse: () => setAction(BROWSE),
@@ -107,29 +142,37 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
107
142
  marks,
108
143
  filter,
109
144
  canWiden: sourceId !== undefined,
110
- ready: state.status === "ready",
145
+ ready: state.status === "ready" && appCatalog.status === "ready",
111
146
  toggleAll: () => setAll((value) => !value),
112
147
  startPackage: (target) => void startPackageAction(packageContext, target, "i"),
148
+ startApp: (entry) => void startAppAction({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, [entry], "i"),
113
149
  setMessage,
114
150
  setAction,
115
151
  };
116
152
  useKeys((input, key) => {
117
- const mode = actionRef.current;
118
- if (isPackageMode(mode))
119
- handlePackageKey(packageContext, mode, input, key);
120
- else if (mode.kind !== "browse")
121
- handleCatalogActionKey(flow, mode, input, key);
122
- else if (filter.filterRef.current.editing)
123
- filter.handleEditKey(input, key, navigation.restart);
124
- else
125
- handleCatalogBrowseKey(browse, input, key);
153
+ const runningAppMode = appModeRef.current;
154
+ if (runningAppMode !== undefined)
155
+ handleAppKey({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, runningAppMode, input, key);
156
+ else {
157
+ const mode = actionRef.current;
158
+ if (isPackageMode(mode))
159
+ handlePackageKey(packageContext, mode, input, key);
160
+ else if (mode.kind !== "browse")
161
+ handleCatalogActionKey(flow, mode, input, key);
162
+ else if (filter.filterRef.current.editing)
163
+ filter.handleEditKey(input, key, navigation.restart);
164
+ else
165
+ handleCatalogBrowseKey(browse, input, key);
166
+ }
126
167
  });
127
168
  const scope = all || sourceId === undefined ? "all sources" : sourceId;
128
- if (state.status === "loading" || (state.status === "ready" && !installedReady))
129
- return _jsx(Text, { children: "Loading skills..." });
169
+ if (state.status === "loading" || appCatalog.status === "loading" || (state.status === "ready" && !installedReady))
170
+ return _jsx(Text, { children: "Loading catalog..." });
130
171
  if (state.status === "error")
131
172
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
132
173
  const window = computeWindow(rows.length, index, height);
133
174
  const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
134
- return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.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 }), _jsx(ActionPanel, { mode: action })] }));
175
+ if (appMode !== undefined)
176
+ return _jsx(AppPanel, { mode: appMode });
177
+ 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 })] }));
135
178
  }
@@ -0,0 +1,33 @@
1
+ export const TABS = [
2
+ ["sources", "[1] Sources"],
3
+ ["catalog", "[2] Catalog"],
4
+ ["installations", "[3] Installations"],
5
+ ["updates", "[4] Updates"],
6
+ ["profile", "[5] Profile"],
7
+ ];
8
+ export function headerTabRows(width) {
9
+ const rows = [];
10
+ let row = [];
11
+ let rowWidth = 0;
12
+ for (const tab of TABS) {
13
+ const separatorWidth = row.length === 0 ? 0 : 2;
14
+ // Each rendered row has at most one active marker, regardless of tab count.
15
+ const nextWidth = rowWidth + separatorWidth + tab[1].length;
16
+ if (row.length > 0 && nextWidth + 2 > width) {
17
+ rows.push(row);
18
+ row = [];
19
+ rowWidth = 0;
20
+ }
21
+ rowWidth += (row.length === 0 ? 0 : 2) + tab[1].length;
22
+ row.push(tab);
23
+ }
24
+ if (row.length > 0)
25
+ rows.push(row);
26
+ return rows;
27
+ }
28
+ export function headerTabRowCount(width) {
29
+ return headerTabRows(width).length;
30
+ }
31
+ export function formatHeaderTabs(view, width) {
32
+ return headerTabRows(width).map(row => row.map(([name, label]) => `${view === name ? "▶ " : ""}${label}`).join(" ")).join("\n");
33
+ }
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { HOST_CHOICES } from "./catalog-actions.js";
4
4
  import { ManagePanel } from "./manage-panel.js";
5
- import { HostChecklist, PreviewLines } from "./panel-parts.js";
5
+ import { DecisionPrompt, HostChecklist, PreviewLines } from "./panel-parts.js";
6
6
  import { theme } from "./theme.js";
7
7
  /** The prompt of the adoption and manage modes; browsing shows nothing. */
8
8
  export function ModePanel({ mode }) {
@@ -18,9 +18,9 @@ export function ModePanel({ mode }) {
18
18
  case "fixed":
19
19
  return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
20
20
  case "confirm":
21
- return mode.adoption.verdict.adoptable ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.adoption.prepared.preview }), mode.adoption.prepared.runsExternalCommand ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(Text, { children: ["Adopt ", mode.entry.name, "? y/n"] })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.error, children: mode.adoption.verdict.reason }), _jsx(Text, { children: "Enter/Esc to go back" })] }));
21
+ return mode.adoption.verdict.adoptable ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.adoption.prepared.preview }), mode.adoption.prepared.runsExternalCommand ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(DecisionPrompt, { children: ["Adopt ", mode.entry.name, "?"] })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.error, children: mode.adoption.verdict.reason }), _jsx(Text, { children: "Enter/Esc to go back" })] }));
22
22
  case "confirm-exposure":
23
- return (_jsxs(Text, { color: theme.warning, children: ["Adopting ", mode.entry.name, " adds the missing Host location and exposes it to more hosts; this needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
23
+ return (_jsxs(Text, { color: theme.warning, children: ["Adopting ", mode.entry.name, " adds the missing Host location and exposes it to more hosts; this needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
24
24
  default:
25
25
  return _jsx(ManagePanel, { mode: mode });
26
26
  }
@@ -1,12 +1,27 @@
1
- import { jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
- import { describeDetails, describeLinks, describeNode, markersOf } from "./installations-tree.js";
4
- import { describePackageRow } from "./package-rows.js";
3
+ import { describeLinks, describeNode, markersOf } from "./installations-tree.js";
5
4
  import { rowStyle, theme } from "./theme.js";
5
+ function AppDetails({ row }) {
6
+ const version = row.inspection.installedVersion;
7
+ const statusColor = row.inspection.status === "invalid" ? theme.error : theme.warning;
8
+ const invalid = row.inspection.status === "invalid" ? ` · invalid recipe: ${row.inspection.reason ?? "unknown error"}` : "";
9
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: version === undefined ? statusColor : theme.version, children: version ?? row.inspection.status }), row.inspection.evidence === "recorded" ? _jsx(Text, { color: theme.value, children: " (recorded)" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
10
+ }
11
+ function PackageDetails({ row }) {
12
+ const version = row.installedVersion;
13
+ const installedPrefix = version === undefined ? "" : row.installed.startsWith("installed at ") ? "installed at " : "installed ";
14
+ const installedSuffix = version === undefined ? "" : row.installed.slice(installedPrefix.length + version.length);
15
+ const status = row.status.kind === "unknown" && row.status.reason === "local changes"
16
+ ? "update not checked · local changes"
17
+ : row.status.kind === "unknown" ? "cannot assess" : row.status.kind === "current" ? "up to date" : "update available";
18
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsxs(Text, { color: theme.value, children: [row.selection.host, " "] }), version === undefined ? _jsx(Text, { color: theme.warning, children: row.installed }) : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: installedPrefix }), _jsx(Text, { color: theme.version, children: version }), _jsx(Text, { color: theme.value, children: installedSuffix })] }), _jsx(Text, { color: theme.value, children: " available " }), _jsx(Text, { color: row.available === "unknown" ? theme.warning : theme.version, children: row.available }), _jsxs(Text, { color: theme.warning, children: [" ", status] }), row.drift === undefined ? null : _jsxs(Text, { color: theme.warning, children: [" drift: ", row.drift] })] }));
19
+ }
6
20
  /** One line of the Installations tree: cursor, indent, mark box, label and the leaf's details. */
7
21
  export function TreeRowLine({ row, selected, marked }) {
8
22
  const item = row.node.data;
9
- const leaf = !row.expandable && item.kind !== "group";
23
+ const markable = item.kind !== "group" && item.kind !== "package";
24
+ const leaf = !row.expandable && markable;
10
25
  const prefix = `${selected ? "> " : " "}${" ".repeat(row.depth)}${leaf ? (marked ? "[x] " : "[ ] ") : ""}`;
11
- return (_jsxs(Text, { ...rowStyle(selected), color: item.kind === "group" ? theme.group : undefined, children: [prefix, describeNode(row), item.kind === "installation" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeDetails(item.row), markersOf(item.row) === "" ? "" : ` ${markersOf(item.row)}`] })) : null, item.kind === "package" ? (_jsxs(Text, { color: theme.marker, children: [" ", describePackageRow(item.row)] })) : null, item.kind === "unmanaged" && describeLinks(item.group) !== "" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeLinks(item.group)] })) : null] }));
26
+ return (_jsxs(Text, { ...rowStyle(selected), color: item.kind === "group" ? theme.group : undefined, children: [prefix, item.kind === "app" ? item.row.entry.recipe?.name ?? item.row.name ?? item.row.entry.file : describeNode(row), item.kind === "installation" ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: item.row.installed === "unknown" ? theme.warning : theme.version, children: item.row.installed }), _jsxs(Text, { color: theme.field, children: [" (", item.row.policy, ") [", item.row.selection.hosts.join(", "), "]"] }), markersOf(item.row) === "" ? null : _jsxs(Text, { color: theme.marker, children: [" ", markersOf(item.row)] })] })) : null, item.kind === "app" ? _jsx(AppDetails, { row: item.row }) : null, item.kind === "package" ? _jsx(PackageDetails, { row: item.row }) : null, item.kind === "unmanaged" && describeLinks(item.group) !== "" ? (_jsxs(Text, { color: theme.success, children: [" ", describeLinks(item.group)] })) : null] }));
12
27
  }
@@ -49,10 +49,6 @@ export function buildTree(data) {
49
49
  });
50
50
  return roots;
51
51
  }
52
- /** Version, policy and hosts of a leaf; `adopted` / `modified` only when true (the legend explains them). */
53
- export function describeDetails({ selection, policy, installed }) {
54
- return `${installed} (${policy}) [${selection.hosts.join(", ")}]`;
55
- }
56
52
  export function markersOf({ adopted, modified }) {
57
53
  return [adopted ? "adopted" : "", modified ? "modified" : ""].filter((flag) => flag !== "").join(" ");
58
54
  }
@@ -78,11 +74,12 @@ export function describeNode(row) {
78
74
  return data.row.name;
79
75
  if (data.kind === "app") {
80
76
  const { entry, inspection } = data.row;
81
- const name = entry.recipe?.name ?? entry.file;
82
- if (inspection.status === "invalid")
77
+ const name = entry.recipe?.name ?? data.row.name ?? entry.file;
78
+ if (inspection.status === "invalid" && inspection.evidence !== "recorded")
83
79
  return `${name} invalid recipe: ${inspection.reason}`;
84
80
  const version = inspection.installedVersion ?? inspection.status;
85
- return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}`;
81
+ const invalid = inspection.status === "invalid" ? ` · invalid recipe: ${inspection.reason}` : "";
82
+ return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${invalid}`;
86
83
  }
87
84
  return `${data.group.name} [${data.group.hosts.join(", ")}]`;
88
85
  }
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { createAppOperations } from "../app-flow.js";
5
- import { handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
5
+ import { AppPanel, handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
6
6
  import { handlePackageKey, isPackageMode } from "./package-actions.js";
7
7
  import { packageOperationsOf } from "./environment.js";
8
8
  import { loadInstallations } from "./installations.js";
@@ -24,6 +24,7 @@ import { useKeys } from "./keys.js";
24
24
  const NO_ENVIRONMENT = {};
25
25
  const LEGEND = "adopted: tracked in place · modified: changed on disk";
26
26
  export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
27
+ const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
27
28
  const height = useListHeight(listHeight, 7);
28
29
  const env = environment ?? NO_ENVIRONMENT;
29
30
  const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
@@ -71,9 +72,15 @@ export function InstallationsView({ operations, environment, onCapturingChange,
71
72
  return;
72
73
  checked.current.add(identity);
73
74
  const generation = loadGeneration.current;
74
- void apps.inspect(entry).then(inspection => {
75
- if (!mounted.current || generation !== loadGeneration.current)
75
+ void apps.approvalStatus(entry).then(approval => {
76
+ if (approval.status !== "approved")
77
+ return undefined;
78
+ return apps.inspect(entry);
79
+ }).then(inspection => {
80
+ if (!inspection || !mounted.current || generation !== loadGeneration.current)
76
81
  return;
82
+ if (inspection.status === "unknown")
83
+ checked.current.delete(identity);
77
84
  setState(previous => previous.status !== "ready" ? previous : {
78
85
  ...previous,
79
86
  data: {
@@ -131,7 +138,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
131
138
  packages,
132
139
  startAdoption: (group) => void startAdoption(adoption, group),
133
140
  setMessage,
134
- startApps: (entries, input) => void startAppAction({ apps, manage }, entries, input),
141
+ startApps: (entries, input) => void startAppAction({ apps, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, entries, input),
135
142
  };
136
143
  useKeys((input, key) => {
137
144
  const current = modeRef.current;
@@ -140,7 +147,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
140
147
  else if (isPackageMode(current))
141
148
  handlePackageKey({ packages, manage }, current, input, key);
142
149
  else if (isAppMode(current))
143
- handleAppKey({ apps, manage }, current, input, key);
150
+ handleAppKey({ apps, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, current, input, key);
144
151
  else if (!handleManageKey(manage, current, input, key))
145
152
  handleAdoptionKey(adoption, current, input, key);
146
153
  });
@@ -148,6 +155,8 @@ export function InstallationsView({ operations, environment, onCapturingChange,
148
155
  return _jsx(Text, { children: "Loading installations..." });
149
156
  if (state.status === "error")
150
157
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
158
+ if (isAppMode(mode))
159
+ return _jsx(AppPanel, { mode: mode });
151
160
  const ready = state.data;
152
161
  const window = computeWindow(rows.length, index, height);
153
162
  const listed = rows.slice(window.start, window.end);
@@ -79,13 +79,15 @@ export async function loadInstallations(operations, environment, appOperations =
79
79
  let appsError;
80
80
  try {
81
81
  const tracked = await appOperations.trackedApps();
82
- apps = await Promise.all((await appOperations.load()).filter(entry => entry.applicable).map(async (entry) => ({
83
- entry,
84
- inspection: await appOperations.inspect(entry, {
85
- installedVersion: tracked.find(record => record.name === entry.recipe?.name
86
- && record.recipeFile === entry.canonicalFile)?.installedVersion,
87
- }),
88
- })));
82
+ apps = (await Promise.all((await appOperations.load()).filter(entry => entry.applicable).map(async (entry) => {
83
+ const record = tracked.find(item => item.recipeFile === entry.canonicalFile
84
+ && (item.name === entry.recipe?.name || entry.recipe === undefined));
85
+ const inspection = record && (entry.error || !entry.recipe)
86
+ ? { status: "invalid", installedVersion: record.installedVersion, evidence: "recorded", reason: entry.error }
87
+ : await appOperations.inspect(entry, record === undefined ? undefined : { installedVersion: record.installedVersion });
88
+ return { entry, inspection, ...(record === undefined ? {} : { name: record.name }) };
89
+ }))).filter(row => row.inspection.status === "installed"
90
+ || (row.inspection.status === "invalid" && row.inspection.evidence === "recorded"));
89
91
  }
90
92
  catch (error) {
91
93
  appsError = errorText(error);
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import { AppPanel } from "./app-actions.js";
4
4
  import { PackagePanel } from "./package-actions.js";
5
- import { ChecklistLines, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
5
+ import { ChecklistLines, DecisionPrompt, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
6
6
  import { theme } from "./theme.js";
7
7
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
8
8
  function SkippedLines({ skipped }) {
@@ -10,7 +10,7 @@ function SkippedLines({ skipped }) {
10
10
  }
11
11
  /** One block per planned item, the skipped ones, then the y/n question. */
12
12
  function PlanPreview({ entries, skipped, question }) {
13
- return (_jsxs(Box, { flexDirection: "column", children: [entries.map((entry) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.group, children: entry.label }), _jsx(PreviewLines, { lines: entry.lines, warnFirst: entry.warnFirst })] }, entry.id))), _jsx(SkippedLines, { skipped: skipped }), _jsx(Text, { children: question })] }));
13
+ return (_jsxs(Box, { flexDirection: "column", children: [entries.map((entry) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.group, children: entry.label }), _jsx(PreviewLines, { lines: entry.lines, warnFirst: entry.warnFirst })] }, entry.id))), _jsx(SkippedLines, { skipped: skipped }), _jsx(DecisionPrompt, { children: question })] }));
14
14
  }
15
15
  function UninstallPreview({ plan }) {
16
16
  const entries = plan.items.map((item) => ({
@@ -19,15 +19,16 @@ function UninstallPreview({ plan }) {
19
19
  lines: item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview,
20
20
  warnFirst: item.kind === "unmanaged",
21
21
  }));
22
- return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Uninstall ${plural(plan.items.length)}? y/n` });
22
+ return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Uninstall ${plural(plan.items.length)}?` });
23
23
  }
24
24
  function HostsPreview({ plan }) {
25
25
  const entries = plan.items.map((item) => ({ id: item.id, label: item.label, lines: item.prepared.preview, warnFirst: false }));
26
- return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Add hosts to ${plural(plan.items.length)}? y/n` });
26
+ return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Add hosts to ${plural(plan.items.length)}?` });
27
27
  }
28
28
  /** Renders the prompt of a manage mode: host lists, previews and confirmations. */
29
29
  export function ManagePanel({ mode }) {
30
30
  switch (mode.kind) {
31
+ case "app-busy":
31
32
  case "app-approve":
32
33
  case "app-hosts":
33
34
  case "app-confirm":
@@ -45,7 +46,7 @@ export function ManagePanel({ mode }) {
45
46
  case "confirm-host-add":
46
47
  return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Add hosts to ${mode.skill.name}? y/n` });
47
48
  case "confirm-host-exposure":
48
- return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.skill.name, " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
49
+ return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.skill.name, " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
49
50
  case "confirm-host-remove":
50
51
  return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove hosts from ${mode.skill.name}? y/n` });
51
52
  case "confirm-uninstall":
@@ -63,7 +64,7 @@ export function ManagePanel({ mode }) {
63
64
  case "confirm-bulk-hosts":
64
65
  return _jsx(HostsPreview, { plan: mode.plan });
65
66
  case "confirm-bulk-exposure":
66
- 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). Confirm? y/n"] }));
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?" })] }));
67
68
  case "busy":
68
69
  return _jsx(Text, { children: mode.label });
69
70
  }
@@ -7,6 +7,15 @@ export function moveCursor(cursor, length, input, key) {
7
7
  return Math.max(cursor - 1, 0);
8
8
  return undefined;
9
9
  }
10
+ /** Read-only preview navigation: arrows and page keys move a viewport; j/k move one line. */
11
+ export function movePreviewCursor(cursor, length, input, key, pageSize) {
12
+ const step = key.pageDown || key.downArrow ? pageSize : key.pageUp || key.upArrow ? -pageSize : 0;
13
+ if (step !== 0)
14
+ return Math.min(Math.max(cursor + step, 0), Math.max(length - 1, 0));
15
+ if (input === "j" || input === "k")
16
+ return moveCursor(cursor, length, input, key);
17
+ return undefined;
18
+ }
10
19
  /** `selected` with `choice` flipped, always in the order of `choices`. */
11
20
  export function toggleChoice(choices, selected, choice) {
12
21
  return choices.filter((candidate) => candidate === choice ? !selected.includes(candidate) : selected.includes(candidate));
@@ -51,15 +51,27 @@ export function packageRow(record, inspection) {
51
51
  selection: record.selection,
52
52
  name: inspection.descriptor?.name ?? describeCoordinates(record.selection),
53
53
  installed: installedEvidenceLabel(inspection.installed),
54
+ ...(inspection.installed.kind === "installed" && inspection.installed.version !== undefined
55
+ ? { installedVersion: inspection.installed.version }
56
+ : inspection.installed.kind === "installed" && inspection.installed.commit !== undefined
57
+ ? { installedVersion: inspection.installed.commit.slice(0, 7) }
58
+ : {}),
54
59
  available: packageVersionLabel(inspection.available),
55
- status: classifyUpdate(inspection.installed, inspection.available, inspection.availableCommit, inspection.installedCommitIsAncestor),
60
+ status: packageRowStatus(inspection),
56
61
  ...(inspection.drift === undefined ? {} : { drift: inspection.drift }),
57
62
  };
58
63
  }
59
- /** Host, evidence and status of one row, as the Installations line prints it. */
60
- export function describePackageRow(row) {
61
- const drift = row.drift === undefined ? "" : ` drift: ${row.drift}`;
62
- return `${row.selection.host} ${row.installed} available ${row.available} ${packageStatusLabel(row.status)}${drift}`;
64
+ function packageRowStatus(inspection) {
65
+ const status = classifyUpdate(inspection.installed, inspection.available, inspection.availableCommit, inspection.installedCommitIsAncestor);
66
+ if (status === "unknown") {
67
+ return {
68
+ kind: "unknown",
69
+ reason: inspection.installed.kind === "installed" && /local modifications|local changes/iu.test(inspection.installed.reason ?? "")
70
+ ? "local changes"
71
+ : "unknown",
72
+ };
73
+ }
74
+ return { kind: status };
63
75
  }
64
76
  /** One component of the inventory; the executable effect and a host-only owner are called out. */
65
77
  export function describePackageComponent(component) {
@@ -1,6 +1,17 @@
1
- import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { theme } from "./theme.js";
4
+ import { previewViewport } from "./preview-window.js";
5
+ /** A titled, color-independent boundary for a focused interaction or result. */
6
+ export function BorderPanel({ title, children }) {
7
+ return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.title, bold: true, children: title }), children] }));
8
+ }
9
+ export function FieldLine({ label, value }) {
10
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
11
+ }
12
+ export function DecisionPrompt({ children }) {
13
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: children }), " ", _jsx(Text, { color: theme.success, bold: true, children: "y" }), _jsx(Text, { color: theme.legend, children: "/" }), _jsx(Text, { color: theme.error, children: "n" })] }));
14
+ }
4
15
  /** The `> [x] 1 name` rows of a checklist; the caller renders the header. */
5
16
  export function ChecklistLines({ choices, cursor, isSelected, keyOf, label }) {
6
17
  return (_jsx(_Fragment, { children: choices.map((choice, position) => (_jsxs(Text, { children: [position === cursor ? "> " : " ", "[", isSelected(choice) ? "x" : " ", "] ", position + 1, " ", label(choice)] }, keyOf(choice)))) }));
@@ -10,14 +21,35 @@ export function HostChecklist({ choices, cursor, selected }) {
10
21
  return _jsx(ChecklistLines, { choices: choices, cursor: cursor, isSelected: (host) => selected.includes(host), keyOf: (host) => host, label: (host) => host });
11
22
  }
12
23
  /** Preview lines; `warnFirst` colors the first one as a warning. */
24
+ const PREVIEW_FIELD_LABELS = [
25
+ "File:", "Name:", "Platform:", "Status:", "Homepage:", "Owned Skills:",
26
+ "App:", "Environment:", "Version:", "argv:", "Executable:", "Manual:",
27
+ "Declaration:", "Version pattern:", "Working directory:", "Previous version:",
28
+ "Latest version:", "Package:", "Host:", "Source:", "Scope:", "Coordinates:", "Origin:",
29
+ "Available:", "Approval:", "Path:", "Blast radius:", "Warning:", " target:", " proposed changes:",
30
+ " external command:", " external cwd:", " external target:",
31
+ ];
32
+ function previewField(line) {
33
+ const label = PREVIEW_FIELD_LABELS.find(candidate => line.startsWith(candidate));
34
+ return label === undefined ? undefined : { label, value: line.slice(label.length) };
35
+ }
13
36
  export function PreviewLines({ lines, warnFirst = false }) {
14
- return (_jsx(_Fragment, { children: lines.map((line, position) => _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : undefined, children: line }, position)) }));
37
+ return (_jsx(_Fragment, { children: lines.map((line, position) => {
38
+ const field = previewField(line);
39
+ return field ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: field.label }), _jsx(Text, { color: theme.value, children: field.value })] }, position)) : _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : theme.value, children: line }, position);
40
+ }) }));
15
41
  }
16
42
  /** Under a list: the window indicator, how many items are marked, and the last result. */
17
43
  export function ListFooter({ indicator, selectedCount, message }) {
18
- return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, selectedCount > 0 ? _jsxs(Text, { color: theme.muted, children: [selectedCount, " selected"] }) : null, message ? _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) : null] }));
44
+ return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, selectedCount > 0 ? _jsxs(Text, { color: theme.muted, children: [selectedCount, " selected"] }) : null, message ? (_jsx(BorderPanel, { title: message.kind === "error" ? "Result · failed" : "Result · complete", children: _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) })) : null] }));
19
45
  }
20
46
  /** A preview followed by a y/n question. */
21
- export function PreviewConfirm({ lines, question, warnFirst }) {
22
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: lines, warnFirst: warnFirst }), _jsx(Text, { children: question })] }));
47
+ export function PreviewConfirm({ title = "Confirmation", lines, question, warnFirst, cursor, chrome }) {
48
+ const { rows, columns } = useWindowSize();
49
+ const viewport = cursor === undefined ? undefined : previewViewport(lines, cursor, rows || 24, columns || 80, chrome ?? { title, questions: [question], footerRows: 2 });
50
+ const shown = viewport?.rows ?? lines;
51
+ return (_jsxs(BorderPanel, { title: title, children: [_jsx(PreviewLines, { lines: shown, warnFirst: warnFirst && (viewport?.start ?? 0) === 0 }), viewport && viewport.total > viewport.height ? _jsxs(Text, { color: theme.muted, children: [viewport.start + 1, "\u2013", viewport.end, " of ", viewport.total] }) : null, viewport ? _jsxs(Text, { color: theme.legend, children: ["Review ", viewport.cursor + 1, " of ", viewport.total, " \u00B7 arrows/Pg page"] }) : null, question.includes("y/n") ? (() => {
52
+ const marker = question.indexOf("y/n");
53
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: question.slice(0, marker) }), _jsx(Text, { color: theme.success, bold: true, children: "y" }), _jsx(Text, { color: theme.legend, children: "/" }), _jsx(Text, { color: theme.error, children: "n" }), _jsx(Text, { color: theme.legend, children: question.slice(marker + 3) })] });
54
+ })() : _jsx(Text, { color: theme.legend, children: question })] }));
23
55
  }
@@ -0,0 +1,48 @@
1
+ import stringWidth from "string-width";
2
+ import { headerTabRowCount } from "./header-layout.js";
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
+ function wrapLine(line, width) {
5
+ const segments = [...graphemes.segment(line)];
6
+ if (segments.length === 0)
7
+ return [""];
8
+ const wrapped = [];
9
+ let row = "";
10
+ let rowWidth = 0;
11
+ for (const { segment } of segments) {
12
+ const segmentWidth = stringWidth(segment);
13
+ if (row !== "" && rowWidth + segmentWidth > width) {
14
+ wrapped.push(row);
15
+ row = "";
16
+ rowWidth = 0;
17
+ }
18
+ row += segment;
19
+ rowWidth += segmentWidth;
20
+ }
21
+ wrapped.push(row);
22
+ return wrapped;
23
+ }
24
+ export function previewViewport(lines, cursor, terminalRows, terminalColumns, chrome = { title: "Confirmation", questions: ["Scroll to end before y; n cancels.", "Confirm? y/n"] }) {
25
+ const contentWidth = Math.max(terminalColumns - 8, 1);
26
+ const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map((row) => ({ row, sourceLine })));
27
+ const previewRows = wrapped.map(({ row }) => row);
28
+ const headerRows = headerTabRowCount(contentWidth) + 3;
29
+ const titleRows = wrapLine(chrome.title, contentWidth).length;
30
+ const questionRows = Math.max(...chrome.questions.map(question => wrapLine(question, contentWidth).length));
31
+ const panelChromeRows = 2 + titleRows + questionRows + (chrome.footerRows ?? 1);
32
+ const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
33
+ const availableRows = Math.max(terminalRows - headerRows - 1 - panelChromeRows - reservedRows, 1);
34
+ const height = previewRows.length > availableRows ? Math.max(availableRows - 1, 1) : availableRows;
35
+ const selected = Math.min(Math.max(cursor, 0), Math.max(previewRows.length - 1, 0));
36
+ const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(previewRows.length - height, 0));
37
+ const end = Math.min(start + height, previewRows.length);
38
+ return {
39
+ rows: previewRows.slice(start, end),
40
+ sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
41
+ start,
42
+ end,
43
+ total: previewRows.length,
44
+ cursor: selected,
45
+ height,
46
+ reachedEnd: end === previewRows.length,
47
+ };
48
+ }