@iseoane/agent-depot 0.6.13 → 0.6.15

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 (55) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/src/app-flow.js +8 -1
  4. package/dist/src/atomic-file.js +29 -3
  5. package/dist/src/cli.js +2 -0
  6. package/dist/src/flow-progress.js +9 -0
  7. package/dist/src/git-source.js +257 -15
  8. package/dist/src/preview-model.js +37 -0
  9. package/dist/src/process-runner.js +1 -0
  10. package/dist/src/profile-cli.js +12 -4
  11. package/dist/src/profile-export.js +23 -3
  12. package/dist/src/skill-update.js +1 -0
  13. package/dist/src/source-refresh-session.js +139 -0
  14. package/dist/src/sources.js +4 -4
  15. package/dist/src/tui/adoption-actions.js +21 -2
  16. package/dist/src/tui/app-actions.js +57 -47
  17. package/dist/src/tui/app.js +22 -4
  18. package/dist/src/tui/bulk-actions.js +45 -11
  19. package/dist/src/tui/catalog-actions.js +12 -0
  20. package/dist/src/tui/catalog-flow.js +31 -11
  21. package/dist/src/tui/catalog-panel.js +15 -13
  22. package/dist/src/tui/catalog-tree.js +7 -2
  23. package/dist/src/tui/catalog-view.js +12 -6
  24. package/dist/src/tui/file-picker.js +2 -1
  25. package/dist/src/tui/installations-browse.js +1 -1
  26. package/dist/src/tui/installations-panel.js +12 -4
  27. package/dist/src/tui/installations-rows.js +2 -2
  28. package/dist/src/tui/installations-tree.js +1 -1
  29. package/dist/src/tui/installations-view.js +16 -7
  30. package/dist/src/tui/keys.js +35 -4
  31. package/dist/src/tui/manage-actions.js +55 -27
  32. package/dist/src/tui/manage-panel.js +15 -29
  33. package/dist/src/tui/mode-keys.js +11 -1
  34. package/dist/src/tui/package-actions.js +25 -18
  35. package/dist/src/tui/package-rows.js +9 -4
  36. package/dist/src/tui/panel-parts.js +54 -37
  37. package/dist/src/tui/preview-render.js +93 -0
  38. package/dist/src/tui/preview-window.js +185 -20
  39. package/dist/src/tui/profile-view.js +54 -44
  40. package/dist/src/tui/render.js +13 -1
  41. package/dist/src/tui/safe-text.js +25 -0
  42. package/dist/src/tui/sources-mode.js +2 -2
  43. package/dist/src/tui/sources-recipes.js +53 -32
  44. package/dist/src/tui/sources-view.js +85 -39
  45. package/dist/src/tui/text-wrap.js +47 -3
  46. package/dist/src/tui/unmanaged-actions.js +17 -5
  47. package/dist/src/tui/updates-flow.js +11 -5
  48. package/dist/src/tui/updates-keys.js +2 -2
  49. package/dist/src/tui/updates-model.js +5 -2
  50. package/dist/src/tui/updates-panel.js +10 -9
  51. package/dist/src/tui/updates-preview.js +27 -44
  52. package/dist/src/tui/updates-view.js +15 -11
  53. package/dist/src/tui/updates.js +117 -33
  54. package/dist/src/update-flow.js +31 -15
  55. package/package.json +1 -1
@@ -1,12 +1,13 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
4
+ import { describeSkills, HOST_CHOICES, installPreview, SCOPE_CHOICES } from "./catalog-actions.js";
4
5
  import { packageOwnedSkillNotice, packageOwnerBadge } from "./catalog-ownership.js";
5
- import { skillKey } from "./catalog-tree.js";
6
6
  import { hasUnmanagedCopy } from "./catalog-installs.js";
7
- import { describeRow, truncateDescription, UNMANAGED_MARKER } from "./catalog-tree.js";
7
+ import { describeRow, singleLine, truncateDescription, UNMANAGED_MARKER } from "./catalog-tree.js";
8
8
  import { isPackageMode, PackagePanel } from "./package-actions.js";
9
- import { BorderPanel, DecisionPrompt, HostChecklist, PreviewLines, Working } from "./panel-parts.js";
9
+ import { BorderPanel, DecisionPrompt, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
10
+ import { usePreviewGeometry } from "./preview-window.js";
10
11
  import { rowStyle } from "./theme.js";
11
12
  import { theme } from "./theme.js";
12
13
  /** Scope and counts, the filter line, and why the list may be empty. */
@@ -19,7 +20,7 @@ export function CatalogRowLine({ row, selected, marked, installed }) {
19
20
  const isGroup = data.kind === "source" || data.kind === "group";
20
21
  const ownedBy = data.kind === "skill" ? data.ownedBy : undefined;
21
22
  return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : undefined, wrap: "truncate-end", children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", data.kind === "skill" && ownedBy !== undefined
22
- ? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description), data.variant === true ? ` (${data.skill.path})` : ""] })
23
+ ? _jsxs(_Fragment, { children: [singleLine(data.skill.name), _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description), data.variant === true ? ` (${data.skill.path})` : ""] })
23
24
  : data.kind === "app" ? `App ${data.row.entry.recipe?.name ?? data.row.entry.file}` : describeRow(row), data.kind === "app" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: data.row.inspection.status === "not installed" ? theme.warning : theme.value, children: data.row.inspection.status })] })) : null, data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
24
25
  ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
25
26
  : null] }));
@@ -29,6 +30,11 @@ export function CatalogSelectionHelp({ row, height }) {
29
30
  const owner = row?.node.data.kind === "skill" ? row.node.data.ownedBy : undefined;
30
31
  return (_jsx(Box, { flexDirection: "column", minHeight: height, children: owner === undefined ? null : _jsx(Text, { color: theme.marker, children: packageOwnedSkillNotice(owner) }) }));
31
32
  }
33
+ /** The install confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
34
+ export function InstallConfirmPanel({ mode }) {
35
+ const { lines, chrome } = installPreview(mode.items);
36
+ return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
37
+ }
32
38
  /** The prompt of the install flow; browsing shows nothing. */
33
39
  export function ActionPanel({ mode }) {
34
40
  if (isPackageMode(mode))
@@ -44,17 +50,13 @@ export function ActionPanel({ mode }) {
44
50
  return _jsx(BorderPanel, { title: "Skill version policy", children: _jsx(Text, { children: "1 latest 2 fixed (Esc cancel)" }) });
45
51
  case "fixed":
46
52
  return _jsx(BorderPanel, { title: "Fixed Skill version", children: _jsxs(Text, { children: [mode.value, "_ (Enter confirm, Esc cancel)"] }) });
47
- case "confirm-install": {
48
- const ready = mode.items.filter((item) => item.prepared !== undefined);
49
- const single = mode.items.length === 1;
50
- const projectTarget = ready[0]?.prepared?.projectTarget ?? [];
51
- return (_jsxs(BorderPanel, { title: `Skill install preview · ${ready.length} ready`, children: [projectTarget.length === 0 ? null : _jsx(PreviewLines, { lines: projectTarget }), mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(DecisionPrompt, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "?"] })] }));
52
- }
53
+ case "confirm-install":
54
+ return _jsx(InstallConfirmPanel, { mode: mode });
53
55
  case "confirm-exposure": {
54
56
  const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
55
57
  return (_jsx(BorderPanel, { title: `Skill Host exposure · ${names.length} confirmation`, children: _jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation. ", _jsx(DecisionPrompt, { children: "Confirm?" })] }) }));
56
58
  }
57
59
  case "busy":
58
- return _jsx(BorderPanel, { title: "Skill install", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
60
+ return (_jsxs(BorderPanel, { title: "Skill install", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), (mode.done ?? []).length > 0 ? _jsxs(Text, { color: theme.muted, children: [mode.done.length, " finished"] }) : null] }));
59
61
  }
60
62
  }
@@ -8,8 +8,13 @@ export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installatio
8
8
  export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
9
9
  /** Keyed on the full selection, so two plugins of one marketplace root stay distinct nodes. */
10
10
  export const bundleKey = (entry) => `bundle:${entry.sourceId}:${selectionKey(entry.selection)}`;
11
+ /** A Skill's name or description on one row; a line break from a remote SKILL.md would add rows to the list window. */
12
+ export function singleLine(text) {
13
+ return text.replace(/[\r\n]+/gu, " ");
14
+ }
11
15
  export function truncateDescription(text) {
12
- return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
16
+ const line = singleLine(text);
17
+ return line.length > DESCRIPTION_LIMIT ? `${line.slice(0, DESCRIPTION_LIMIT - 3)}...` : line;
13
18
  }
14
19
  /** A repository name, with directory and ref for scoped GitHub Sources. */
15
20
  export function sourceLabel(source) {
@@ -107,7 +112,7 @@ export function describeRow(row) {
107
112
  }
108
113
  if (data.kind === "app")
109
114
  return `App ${data.row.entry.recipe?.name ?? data.row.entry.file} ${data.row.inspection.status}`;
110
- return `${data.skill.name} ${truncateDescription(data.skill.description)}${data.variant === true ? ` (${data.skill.path})` : ""}`;
115
+ return `${singleLine(data.skill.name)} ${truncateDescription(data.skill.description)}${data.variant === true ? ` (${data.skill.path})` : ""}`;
111
116
  }
112
117
  /** Skill nodes inside a Source's Skills group, directly or within one of its variant groups. */
113
118
  function skillNodesOf(roots) {
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useWindowSize } from "ink";
2
+ import { Box, useWindowSize } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createAppOperations } from "../app-flow.js";
5
6
  import { BROWSE } from "./catalog-actions.js";
@@ -8,7 +9,7 @@ import { filterSkills } from "./catalog-filter.js";
8
9
  import { handleCatalogActionKey } from "./catalog-flow.js";
9
10
  import { isInstalled } from "./catalog-installs.js";
10
11
  import { AppPanel, handleAppKey, startAppAction } from "./app-actions.js";
11
- import { ActionPanel, CatalogHeader, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
12
+ import { ActionPanel, CatalogHeader, InstallConfirmPanel, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
12
13
  import { packageOwnedSkillNotice } from "./catalog-ownership.js";
13
14
  import { packageOperationsOf } from "./environment.js";
14
15
  import { handlePackageKey, isPackageMode, PackagePanel, startPackageAction } from "./package-actions.js";
@@ -21,6 +22,7 @@ import { invalidateRetention } from "./view-retention.js";
21
22
  import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
22
23
  import { computeWindow, useListHeight } from "./window.js";
23
24
  import { useKeys } from "./keys.js";
25
+ import { usePreviewGeometry } from "./preview-window.js";
24
26
  const NO_ENVIRONMENT = {};
25
27
  function wrappedLineCount(text, columns) {
26
28
  let lines = 1;
@@ -41,7 +43,8 @@ function wrappedLineCount(text, columns) {
41
43
  return lines;
42
44
  }
43
45
  export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight, retention }) {
44
- const { columns, rows: terminalRows } = useWindowSize();
46
+ const { columns } = useWindowSize();
47
+ const previewGeometry = usePreviewGeometry();
45
48
  const env = environment ?? NO_ENVIRONMENT;
46
49
  const [all, setAll] = useState(sourceId === undefined);
47
50
  const mounted = useMounted();
@@ -110,6 +113,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
110
113
  const flow = {
111
114
  operations,
112
115
  env,
116
+ previewGeometry,
113
117
  setAction,
114
118
  setMessage,
115
119
  isMounted: () => mounted.current,
@@ -158,7 +162,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
158
162
  setAction(BROWSE);
159
163
  },
160
164
  };
161
- const packageContext = { packages, manage: packageHost, terminalRows: terminalRows || 24, terminalColumns: columns || 80 };
165
+ const packageContext = { packages, manage: packageHost, previewGeometry };
162
166
  const browse = {
163
167
  navigation,
164
168
  height,
@@ -168,14 +172,14 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
168
172
  ready: state.status === "ready" && appCatalog.status === "ready",
169
173
  toggleAll: () => setAll((value) => !value),
170
174
  startPackage: (target) => void startPackageAction(packageContext, target, "i"),
171
- startApp: (entry) => void startAppAction({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, [entry], "i"),
175
+ startApp: (entry) => void startAppAction({ apps, manage: appManage, previewGeometry }, [entry], "i"),
172
176
  setMessage,
173
177
  setAction,
174
178
  };
175
179
  useKeys((input, key) => {
176
180
  const runningAppMode = appModeRef.current;
177
181
  if (runningAppMode !== undefined)
178
- handleAppKey({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, runningAppMode, input, key);
182
+ handleAppKey({ apps, manage: appManage, previewGeometry }, runningAppMode, input, key);
179
183
  else {
180
184
  const mode = actionRef.current;
181
185
  if (isPackageMode(mode))
@@ -200,5 +204,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
200
204
  // A paged declaration owns the view: rows of the tree above it would not be counted in its height.
201
205
  if (isPackageMode(action))
202
206
  return _jsx(PackagePanel, { mode: action });
207
+ if (action.kind === "confirm-install")
208
+ return _jsx(InstallConfirmPanel, { mode: action });
203
209
  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 })] }));
204
210
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import path from "node:path";
3
- import { Text, useWindowSize } from "ink";
3
+ import { useWindowSize } from "ink";
4
+ import { Text } from "./safe-text.js";
4
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6
  import { pickerEntries, readDirectoryListing, PROFILE_EXTENSION } from "./file-picker-model.js";
6
7
  import { useKeys } from "./keys.js";
@@ -107,7 +107,7 @@ function actOnRow(context, snapshot, row, input, key) {
107
107
  else if (item.kind === "app") {
108
108
  if (input === "A")
109
109
  context.setMessage({ kind: "error", text: "Adoption applies to unmanaged Skills, not Apps" });
110
- else if (key.return && item.row.inspection.status !== "needs approval") {
110
+ else if (key.return && item.row.inspection.status !== "needs approval" && !item.row.inspection.needsApproval) {
111
111
  const { entry, inspection } = item.row;
112
112
  const approved = inspection.status === "installed" || inspection.status === "not installed";
113
113
  context.setMessage({
@@ -1,9 +1,17 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
4
+ import { adoptionPreview } from "./adoption-actions.js";
3
5
  import { HOST_CHOICES } from "./catalog-actions.js";
4
6
  import { ManagePanel } from "./manage-panel.js";
5
- import { DecisionPrompt, HostChecklist, PreviewLines } from "./panel-parts.js";
7
+ import { DecisionPrompt, HostChecklist, PagedPreview } from "./panel-parts.js";
8
+ import { usePreviewGeometry } from "./preview-window.js";
6
9
  import { theme } from "./theme.js";
10
+ /** An adoptable preview with the shared footer; the view shows it alone so no tree rows count against its height. */
11
+ export function AdoptionConfirmPanel({ mode }) {
12
+ const { lines, chrome } = adoptionPreview(mode);
13
+ return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
14
+ }
7
15
  /** The prompt of the adoption and manage modes; browsing shows nothing. */
8
16
  export function ModePanel({ mode }) {
9
17
  switch (mode.kind) {
@@ -18,7 +26,7 @@ export function ModePanel({ mode }) {
18
26
  case "fixed":
19
27
  return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
20
28
  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(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" })] }));
29
+ return mode.adoption.verdict.adoptable ? (_jsx(AdoptionConfirmPanel, { mode: mode })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.error, children: mode.adoption.verdict.reason }), _jsx(Text, { children: "Enter/Esc to go back" })] }));
22
30
  case "confirm-exposure":
23
31
  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
32
  default:
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Text } from "ink";
2
+ import { Text } from "./safe-text.js";
3
3
  import { describeLinks, describeNode, markersOf } from "./installations-tree.js";
4
4
  import { rowStyle, theme } from "./theme.js";
5
5
  function AppDetails({ row }) {
6
6
  const version = row.inspection.installedVersion;
7
7
  const statusColor = row.inspection.status === "invalid" ? theme.error : theme.warning;
8
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 })] }));
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, row.inspection.needsApproval ? _jsx(Text, { color: theme.warning, children: " \u00B7 needs approval" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
10
10
  }
11
11
  function PackageDetails({ row }) {
12
12
  const version = row.installedVersion;
@@ -79,7 +79,7 @@ export function describeNode(row) {
79
79
  return `${name} invalid recipe: ${inspection.reason}`;
80
80
  const version = inspection.installedVersion ?? inspection.status;
81
81
  const invalid = inspection.status === "invalid" ? ` · invalid recipe: ${inspection.reason}` : "";
82
- return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${invalid}`;
82
+ return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${inspection.needsApproval ? " · needs approval" : ""}${invalid}`;
83
83
  }
84
84
  return `${data.group.name} [${data.group.hosts.join(", ")}]`;
85
85
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useWindowSize } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createAppOperations } from "../app-flow.js";
5
6
  import { AppPanel, handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
@@ -10,8 +11,9 @@ import { BROWSE } from "./installations-mode.js";
10
11
  import { errorText } from "./batch.js";
11
12
  import { handleAdoptionKey, startAdoption } from "./adoption-actions.js";
12
13
  import { handleBrowseKey } from "./installations-browse.js";
13
- import { handleManageKey } from "./manage-actions.js";
14
- import { ModePanel } from "./installations-panel.js";
14
+ import { handleManageKey, isPagedConfirmMode } from "./manage-actions.js";
15
+ import { PagedConfirmPanel } from "./manage-panel.js";
16
+ import { AdoptionConfirmPanel, ModePanel } from "./installations-panel.js";
15
17
  import { TreeRowLine } from "./installations-rows.js";
16
18
  import { buildTree, leavesOf, markersOf } from "./installations-tree.js";
17
19
  import { ListFooter, Working } from "./panel-parts.js";
@@ -23,10 +25,11 @@ import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
23
25
  import { computeWindow, useListHeight } from "./window.js";
24
26
  import { useKeys } from "./keys.js";
25
27
  import { invalidateRetention } from "./view-retention.js";
28
+ import { usePreviewGeometry } from "./preview-window.js";
26
29
  const NO_ENVIRONMENT = {};
27
30
  const LEGEND = "adopted: tracked in place · modified: changed on disk";
28
31
  export function InstallationsView({ operations, environment, onCapturingChange, listHeight, retention }) {
29
- const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
32
+ const previewGeometry = usePreviewGeometry();
30
33
  const height = useListHeight(listHeight, 7);
31
34
  const env = environment ?? NO_ENVIRONMENT;
32
35
  const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
@@ -148,6 +151,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
148
151
  operations,
149
152
  env,
150
153
  installed,
154
+ previewGeometry,
151
155
  setMode,
152
156
  browse: () => setMode(BROWSE),
153
157
  setMessage,
@@ -165,6 +169,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
165
169
  const adoption = {
166
170
  operations,
167
171
  env,
172
+ previewGeometry,
168
173
  setMode,
169
174
  browse: manage.browse,
170
175
  setMessage,
@@ -180,16 +185,16 @@ export function InstallationsView({ operations, environment, onCapturingChange,
180
185
  packages,
181
186
  startAdoption: (group) => void startAdoption(adoption, group),
182
187
  setMessage,
183
- startApps: (entries, input) => void startAppAction({ apps, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, entries, input),
188
+ startApps: (entries, input) => void startAppAction({ apps, manage, previewGeometry }, entries, input),
184
189
  };
185
190
  useKeys((input, key) => {
186
191
  const current = modeRef.current;
187
192
  if (current.kind === "browse")
188
193
  handleBrowseKey(browse, input, key);
189
194
  else if (isPackageMode(current))
190
- handlePackageKey({ packages, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, current, input, key);
195
+ handlePackageKey({ packages, manage, previewGeometry }, current, input, key);
191
196
  else if (isAppMode(current))
192
- handleAppKey({ apps, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, current, input, key);
197
+ handleAppKey({ apps, manage, previewGeometry }, current, input, key);
193
198
  else if (!handleManageKey(manage, current, input, key))
194
199
  handleAdoptionKey(adoption, current, input, key);
195
200
  });
@@ -202,6 +207,10 @@ export function InstallationsView({ operations, environment, onCapturingChange,
202
207
  // A paged declaration owns the view: rows of the tree above it would not be counted in its height.
203
208
  if (isPackageMode(mode))
204
209
  return _jsx(PackagePanel, { mode: mode });
210
+ if (isPagedConfirmMode(mode))
211
+ return _jsx(PagedConfirmPanel, { mode: mode });
212
+ if (mode.kind === "confirm" && mode.adoption.verdict.adoptable)
213
+ return _jsx(AdoptionConfirmPanel, { mode: mode });
205
214
  const ready = state.data;
206
215
  const window = computeWindow(rows.length, index, height);
207
216
  const listed = rows.slice(window.start, window.end);
@@ -1,4 +1,5 @@
1
1
  import { useInput } from "ink";
2
+ import { useRef } from "react";
2
3
  /** Plain printable characters only: control characters and escape sequences are never split. */
3
4
  const PRINTABLE = /^[^\p{Cc}]+$/u;
4
5
  /**
@@ -10,22 +11,52 @@ const PRINTABLE = /^[^\p{Cc}]+$/u;
10
11
  */
11
12
  const ESCAPE_REMNANT = /^(?:\[[\d;<:?]*[A-DFHIMOPQRSZm~]|O[A-DFHPQRS])$/;
12
13
  const PASTE_MARKERS = new Set(["[200~", "[201~"]);
14
+ /** Keys that did not arrive alone: split off a multi-character block, or read after another key in the same turn. */
15
+ const groupedKeys = new WeakSet();
16
+ /**
17
+ * Whether this key press is a `y` that answers a confirmation. A `y` that arrived together with other
18
+ * keys never does: they may have opened the question or moved the cursor to its end before it was drawn.
19
+ * Text fields read `input` directly, so pasted text still reaches them.
20
+ */
21
+ export function isYes(input, key) {
22
+ return input === "y" && !groupedKeys.has(key);
23
+ }
24
+ function grouped(key) {
25
+ const copy = { ...key };
26
+ groupedKeys.add(copy);
27
+ return copy;
28
+ }
29
+ /**
30
+ * The most events any hook has read this turn. A hook that mounts mid-read starts its own count at 0, but the hooks that were already
31
+ * listening have counted the keys it missed, so this tells it that it is not at the start of the turn.
32
+ */
33
+ let eventsInTurn = 0;
13
34
  /**
14
35
  * `useInput` that treats several plain characters delivered in one write (fast
15
- * typing, a paste) as the separate key presses they are, in order.
36
+ * typing, a paste) as the separate key presses they are, in order. Ink also hands
37
+ * the escape sequences of one read over as separate events within one turn; every key
38
+ * after the first of a turn counts as grouped, in any hook, as do all characters of a split block.
16
39
  */
17
40
  export function useKeys(handler) {
41
+ const eventsThisTurn = useRef(0);
18
42
  useInput((input, key) => {
43
+ if (eventsInTurn === 0)
44
+ queueMicrotask(() => { eventsInTurn = 0; });
45
+ if (eventsThisTurn.current++ === 0)
46
+ queueMicrotask(() => { eventsThisTurn.current = 0; });
47
+ eventsInTurn = Math.max(eventsInTurn, eventsThisTurn.current);
48
+ const later = eventsThisTurn.current > 1 || eventsInTurn > 1;
19
49
  if (ESCAPE_REMNANT.test(input)) {
20
50
  if (!PASTE_MARKERS.has(input))
21
- handler(input, key);
51
+ handler(input, later ? grouped(key) : key);
22
52
  return;
23
53
  }
24
54
  if (input.length > 1 && !key.ctrl && !key.meta && !key.escape && PRINTABLE.test(input)) {
55
+ const blockKey = grouped(key);
25
56
  for (const character of input)
26
- handler(character, key);
57
+ handler(character, blockKey);
27
58
  return;
28
59
  }
29
- handler(input, key);
60
+ handler(input, later ? grouped(key) : key);
30
61
  });
31
62
  }
@@ -2,9 +2,9 @@ import { APP_KINDS } from "./app-actions.js";
2
2
  import { PACKAGE_KINDS } from "./package-actions.js";
3
3
  import { globalRecord, missingHosts, prepareHostAddition, prepareHostRemoval, prepareUninstall, runHostAddition, runHostRemoval, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
4
4
  import { errorText } from "./batch.js";
5
- import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
6
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
7
- import { handleUnmanagedKey, UNMANAGED_KINDS } from "./unmanaged-actions.js";
5
+ import { BULK_KINDS, bulkConfirmPreview, handleBulkKey } from "./bulk-actions.js";
6
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
7
+ import { handleUnmanagedKey, UNMANAGED_KINDS, unmanagedConfirmPreview } from "./unmanaged-actions.js";
8
8
  import { busy } from "./working-model.js";
9
9
  const MANAGE_KINDS = new Set([
10
10
  "remove-scope",
@@ -43,7 +43,7 @@ async function startFullUninstall(context, skill) {
43
43
  if (!context.isMounted())
44
44
  return;
45
45
  if (prepared.kind === "ready") {
46
- context.setMode({ kind: "confirm-uninstall", skill, prepared });
46
+ context.setMode({ kind: "confirm-uninstall", skill, prepared, cursor: 0 });
47
47
  return;
48
48
  }
49
49
  context.setMessage({ kind: "error", text: UNINSTALL_REFUSALS[prepared.kind] });
@@ -73,13 +73,13 @@ async function startHostChange(context, mode) {
73
73
  const prepared = await prepareHostAddition(operations, env, mode.skill, installed, mode.selected);
74
74
  if (!context.isMounted())
75
75
  return;
76
- context.setMode({ kind: "confirm-host-add", skill: mode.skill, prepared });
76
+ context.setMode({ kind: "confirm-host-add", skill: mode.skill, prepared, cursor: 0 });
77
77
  }
78
78
  else {
79
79
  const prepared = await prepareHostRemoval(operations, env, mode.skill, installed, mode.selected);
80
80
  if (!context.isMounted())
81
81
  return;
82
- context.setMode({ kind: "confirm-host-remove", skill: mode.skill, prepared });
82
+ context.setMode({ kind: "confirm-host-remove", skill: mode.skill, prepared, cursor: 0 });
83
83
  }
84
84
  }
85
85
  catch (error) {
@@ -117,35 +117,61 @@ function handleRemoveScopeKey(context, mode, input, key) {
117
117
  else if (input === "2")
118
118
  context.setMode({ kind: "hosts-remove", skill: mode.skill, choices: mode.hosts, cursor: 0, selected: [] });
119
119
  }
120
- /** Keys of the confirmation modes: `y` runs the step (or asks for exposure consent first), `n`/Esc cancels. */
121
- function handleConfirmKey(context, mode, input, key) {
122
- const { operations, env } = context;
123
- const cancelWith = (text) => () => cancel(context, text);
120
+ /** What a paged confirmation shows, so the key handler and the panel page the same rows. */
121
+ export function pagedConfirmPreview(mode) {
122
+ if (mode.kind === "confirm-unmanaged")
123
+ return unmanagedConfirmPreview(mode);
124
+ if (mode.kind === "confirm-bulk-uninstall" || mode.kind === "confirm-bulk-hosts")
125
+ return bulkConfirmPreview(mode);
126
+ const name = mode.skill.name;
124
127
  switch (mode.kind) {
125
128
  case "confirm-host-add":
126
- answerYesNo(input, key, () => context.setMode({ ...mode, kind: "confirm-host-exposure" }), cancelWith("Install cancelled"));
127
- return;
128
- case "confirm-host-exposure":
129
- answerYesNo(input, key, () => {
130
- void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostAddition(mode.prepared, operations, env));
131
- }, cancelWith("Install cancelled"));
132
- return;
129
+ return { lines: mode.prepared.preview, chrome: { title: "Add hosts", action: `Add hosts to ${name}`, consequence: `Add hosts to ${name}? y/n` } };
133
130
  case "confirm-host-remove":
134
- answerYesNo(input, key, () => {
135
- void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
136
- }, cancelWith("Uninstall cancelled"));
137
- return;
131
+ return { lines: mode.prepared.preview, chrome: { title: "Remove hosts", action: `Remove hosts from ${name}`, consequence: `Remove hosts from ${name}? y/n` } };
138
132
  case "confirm-uninstall":
139
- answerYesNo(input, key, () => {
133
+ return { lines: mode.prepared.plan.preview, chrome: { title: "Uninstall", action: `Uninstall ${name}`, consequence: `Uninstall ${name}? y/n` } };
134
+ }
135
+ }
136
+ export function isPagedConfirmMode(mode) {
137
+ return ["confirm-host-add", "confirm-host-remove", "confirm-uninstall", "confirm-unmanaged", "confirm-bulk-uninstall", "confirm-bulk-hosts"].includes(mode.kind);
138
+ }
139
+ /** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
140
+ function handlePagedConfirmKey(context, mode, input, key) {
141
+ const { operations, env } = context;
142
+ const { lines, chrome } = pagedConfirmPreview(mode);
143
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
144
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
145
+ if (cursor !== undefined) {
146
+ context.setMode({ ...mode, cursor });
147
+ return;
148
+ }
149
+ answerYesNo(input, key, () => {
150
+ if (!viewport.reachedEnd)
151
+ return;
152
+ switch (mode.kind) {
153
+ case "confirm-host-add":
154
+ context.setMode({ kind: "confirm-host-exposure", skill: mode.skill, prepared: mode.prepared });
155
+ return;
156
+ case "confirm-host-remove":
157
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
158
+ return;
159
+ case "confirm-uninstall":
140
160
  void execute(context, `Removing ${mode.skill.name}…`, async () => {
141
161
  await runUninstall(mode.prepared, operations, env);
142
162
  return mode.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
143
163
  ? `Forgot missing installation record for ${mode.skill.path}; no files were removed`
144
164
  : `Removed ${mode.skill.name} (${mode.skill.path})`;
145
165
  });
146
- }, cancelWith("Uninstall cancelled"));
147
- return;
148
- }
166
+ return;
167
+ }
168
+ }, () => cancel(context, mode.kind === "confirm-host-add" ? "Install cancelled" : "Uninstall cancelled"));
169
+ }
170
+ /** The exposure question has no preview: `y` adds the hosts, `n`/Esc cancels. */
171
+ function handleExposureKey(context, mode, input, key) {
172
+ answerYesNo(input, key, () => {
173
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostAddition(mode.prepared, context.operations, context.env));
174
+ }, () => cancel(context, "Install cancelled"));
149
175
  }
150
176
  /**
151
177
  * Handles a key in one of the manage modes. Returns false when the mode is not
@@ -167,10 +193,12 @@ export function handleManageKey(context, mode, input, key) {
167
193
  handleHostChecklistKey(context, mode, input, key);
168
194
  break;
169
195
  case "confirm-host-add":
170
- case "confirm-host-exposure":
171
196
  case "confirm-host-remove":
172
197
  case "confirm-uninstall":
173
- handleConfirmKey(context, mode, input, key);
198
+ handlePagedConfirmKey(context, mode, input, key);
199
+ break;
200
+ case "confirm-host-exposure":
201
+ handleExposureKey(context, mode, input, key);
174
202
  break;
175
203
  default:
176
204
  break;
@@ -1,29 +1,20 @@
1
1
  import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { AppPanel } from "./app-actions.js";
4
5
  import { PackagePanel } from "./package-actions.js";
5
- import { ChecklistLines, DecisionPrompt, HostChecklist, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
6
+ import { pagedConfirmPreview } from "./manage-actions.js";
7
+ import { ChecklistLines, DecisionPrompt, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
8
+ import { usePreviewGeometry } from "./preview-window.js";
6
9
  import { theme } from "./theme.js";
7
10
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
8
11
  function SkippedLines({ skipped }) {
9
12
  return (_jsx(_Fragment, { children: skipped.map(({ id, label, reason }) => _jsxs(Text, { color: theme.warning, children: ["Skipped ", label, ": ", reason] }, id)) }));
10
13
  }
11
- /** One block per planned item, the skipped ones, then the y/n question. */
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(DecisionPrompt, { children: question })] }));
14
- }
15
- function UninstallPreview({ plan }) {
16
- const entries = plan.items.map((item) => ({
17
- id: item.id,
18
- label: item.label,
19
- lines: item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview,
20
- warnFirst: item.kind === "unmanaged",
21
- }));
22
- return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Uninstall ${plural(plan.items.length)}?` });
23
- }
24
- function HostsPreview({ plan }) {
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)}?` });
14
+ /** A Skill confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
15
+ export function PagedConfirmPanel({ mode }) {
16
+ const { lines, styled, chrome } = pagedConfirmPreview(mode);
17
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
27
18
  }
28
19
  /** Renders the prompt of a manage mode: host lists, previews and confirmations. */
29
20
  export function ManagePanel({ mode }) {
@@ -44,25 +35,20 @@ export function ManagePanel({ mode }) {
44
35
  case "hosts-remove":
45
36
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [mode.kind === "hosts-add" ? "Add hosts to" : "Remove hosts from", " ", mode.skill.name, ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
46
37
  case "confirm-host-add":
47
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Add hosts to ${mode.skill.name}? y/n` });
48
- case "confirm-host-exposure":
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?" })] }));
50
38
  case "confirm-host-remove":
51
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove hosts from ${mode.skill.name}? y/n` });
52
39
  case "confirm-uninstall":
53
- return _jsx(PreviewConfirm, { lines: mode.prepared.plan.preview, question: `Uninstall ${mode.skill.name}? y/n` });
40
+ case "confirm-unmanaged":
41
+ case "confirm-bulk-uninstall":
42
+ case "confirm-bulk-hosts":
43
+ return _jsx(PagedConfirmPanel, { mode: mode });
44
+ case "confirm-host-exposure":
45
+ 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?" })] }));
54
46
  case "unmanaged-scope":
55
47
  return _jsxs(Text, { children: ["Remove ", mode.group.name, " (", mode.group.locations.length, " locations): 1 all locations 2 choose locations (Esc cancel)"] });
56
48
  case "unmanaged-pick":
57
49
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Remove locations of ", mode.group.name, ". Location (space/1-", mode.group.locations.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(ChecklistLines, { choices: mode.group.locations, cursor: mode.cursor, isSelected: (location) => mode.selected.includes(location.path), keyOf: (location) => location.path, label: (location) => `${location.path}${location.linkTarget === undefined ? "" : ` symlink → ${location.linkTarget}`}` })] }));
58
- case "confirm-unmanaged":
59
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove ${mode.group.name}? y/n`, warnFirst: true });
60
50
  case "bulk-hosts":
61
51
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Add hosts to ", plural(mode.targets.length), ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected }), _jsx(SkippedLines, { skipped: mode.skipped })] }));
62
- case "confirm-bulk-uninstall":
63
- return _jsx(UninstallPreview, { plan: mode.plan });
64
- case "confirm-bulk-hosts":
65
- return _jsx(HostsPreview, { plan: mode.plan });
66
52
  case "confirm-bulk-exposure":
67
53
  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
54
  case "busy":
@@ -1,4 +1,5 @@
1
1
  import { isValidFixedVersion } from "../skill-install.js";
2
+ import { isYes } from "./keys.js";
2
3
  /** The cursor after a down/up key (arrows or j/k), or undefined when the key does not move it. */
3
4
  export function moveCursor(cursor, length, input, key) {
4
5
  if (key.downArrow || input === "j")
@@ -51,11 +52,20 @@ export function applyChecklistOutcome(outcome, handlers) {
51
52
  }
52
53
  /** The y/n confirmation keys: `y` confirms, `n` or Esc declines, anything else is ignored. */
53
54
  export function answerYesNo(input, key, onYes, onNo) {
54
- if (input === "y")
55
+ if (isYes(input, key))
55
56
  onYes();
56
57
  else if (input === "n" || key.escape)
57
58
  onNo();
58
59
  }
60
+ /** A paged preview answers `y` only once `viewport` has been read to the end; paging keys move the cursor. */
61
+ export function answerPaged(viewport, cursor, input, key, outcome) {
62
+ const moved = movePreviewCursor(cursor, viewport.total, input, key, viewport.height);
63
+ if (moved !== undefined)
64
+ outcome.moveTo(moved);
65
+ else
66
+ answerYesNo(input, key, () => { if (viewport.reachedEnd)
67
+ outcome.yes(); }, outcome.no);
68
+ }
59
69
  /** Esc cancels, Enter submits a valid version, backspace deletes, any other plain key is typed. */
60
70
  export function versionInputKey(value, input, key) {
61
71
  if (key.escape)