@iseoane/agent-depot 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.es.md +137 -34
  3. package/README.md +120 -29
  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/file-picker-model.js +87 -0
  16. package/dist/src/tui/file-picker.js +142 -0
  17. package/dist/src/tui/header-layout.js +33 -0
  18. package/dist/src/tui/installations-panel.js +3 -3
  19. package/dist/src/tui/installations-rows.js +20 -5
  20. package/dist/src/tui/installations-tree.js +4 -7
  21. package/dist/src/tui/installations-view.js +15 -6
  22. package/dist/src/tui/installations.js +9 -7
  23. package/dist/src/tui/manage-panel.js +7 -6
  24. package/dist/src/tui/mode-keys.js +9 -0
  25. package/dist/src/tui/package-rows.js +17 -5
  26. package/dist/src/tui/panel-parts.js +119 -6
  27. package/dist/src/tui/preview-window.js +51 -0
  28. package/dist/src/tui/profile-view.js +394 -136
  29. package/dist/src/tui/sources-mode.js +3 -1
  30. package/dist/src/tui/sources-recipes.js +50 -19
  31. package/dist/src/tui/sources-view.js +50 -14
  32. package/dist/src/tui/text-wrap.js +53 -0
  33. package/dist/src/tui/theme.js +14 -7
  34. package/dist/src/tui/updates-flow.js +2 -2
  35. package/dist/src/tui/updates-keys.js +34 -5
  36. package/dist/src/tui/updates-model.js +2 -0
  37. package/dist/src/tui/updates-panel.js +40 -15
  38. package/dist/src/tui/updates-preview.js +65 -0
  39. package/dist/src/tui/updates-view.js +17 -2
  40. package/dist/src/tui/updates.js +14 -5
  41. package/dist/src/tui/window.js +8 -6
  42. package/package.json +6 -6
  43. package/skills/doctor-md-agents/README.md +3 -1
  44. package/skills/doctor-md-skill/README.md +2 -2
@@ -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,116 @@ 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
+ }
36
+ function previewSpan(text, color, bold = false) {
37
+ return { text, color, bold };
38
+ }
39
+ function profilePreviewSpans(line) {
40
+ if (line.startsWith("Destination: ")) {
41
+ return [previewSpan("Destination:", theme.field), previewSpan(line.slice("Destination:".length), theme.value)];
42
+ }
43
+ if (line === "Preview: export portable user-global profile" || line === "Preview: import portable user-global profile") {
44
+ return [previewSpan("Preview:", theme.field), previewSpan(line.slice("Preview:".length), theme.value)];
45
+ }
46
+ if (line.startsWith("WARNING:"))
47
+ return [previewSpan(line, theme.warning)];
48
+ const source = /^Source (git:[^ ]+) (.+) \(included: (true|false)\)$/u.exec(line);
49
+ if (source)
50
+ return [previewSpan("Source", theme.field), previewSpan(" ", theme.value),
51
+ previewSpan(source[1], theme.muted), previewSpan(" ", theme.value), previewSpan(source[2], theme.value),
52
+ previewSpan(" (", theme.value), previewSpan("included:", theme.field), previewSpan(` ${source[3]})`, theme.value)];
53
+ const skill = /^Skill (.+) \[([^\]]*)\] \((latest|fixed(?: [^)]+)?)\)$/u.exec(line);
54
+ if (skill)
55
+ return [previewSpan("Skill", theme.field), previewSpan(" ", theme.value), previewSpan(skill[1], theme.value, true),
56
+ previewSpan(" [", theme.value), previewSpan(skill[2], theme.marker), previewSpan("] (", theme.value),
57
+ previewSpan(skill[3], theme.version), previewSpan(")", theme.value)];
58
+ const app = /^App recipe (.+) \(([^;]+); approval not transferred\)$/u.exec(line);
59
+ if (app)
60
+ return [previewSpan("App recipe", theme.field), previewSpan(" ", theme.value), previewSpan(app[1], theme.value, true),
61
+ previewSpan(" (", theme.value), previewSpan(app[2], theme.marker), previewSpan("; approval not transferred)", theme.value)];
62
+ const packageLine = /^Package (.+) from (.+) \((latest|fixed [^;]+); saved selection\)(.*)$/u.exec(line);
63
+ if (packageLine)
64
+ return [previewSpan("Package", theme.field), previewSpan(" ", theme.value),
65
+ previewSpan(packageLine[1], theme.value, true), previewSpan(" from ", theme.value), previewSpan(packageLine[2], theme.value),
66
+ previewSpan(" (", theme.value), previewSpan(packageLine[3], theme.version),
67
+ previewSpan(`; saved selection)${packageLine[4]}`, theme.value)];
68
+ const replica = /^Replica intent (Installed Skill|Installed App|Installed Package) (.+?)(; .*)?$/u.exec(line);
69
+ if (replica)
70
+ return [previewSpan("Replica intent", theme.field), previewSpan(" ", theme.value),
71
+ previewSpan(replica[1], theme.field), previewSpan(" ", theme.value), previewSpan(replica[2], theme.value, true),
72
+ previewSpan(replica[3] ?? "", theme.value)];
73
+ if (line.startsWith(" Source "))
74
+ return [previewSpan(" Source", theme.field), previewSpan(line.slice(" Source".length), theme.value)];
75
+ if (line.startsWith("Origin replica intent:"))
76
+ return [previewSpan("Origin replica intent:", theme.field),
77
+ previewSpan(line.slice("Origin replica intent:".length), theme.value)];
78
+ const imported = /^(add|same|conflict|skip)( \(discovery inclusion is not imported\))?: (Source|Skill|App recipe|Package) (.+)$/u.exec(line);
79
+ if (imported)
80
+ return [previewSpan(`${imported[1]}${imported[2] ?? ""}:`, theme.field), previewSpan(` ${imported[3]}`, theme.field),
81
+ previewSpan(" ", theme.value), previewSpan(imported[4], theme.value, true)];
82
+ const hosts = /^ {2}hosts: ([^;]*); version policy: (latest|fixed(?: .+)?)$/u.exec(line);
83
+ if (hosts)
84
+ return [previewSpan(" hosts:", theme.field), previewSpan(" ", theme.value),
85
+ previewSpan(hosts[1], theme.marker), previewSpan("; ", theme.value), previewSpan("version policy:", theme.field),
86
+ previewSpan(` ${hosts[2]}`, theme.version)];
87
+ return undefined;
88
+ }
89
+ function visiblePreviewSpans(spans, offset, length) {
90
+ const end = offset + length;
91
+ let spanStart = 0;
92
+ const visible = [];
93
+ for (const span of spans) {
94
+ const spanEnd = spanStart + span.text.length;
95
+ const start = Math.max(offset, spanStart);
96
+ const stop = Math.min(end, spanEnd);
97
+ if (start < stop)
98
+ visible.push({ ...span, text: span.text.slice(start - spanStart, stop - spanStart) });
99
+ spanStart = spanEnd;
100
+ }
101
+ return visible;
102
+ }
103
+ function PreviewLine({ line, position, warnFirst, logicalLine, offset = 0 }) {
104
+ const profileSpans = logicalLine === undefined ? profilePreviewSpans(line) : profilePreviewSpans(logicalLine);
105
+ if (profileSpans) {
106
+ const spans = logicalLine === undefined ? profileSpans : visiblePreviewSpans(profileSpans, offset, line.length);
107
+ return _jsx(Text, { children: spans.map((span, index) => _jsx(Text, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
108
+ }
109
+ const field = previewField(logicalLine ?? line);
110
+ if (field) {
111
+ const fieldSpans = [previewSpan(field.label, theme.field), previewSpan(field.value, theme.value)];
112
+ const spans = logicalLine === undefined ? fieldSpans : visiblePreviewSpans(fieldSpans, offset, line.length);
113
+ return _jsx(Text, { children: spans.map((span, index) => _jsx(Text, { color: span.color, children: span.text }, index)) }, position);
114
+ }
115
+ return _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : theme.value, children: line }, position);
116
+ }
13
117
  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)) }));
118
+ return _jsx(_Fragment, { children: lines.map((line, position) => _jsx(PreviewLine, { line: line, position: position, warnFirst: warnFirst }, position)) });
15
119
  }
16
120
  /** Under a list: the window indicator, how many items are marked, and the last result. */
17
121
  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] }));
122
+ 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
123
  }
20
124
  /** 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 })] }));
125
+ export function PreviewConfirm({ title = "Confirmation", lines, question, warnFirst, cursor, chrome }) {
126
+ const { rows, columns } = useWindowSize();
127
+ const viewport = cursor === undefined ? undefined : previewViewport(lines, cursor, rows || 24, columns || 80, chrome ?? { title, questions: [question], footerRows: 2 });
128
+ const shown = viewport?.rows ?? lines;
129
+ return (_jsxs(BorderPanel, { title: title, children: [viewport ? shown.map((line, position) => {
130
+ const sourceLine = viewport.sourceLines[position];
131
+ return _jsx(PreviewLine, { line: line, position: position, warnFirst: Boolean(warnFirst && viewport.start === 0), logicalLine: lines[sourceLine], offset: viewport.sourceOffsets[position] }, position);
132
+ }) : _jsx(PreviewLines, { lines: shown, warnFirst: warnFirst }), 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") ? (() => {
133
+ const marker = question.indexOf("y/n");
134
+ 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) })] });
135
+ })() : _jsx(Text, { color: theme.legend, children: question })] }));
23
136
  }
@@ -0,0 +1,51 @@
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 [{ row: "", offset: 0 }];
8
+ const wrapped = [];
9
+ let row = "";
10
+ let rowWidth = 0;
11
+ let rowOffset = 0;
12
+ for (const { segment, index } of segments) {
13
+ const segmentWidth = stringWidth(segment);
14
+ if (row !== "" && rowWidth + segmentWidth > width) {
15
+ wrapped.push({ row, offset: rowOffset });
16
+ row = "";
17
+ rowWidth = 0;
18
+ rowOffset = index;
19
+ }
20
+ row += segment;
21
+ rowWidth += segmentWidth;
22
+ }
23
+ wrapped.push({ row, offset: rowOffset });
24
+ return wrapped;
25
+ }
26
+ export function previewViewport(lines, cursor, terminalRows, terminalColumns, chrome = { title: "Confirmation", questions: ["Scroll to end before y; n cancels.", "Confirm? y/n"] }) {
27
+ const contentWidth = Math.max(terminalColumns - 8, 1);
28
+ const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map(({ row, offset }) => ({ row, sourceLine, offset })));
29
+ const previewRows = wrapped.map(({ row }) => row);
30
+ const headerRows = headerTabRowCount(contentWidth) + 3;
31
+ const titleRows = wrapLine(chrome.title, contentWidth).length;
32
+ const questionRows = Math.max(...chrome.questions.map(question => wrapLine(question, contentWidth).length));
33
+ const panelChromeRows = 2 + titleRows + questionRows + (chrome.footerRows ?? 1);
34
+ const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
35
+ const availableRows = Math.max(terminalRows - headerRows - 1 - panelChromeRows - reservedRows, 1);
36
+ const height = previewRows.length > availableRows ? Math.max(availableRows - 1, 1) : availableRows;
37
+ const selected = Math.min(Math.max(cursor, 0), Math.max(previewRows.length - 1, 0));
38
+ const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(previewRows.length - height, 0));
39
+ const end = Math.min(start + height, previewRows.length);
40
+ return {
41
+ rows: previewRows.slice(start, end),
42
+ sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
43
+ sourceOffsets: wrapped.slice(start, end).map(({ offset }) => offset),
44
+ start,
45
+ end,
46
+ total: previewRows.length,
47
+ cursor: selected,
48
+ height,
49
+ reachedEnd: end === previewRows.length,
50
+ };
51
+ }