@iseoane/agent-depot 0.6.12 → 0.6.14

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 (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/src/flow-progress.js +6 -0
  4. package/dist/src/git-source.js +18 -6
  5. package/dist/src/preview-model.js +34 -0
  6. package/dist/src/process-runner.js +1 -0
  7. package/dist/src/profile-cli.js +12 -4
  8. package/dist/src/profile-export.js +23 -3
  9. package/dist/src/self-update.js +9 -2
  10. package/dist/src/skill-update.js +1 -0
  11. package/dist/src/source-refresh-session.js +139 -0
  12. package/dist/src/sources.js +4 -4
  13. package/dist/src/tui/adoption-actions.js +21 -2
  14. package/dist/src/tui/app-actions.js +54 -45
  15. package/dist/src/tui/app.js +22 -4
  16. package/dist/src/tui/bulk-actions.js +42 -9
  17. package/dist/src/tui/catalog-actions.js +12 -0
  18. package/dist/src/tui/catalog-flow.js +31 -11
  19. package/dist/src/tui/catalog-panel.js +15 -13
  20. package/dist/src/tui/catalog-tree.js +7 -2
  21. package/dist/src/tui/catalog-view.js +12 -6
  22. package/dist/src/tui/file-picker.js +2 -1
  23. package/dist/src/tui/installations-panel.js +12 -4
  24. package/dist/src/tui/installations-rows.js +1 -1
  25. package/dist/src/tui/installations-view.js +16 -7
  26. package/dist/src/tui/keys.js +27 -4
  27. package/dist/src/tui/manage-actions.js +55 -27
  28. package/dist/src/tui/manage-panel.js +15 -29
  29. package/dist/src/tui/mode-keys.js +11 -1
  30. package/dist/src/tui/package-actions.js +25 -18
  31. package/dist/src/tui/package-rows.js +2 -1
  32. package/dist/src/tui/panel-parts.js +45 -36
  33. package/dist/src/tui/preview-render.js +93 -0
  34. package/dist/src/tui/preview-window.js +141 -10
  35. package/dist/src/tui/profile-view.js +49 -40
  36. package/dist/src/tui/render.js +11 -1
  37. package/dist/src/tui/safe-text.js +25 -0
  38. package/dist/src/tui/sources-mode.js +2 -2
  39. package/dist/src/tui/sources-recipes.js +53 -32
  40. package/dist/src/tui/sources-view.js +86 -39
  41. package/dist/src/tui/unmanaged-actions.js +17 -5
  42. package/dist/src/tui/updates-flow.js +11 -5
  43. package/dist/src/tui/updates-keys.js +2 -2
  44. package/dist/src/tui/updates-model.js +5 -2
  45. package/dist/src/tui/updates-panel.js +10 -9
  46. package/dist/src/tui/updates-preview.js +27 -44
  47. package/dist/src/tui/updates-view.js +8 -10
  48. package/dist/src/tui/updates.js +117 -33
  49. package/dist/src/update-flow.js +31 -15
  50. package/package.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text, useWindowSize } from "ink";
2
+ import { Text } from "./safe-text.js";
3
+ import { commandLine } from "../preview-model.js";
3
4
  import { PROJECT_HOSTS } from "../project-manifest.js";
4
5
  import { errorText, runBatch } from "./batch.js";
6
+ import { isYes } from "./keys.js";
5
7
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
- import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
- import { previewViewport } from "./preview-window.js";
8
+ import { BorderPanel, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
9
+ import { usePreviewGeometry } from "./preview-window.js";
10
+ import { fieldLine, lineText, span, stepBlockLines, textLine } from "./preview-render.js";
8
11
  import { theme } from "./theme.js";
9
12
  import { operationStart } from "./working-model.js";
10
13
  /** Finished items shown under a running batch; older ones are counted. */
@@ -17,51 +20,57 @@ export function isAppMode(mode) {
17
20
  /** Recipe name, falling back to the file identity for invalid recipes. */
18
21
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
19
22
  function appPreviewChrome(mode) {
20
- const title = mode.kind === "app-approve" ? "Recipe approval · does not install"
21
- : mode.kind === "app-manual" ? "App manual step" : `App ${mode.plan.action} confirmation`;
22
- const question = mode.kind === "app-approve" ? `Approve ${appName(mode.entry)}? y/n · recipe approval does not install the App.`
23
- : mode.kind === "app-manual" ? "Manual step done, check now? y/n"
24
- : `${mode.plan.action} ${appName(mode.plan.entry)}? y/n`;
25
- return { title, questions: [question, "Scroll to end before y; n cancels."], footerRows: 2 };
23
+ if (mode.kind === "app-approve")
24
+ return recipeApprovalChrome(mode.entry);
25
+ if (mode.kind === "app-manual")
26
+ return { title: "App manual step", action: "Check now", consequence: "Manual step done, check now? y/n" };
27
+ const { action, entry } = mode.plan;
28
+ return {
29
+ title: `App ${action} confirmation`,
30
+ action: `${action[0].toUpperCase()}${action.slice(1)} ${appName(entry)}`,
31
+ consequence: `${action} ${appName(entry)}? y/n`,
32
+ };
33
+ }
34
+ /** Approving a recipe records consent to its commands; it installs nothing. */
35
+ export function recipeApprovalChrome(entry) {
36
+ return {
37
+ title: "Recipe approval · does not install",
38
+ action: `Approve ${appName(entry)}`,
39
+ consequence: `Approve ${appName(entry)}? y/n · recipe approval does not install the App.`,
40
+ };
26
41
  }
27
- export function appStepLines(step) {
28
- const argv = step.argv;
42
+ /** What confirming an App step does: its name, Host and versions, then the step as one block. */
43
+ export function appPlanLines(plan) {
44
+ const { previousVersion, latestVersion } = plan;
45
+ const both = previousVersion !== undefined && latestVersion !== undefined;
29
46
  return [
30
- ...(argv ? [
31
- "argv: [",
32
- ...argv.map((argument, index) => ` ${JSON.stringify(argument)}${index < argv.length - 1 ? "," : ""}`),
33
- "]",
34
- `Executable: ${step.executable ?? `${JSON.stringify(argv[0])} not found in PATH`}`,
35
- ] : []),
36
- ...(step.manual ? [`Manual: ${step.manual}`] : []),
37
- ...(step.declaration ? [step.declaration] : []),
38
- ...(step.pattern ? [`Version pattern: ${step.pattern}`] : []),
39
- ...(step.environment ? [`Environment: ${step.environment}`] : []),
40
- `Working directory: ${step.cwd}`,
41
- ...(step.warning ? [step.warning] : []),
47
+ [
48
+ span(` App ${appName(plan.entry)} ${plan.action}${plan.host ? ` (${plan.host})` : ""}`, theme.value, true),
49
+ ...(both ? [span(` ${previousVersion} → ${latestVersion}`, theme.version)] : []),
50
+ ],
51
+ ...(!both && previousVersion !== undefined ? [fieldLine(" Previous version:", previousVersion)] : []),
52
+ ...(!both && latestVersion !== undefined ? [fieldLine(" Latest version:", latestVersion)] : []),
53
+ textLine(" "),
54
+ ...stepBlockLines([{ ...plan, step: plan.host ? `${plan.action}.${plan.host}` : plan.action }]),
42
55
  ];
43
56
  }
57
+ /** The recipe's identity and approval status, then one block per step. */
44
58
  export function appRecipePreviewLines(entry, status, steps) {
45
59
  const recipe = entry.recipe;
46
60
  return [
47
- `File: ${entry.file}`,
48
- `Name: ${recipe?.name ?? entry.file}`,
49
- `Platform: ${recipe?.platform ?? "all"}`,
50
- `Status: ${status}`,
51
- ...(recipe?.homepage ? [`Homepage: ${recipe.homepage}`] : []),
52
- ...(recipe?.skills ? [`Owned Skills: ${JSON.stringify(recipe.skills)}`] : []),
53
- ...steps.flatMap(step => [step.step, ...appStepLines(step)]),
61
+ [
62
+ span(` ${appName(entry)}`, theme.value, true),
63
+ span(` ${recipe?.platform ?? "all"}`, theme.field),
64
+ span(` ● ${status}`, status === "approved" ? theme.success : theme.warning),
65
+ ],
66
+ [span(` ${[entry.file, ...(recipe?.homepage ? [recipe.homepage] : [])].join(" · ")}`, theme.legend)],
67
+ ...(recipe?.skills ? [fieldLine(" Owned Skills:", commandLine(recipe.skills, { terminal: true }))] : []),
68
+ textLine(" "),
69
+ ...stepBlockLines(steps),
54
70
  ];
55
71
  }
56
72
  function appPreviewLines(mode) {
57
- if (mode.kind === "app-approve")
58
- return mode.lines;
59
- return [
60
- `App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
61
- ...(mode.plan.previousVersion ? [`Previous version: ${mode.plan.previousVersion}`] : []),
62
- ...(mode.plan.latestVersion ? [`Latest version: ${mode.plan.latestVersion}`] : []),
63
- ...appStepLines(mode.plan),
64
- ];
73
+ return mode.kind === "app-approve" ? mode.lines : appPlanLines(mode.plan);
65
74
  }
66
75
  /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
67
76
  function appBusy(label, previous, done) {
@@ -182,8 +191,8 @@ export function handleAppKey(context, mode, input, key) {
182
191
  }
183
192
  }
184
193
  else {
185
- const lines = appPreviewLines(mode);
186
- const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, appPreviewChrome(mode));
194
+ const lines = appPreviewLines(mode).map(lineText);
195
+ const viewport = context.previewGeometry(lines, mode.cursor, appPreviewChrome(mode));
187
196
  const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
188
197
  if (cursor !== undefined) {
189
198
  manage.setMode({ ...mode, cursor });
@@ -197,7 +206,7 @@ export function handleAppKey(context, mode, input, key) {
197
206
  cancel();
198
207
  return;
199
208
  }
200
- if (input !== "y" || !viewport.reachedEnd)
209
+ if (!isYes(input, key) || !viewport.reachedEnd)
201
210
  return;
202
211
  if (mode.kind !== "app-approve") {
203
212
  void execute(context, mode);
@@ -209,7 +218,7 @@ export function handleAppKey(context, mode, input, key) {
209
218
  }
210
219
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
211
220
  export function AppPanel({ mode }) {
212
- const { rows, columns } = useWindowSize();
221
+ const geometry = usePreviewGeometry();
213
222
  if (mode.kind === "app-busy") {
214
223
  const done = mode.done ?? [];
215
224
  return (_jsxs(BorderPanel, { title: "App lifecycle", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), done.length > MAX_DONE_LINES ? _jsxs(Text, { color: theme.muted, children: [done.length - MAX_DONE_LINES, " earlier finished"] }) : null, done.slice(-MAX_DONE_LINES).map((line, index) => _jsx(Text, { color: theme.muted, children: line }, index))] }));
@@ -217,8 +226,8 @@ export function AppPanel({ mode }) {
217
226
  if (mode.kind === "app-hosts") {
218
227
  return (_jsxs(BorderPanel, { title: "App Host steps", children: [_jsx(Text, { children: "Checked = setup. Unchecked = teardown. Wiring is not tracked." }), _jsxs(Text, { children: ["Space/1-", mode.choices.length, " toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
219
228
  }
220
- const lines = appPreviewLines(mode);
229
+ const styled = appPreviewLines(mode);
230
+ const lines = styled.map(lineText);
221
231
  const chrome = appPreviewChrome(mode);
222
- const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
223
- return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome }));
232
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
224
233
  }
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useApp, useWindowSize } from "ink";
3
- import { useCallback, useRef, useState } from "react";
2
+ import { Box, useApp, useWindowSize } from "ink";
3
+ import { Text } from "./safe-text.js";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
6
+ import { withRefreshSession } from "../source-refresh-session.js";
5
7
  import { CatalogView } from "./catalog-view.js";
6
8
  import { InstallationsView } from "./installations-view.js";
7
9
  import { headerTabRows, TABS } from "./header-layout.js";
@@ -28,7 +30,23 @@ export function Header({ view, columns }) {
28
30
  const innerWidth = Math.max(columns - 4, 1);
29
31
  return (_jsxs(Box, { borderStyle: "round", borderColor: theme.accent, paddingX: 1, flexDirection: "column", width: columns, children: [_jsxs(Box, { width: innerWidth, justifyContent: "space-between", children: [_jsx(Text, { color: theme.accent, bold: true, children: "AGENT DEPOT" }), AGENT_DEPOT_PACKAGE_VERSION === undefined ? null : _jsxs(Text, { color: theme.value, children: ["v", AGENT_DEPOT_PACKAGE_VERSION] })] }), _jsx(Box, { flexDirection: "column", children: headerTabRows(innerWidth).map((row, rowIndex) => (_jsx(Text, { wrap: "truncate-end", children: row.map(([name, label], tabIndex) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, children: [tabIndex === 0 ? "" : " ", view === name ? "▶ " : "", label] }, name))) }, rowIndex))) })] }));
30
32
  }
31
- export function App({ operations, environment, onExit }) {
33
+ export function App({ operations: plainOperations, environment: plainEnvironment, onExit, refreshSession, refreshOnStart }) {
34
+ // Memoized: views reload whenever the operations they receive change identity.
35
+ const operations = useMemo(() => refreshSession === undefined ? plainOperations : withRefreshSession(plainOperations, refreshSession), [plainOperations, refreshSession]);
36
+ // Package previews refresh through the same session, so they reuse and show the same refreshes.
37
+ const environment = useMemo(() => refreshSession === undefined ? plainEnvironment : {
38
+ ...plainEnvironment,
39
+ refreshSession,
40
+ ...(plainEnvironment?.packages !== undefined ? {} : {
41
+ packagesEnvironment: { ...plainEnvironment?.packagesEnvironment, sourceOperations: operations },
42
+ }),
43
+ }, [plainEnvironment, refreshSession, operations]);
44
+ useEffect(() => {
45
+ if (!refreshOnStart || refreshSession === undefined)
46
+ return;
47
+ // D3 (I0031): only registered Git Sources; the built-in Source is never refreshed.
48
+ plainOperations.listSources().then((sources) => refreshSession.refreshInBackground(sources.filter((source) => source.kind === "git")), () => undefined);
49
+ }, [plainOperations, refreshSession, refreshOnStart]);
32
50
  const { exit } = useApp();
33
51
  const { columns, rows } = useWindowSize();
34
52
  const [capturing, setCapturing] = useState(false);
@@ -79,7 +97,7 @@ export function App({ operations, environment, onExit }) {
79
97
  else if (input === "5")
80
98
  showView("profile");
81
99
  });
82
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { view: view, columns: columns }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
100
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { view: view, columns: columns }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, refreshSession: refreshSession, onOpenCatalog: (source) => {
83
101
  setCatalogSourceId(source.id);
84
102
  showView("catalog");
85
103
  } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, retention: retention })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange, retention: retention })) }), _jsx(Text, { color: theme.muted, children: capturing
@@ -2,7 +2,9 @@ import { PROJECT_HOSTS } from "../project-manifest.js";
2
2
  import { assertManagedInstallationRecordsUnchanged } from "../skill-removal.js";
3
3
  import { errorText, runBatch } from "./batch.js";
4
4
  import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
5
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
5
+ import { answerPaged, answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
6
+ import { span } from "./preview-render.js";
7
+ import { theme } from "./theme.js";
6
8
  import { skillOf, sourceIdOf } from "./installations.js";
7
9
  import { prepareUnmanagedRemoval, runUnmanagedRemoval } from "./unmanaged-actions.js";
8
10
  import { busy } from "./working-model.js";
@@ -193,7 +195,7 @@ async function preparePlan(context, label, prepare, confirmation) {
193
195
  }
194
196
  /** `u` with marks: prepares every marked leaf, then asks once. */
195
197
  export function startBulkUninstall(context, targets) {
196
- return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}…`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan }));
198
+ return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}…`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan, cursor: 0 }));
197
199
  }
198
200
  /** `h` with marks: asks the hosts once, for the union of what the eligible items lack. */
199
201
  export function startBulkHostAddition(context, targets) {
@@ -207,7 +209,7 @@ export function startBulkHostAddition(context, targets) {
207
209
  context.setMode({ kind: "bulk-hosts", targets: ready, skipped, choices, cursor: 0, selected: [] });
208
210
  }
209
211
  function prepareHosts(context, mode) {
210
- return preparePlan(context, `Preparing host change of ${count(mode.targets.length, "item")}…`, () => prepareBulkHostAddition(context, mode.targets, mode.skipped, mode.selected), (plan) => ({ kind: "confirm-bulk-hosts", plan }));
212
+ return preparePlan(context, `Preparing host change of ${count(mode.targets.length, "item")}…`, () => prepareBulkHostAddition(context, mode.targets, mode.skipped, mode.selected), (plan) => ({ kind: "confirm-bulk-hosts", plan, cursor: 0 }));
211
213
  }
212
214
  async function apply(context, label, action) {
213
215
  const started = busy(label);
@@ -226,6 +228,30 @@ async function apply(context, label, action) {
226
228
  context.unmark(result.applied);
227
229
  await context.finish(result.message);
228
230
  }
231
+ function previewOf(item) {
232
+ if (!("kind" in item))
233
+ return item.prepared.preview;
234
+ return item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview;
235
+ }
236
+ /**
237
+ * One block per planned item (its label, then its preview), then the skipped ones, with the footer's action and question.
238
+ * Only the labels are styled; `styled` is sparse so the other lines keep the colours inferred from their text.
239
+ */
240
+ export function bulkConfirmPreview(mode) {
241
+ const { plan } = mode;
242
+ const items = count(plan.items.length, "item");
243
+ const blocks = plan.items.flatMap((item) => [
244
+ { text: item.label, label: true },
245
+ ...previewOf(item).map((text) => ({ text, label: false })),
246
+ ]);
247
+ const skipped = plan.skipped.map(({ label, reason }) => ({ text: `Skipped ${label}: ${reason}`, label: false }));
248
+ const entries = [...blocks, ...skipped];
249
+ const lines = entries.map(({ text }) => text);
250
+ const styled = entries.map(({ text, label }) => label ? [span(text, theme.group)] : undefined);
251
+ return mode.kind === "confirm-bulk-uninstall"
252
+ ? { lines, styled, chrome: { title: "Uninstall", action: `Uninstall ${items}`, consequence: `Uninstall ${items}? y/n` } }
253
+ : { lines, styled, chrome: { title: "Add hosts", action: `Add hosts to ${items}`, consequence: `Add hosts to ${items}? y/n` } };
254
+ }
229
255
  /** Handles a key in a bulk mode; returns false for any other mode. */
230
256
  export function handleBulkKey(context, mode, input, key) {
231
257
  const current = mode;
@@ -241,13 +267,20 @@ export function handleBulkKey(context, mode, input, key) {
241
267
  return true;
242
268
  }
243
269
  case "confirm-bulk-uninstall":
244
- answerYesNo(input, key, () => {
245
- void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, (progress) => runBulkUninstall(current.plan, operations, env, progress));
246
- }, () => cancel(context, "Uninstall cancelled"));
247
- return true;
248
- case "confirm-bulk-hosts":
249
- answerYesNo(input, key, () => context.setMode({ kind: "confirm-bulk-exposure", plan: current.plan }), () => cancel(context, "Add hosts cancelled"));
270
+ case "confirm-bulk-hosts": {
271
+ const { lines, chrome } = bulkConfirmPreview(current);
272
+ answerPaged(context.previewGeometry(lines, current.cursor, chrome), current.cursor, input, key, {
273
+ moveTo: (cursor) => context.setMode({ ...current, cursor }),
274
+ yes: () => {
275
+ if (current.kind === "confirm-bulk-hosts")
276
+ context.setMode({ kind: "confirm-bulk-exposure", plan: current.plan });
277
+ else
278
+ void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, (progress) => runBulkUninstall(current.plan, operations, env, progress));
279
+ },
280
+ no: () => cancel(context, current.kind === "confirm-bulk-hosts" ? "Add hosts cancelled" : "Uninstall cancelled"),
281
+ });
250
282
  return true;
283
+ }
251
284
  case "confirm-bulk-exposure":
252
285
  answerYesNo(input, key, () => {
253
286
  void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, (progress) => runBulkHostAddition(current.plan, operations, env, progress));
@@ -7,3 +7,15 @@ export function describeSkills(skills) {
7
7
  return skills.length === 1 ? skills[0].name : `${skills.length} skills`;
8
8
  }
9
9
  export const BROWSE = { kind: "browse" };
10
+ /** What the install confirmation shows, so the key handler and the panel page the same rows. */
11
+ export function installPreview(items) {
12
+ const ready = items.filter((item) => item.prepared !== undefined);
13
+ const single = items.length === 1;
14
+ const name = describeSkills(ready.map((item) => item.skill));
15
+ const lines = [
16
+ ...(ready[0]?.prepared?.projectTarget ?? []),
17
+ ...items.flatMap(({ skill, prepared, error }) => prepared === undefined ? [`Cannot install ${skill.name}: ${error}`] : [...(single ? [] : [skill.name]), ...prepared.preview]),
18
+ ...(ready.some((item) => item.prepared?.runsExternalCommand) ? ["This install runs an external command (see above)."] : []),
19
+ ];
20
+ return { lines, chrome: { title: `Skill install preview · ${ready.length} ready`, action: `Install ${name}`, consequence: `Install ${name}? y/n` } };
21
+ }
@@ -1,8 +1,9 @@
1
- import { BROWSE, describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
1
+ import { formatProgressLabel } from "../flow-progress.js";
2
+ import { BROWSE, describeSkills, installPreview, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
2
3
  import { prepareInstall, runInstall } from "./catalog-installs.js";
3
4
  import { skillKey } from "./catalog-tree.js";
4
5
  import { errorText, runBatch } from "./batch.js";
5
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey, versionInputKey } from "./mode-keys.js";
6
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, movePreviewCursor, versionInputKey } from "./mode-keys.js";
6
7
  import { busy } from "./working-model.js";
7
8
  function cancel(context) {
8
9
  context.setMessage({ kind: "ok", text: "Install cancelled" });
@@ -27,22 +28,46 @@ async function startInstall(context, skills, choice) {
27
28
  context.setAction(BROWSE);
28
29
  return;
29
30
  }
30
- context.setAction({ kind: "confirm-install", items });
31
+ context.setAction({ kind: "confirm-install", items, cursor: 0 });
31
32
  }
32
33
  /** Applies the prepared items one by one; a failure is reported for that item and the rest still run. */
33
34
  async function confirmInstall(context, items, exposure) {
34
- context.setAction(busy(`Installing ${describeSkills(items.map((item) => item.skill))}…`));
35
+ const running = busy(`Installing ${describeSkills(items.map((item) => item.skill))}…`);
36
+ context.setAction(running);
35
37
  const outcomes = await runBatch(items, async ({ prepared, error }) => {
36
38
  if (prepared === undefined)
37
39
  throw new Error(error);
38
40
  return (await runInstall(prepared, context.operations, exposure && prepared.additionalHostExposure)).join(" ");
39
- }, ({ skill }, caught) => `Failed ${skill.name}: ${errorText(caught)}`);
41
+ }, ({ skill }, caught) => `Failed ${skill.name}: ${errorText(caught)}`, ({ skill }, position, total, finished) => {
42
+ if (!context.isMounted())
43
+ return;
44
+ const label = formatProgressLabel({ kind: "skill", name: skill.name, position, total, verb: "Installing" });
45
+ context.setAction({ ...running, label, done: finished.map((outcome) => outcome.line) });
46
+ });
40
47
  const lines = outcomes.map((outcome) => outcome.line);
41
48
  const failed = new Set(outcomes.filter((outcome) => !outcome.ok).map((outcome) => skillKey(outcome.item.skill)));
42
49
  // Installed Skills leave the list; only the failed ones stay marked for another try.
43
50
  context.keepMarked(failed);
44
51
  await context.finish({ kind: failed.size > 0 ? "error" : "ok", text: lines.join("\n") });
45
52
  }
53
+ /** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
54
+ function handleInstallKey(context, mode, input, key) {
55
+ const { lines, chrome } = installPreview(mode.items);
56
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
57
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
58
+ if (cursor !== undefined) {
59
+ context.setAction({ ...mode, cursor });
60
+ return;
61
+ }
62
+ answerYesNo(input, key, () => {
63
+ if (!viewport.reachedEnd)
64
+ return;
65
+ if (mode.items.some((item) => item.prepared?.additionalHostExposure))
66
+ context.setAction({ kind: "confirm-exposure", items: mode.items });
67
+ else
68
+ void confirmInstall(context, mode.items, false);
69
+ }, () => cancel(context));
70
+ }
46
71
  function handleHostKey(context, mode, input, key) {
47
72
  applyChecklistOutcome(hostChecklistKey(mode, HOST_CHOICES, input, key), {
48
73
  cancel: () => cancel(context),
@@ -92,12 +117,7 @@ export function handleCatalogActionKey(context, mode, input, key) {
92
117
  handleFixedKey(context, mode, input, key);
93
118
  return;
94
119
  case "confirm-install":
95
- answerYesNo(input, key, () => {
96
- if (mode.items.some((item) => item.prepared?.additionalHostExposure))
97
- context.setAction({ ...mode, kind: "confirm-exposure" });
98
- else
99
- void confirmInstall(context, mode.items, false);
100
- }, () => cancel(context));
120
+ handleInstallKey(context, mode, input, key);
101
121
  return;
102
122
  case "confirm-exposure":
103
123
  answerYesNo(input, key, () => void confirmInstall(context, mode.items, true), () => cancel(context));
@@ -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, { chrome: chrome, 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";
@@ -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, { chrome: chrome, 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,5 +1,5 @@
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 }) {