@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
@@ -128,6 +128,7 @@ export async function applyUpdateBatch(selected, options) {
128
128
  const items = "updateable" in selected ? selected.updateable : selected;
129
129
  const results = [];
130
130
  for (const item of items) {
131
+ options.onItem?.(item, results.length + 1, items.length);
131
132
  try {
132
133
  const result = await applySkillUpdate(item, options);
133
134
  results.push(Object.freeze({ id: item.id, selection: item.selection, status: "updated", result }));
@@ -0,0 +1,139 @@
1
+ import { projectSourceOf, resolveProjectSource } from "./sources.js";
2
+ /** ADR 0004 (amended): how long a refresh made by this process stands in for the one a preview needs. */
3
+ const FRESHNESS_WINDOW_MS = 60 * 60 * 1000;
4
+ const BACKGROUND_CONCURRENCY = 2;
5
+ export function createSourceRefreshSession(operations, options = {}) {
6
+ const now = options.now ?? Date.now;
7
+ let states = new Map();
8
+ const inFlight = new Map();
9
+ const listeners = new Set();
10
+ const queue = [];
11
+ let backgroundRunning = 0;
12
+ const setState = (sourceId, state) => {
13
+ states = new Map(states).set(sourceId, state);
14
+ for (const listener of listeners)
15
+ listener();
16
+ };
17
+ // The project refresh carries the Source's own URL, so it also covers Sources missing from the registry.
18
+ const refreshReally = (source, background) => operations.refreshProjectSource
19
+ ? operations.refreshProjectSource(projectSourceOf(source), { background })
20
+ : operations.refreshSource(source.id, { background });
21
+ const start = (source, background) => {
22
+ const startedAt = now();
23
+ setState(source.id, { status: "refreshing", startedAt });
24
+ const running = refreshReally(source, background).then(() => {
25
+ inFlight.delete(source.id);
26
+ setState(source.id, { status: "refreshed", startedAt, finishedAt: now() });
27
+ }, (error) => {
28
+ inFlight.delete(source.id);
29
+ setState(source.id, { status: "failed", startedAt, finishedAt: now(), error: error instanceof Error ? error.message : String(error) });
30
+ throw error;
31
+ });
32
+ inFlight.set(source.id, running);
33
+ return running;
34
+ };
35
+ const isFresh = (sourceId) => {
36
+ const state = states.get(sourceId);
37
+ return state?.status === "refreshed" && state.finishedAt !== undefined && now() - state.finishedAt < FRESHNESS_WINDOW_MS;
38
+ };
39
+ // Background refreshes cannot prompt and are time-limited (D2), so a user call never joins one:
40
+ // it waits for it to settle, then reuses its success (unless forced) or refreshes in the foreground.
41
+ const backgroundRuns = new Set();
42
+ const userRuns = new Map();
43
+ const refreshInBackground = (source) => {
44
+ const running = inFlight.get(source.id);
45
+ if (running)
46
+ return running;
47
+ if (isFresh(source.id))
48
+ return Promise.resolve();
49
+ const started = start(source, true);
50
+ backgroundRuns.add(started);
51
+ const forget = () => backgroundRuns.delete(started);
52
+ started.then(forget, forget);
53
+ return started;
54
+ };
55
+ const refreshForUser = (source, force) => {
56
+ const pending = userRuns.get(source.id);
57
+ // An unforced call may share any pending run; a forced one only another forced run.
58
+ if (pending && (pending.force || !force))
59
+ return pending.run;
60
+ const run = (async () => {
61
+ if (pending)
62
+ await pending.run.catch(() => undefined);
63
+ // A background refresh may start while this one waits, so wait until none is running.
64
+ for (let running = inFlight.get(source.id); running; running = inFlight.get(source.id)) {
65
+ await running.catch(() => undefined);
66
+ }
67
+ if (!force && isFresh(source.id))
68
+ return;
69
+ await start(source, false);
70
+ })();
71
+ const entry = { run, force };
72
+ userRuns.set(source.id, entry);
73
+ const forget = () => {
74
+ if (userRuns.get(source.id) === entry)
75
+ userRuns.delete(source.id);
76
+ };
77
+ run.then(forget, forget);
78
+ return run;
79
+ };
80
+ const drain = () => {
81
+ while (backgroundRunning < BACKGROUND_CONCURRENCY && queue.length > 0) {
82
+ const source = queue.shift();
83
+ backgroundRunning += 1;
84
+ void refreshInBackground(source)
85
+ .catch(() => undefined)
86
+ .finally(() => {
87
+ backgroundRunning -= 1;
88
+ drain();
89
+ });
90
+ }
91
+ };
92
+ return {
93
+ refresh: (source, refreshOptions = {}) => refreshForUser(source, refreshOptions.force === true),
94
+ refreshInBackground(sources) {
95
+ queue.push(...sources);
96
+ drain();
97
+ },
98
+ snapshot: () => states,
99
+ subscribe(listener) {
100
+ listeners.add(listener);
101
+ return () => listeners.delete(listener);
102
+ },
103
+ now,
104
+ stop() {
105
+ queue.length = 0;
106
+ },
107
+ async settled() {
108
+ while (inFlight.size > 0 || userRuns.size > 0) {
109
+ await Promise.allSettled([...inFlight.values(), ...[...userRuns.values()].map((entry) => entry.run)]);
110
+ }
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Routes `refreshSource` and `refreshProjectSource` through the session, so previews, Updates and
116
+ * Packages share and reuse refreshes. Built-in and unregistered ids keep the plain behavior and errors.
117
+ */
118
+ export function withRefreshSession(operations, session) {
119
+ const refreshProjectSource = operations.refreshProjectSource;
120
+ return {
121
+ ...operations,
122
+ async refreshSource(sourceId, options) {
123
+ const source = (await operations.listSources()).find((candidate) => candidate.kind === "git" && candidate.id === sourceId);
124
+ if (!source)
125
+ return operations.refreshSource(sourceId, options);
126
+ await session.refresh(source, options);
127
+ return source;
128
+ },
129
+ ...(refreshProjectSource === undefined ? {} : {
130
+ async refreshProjectSource(projectSource, options) {
131
+ const source = resolveProjectSource(projectSource);
132
+ if (source.kind !== "git")
133
+ return refreshProjectSource(projectSource, options);
134
+ await session.refresh(source, options);
135
+ return source;
136
+ },
137
+ }),
138
+ };
139
+ }
@@ -72,7 +72,7 @@ export function createSourceOperations(options = {}) {
72
72
  const state = await stateStore.load();
73
73
  return Object.freeze([BUILT_IN_SOURCE, ...state.gitSources]);
74
74
  },
75
- async refreshSource(sourceId) {
75
+ async refreshSource(sourceId, options) {
76
76
  if (sourceId === BUILT_IN_SOURCE.id) {
77
77
  throw new BuiltInSourceError();
78
78
  }
@@ -81,7 +81,7 @@ export function createSourceOperations(options = {}) {
81
81
  if (!source) {
82
82
  throw new SourceNotFoundError(sourceId);
83
83
  }
84
- await gitAccess.refresh(source);
84
+ await gitAccess.refresh(source, options);
85
85
  return source;
86
86
  },
87
87
  async removeGitSource(sourceId, installations = []) {
@@ -185,12 +185,12 @@ export function createSourceOperations(options = {}) {
185
185
  async resolveProjectSource(projectSource) {
186
186
  return resolveProjectSource(projectSource);
187
187
  },
188
- async refreshProjectSource(projectSource) {
188
+ async refreshProjectSource(projectSource, options) {
189
189
  const source = resolveProjectSource(projectSource);
190
190
  if (source.kind === "builtin") {
191
191
  throw new BuiltInSourceError();
192
192
  }
193
- await gitAccess.refresh(source);
193
+ await gitAccess.refresh(source, options);
194
194
  return source;
195
195
  },
196
196
  async readInstallationMethod(source, skillPath, previewTree) {
@@ -2,7 +2,7 @@ import { HOST_CHOICES } from "./catalog-actions.js";
2
2
  import { runInstall } from "./catalog-installs.js";
3
3
  import { errorText } from "./batch.js";
4
4
  import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
5
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
5
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, movePreviewCursor, versionInputKey } from "./mode-keys.js";
6
6
  import { busy } from "./working-model.js";
7
7
  function cancel(context) {
8
8
  context.setMessage({ kind: "ok", text: "Adoption cancelled" });
@@ -49,7 +49,7 @@ async function prepare(context, mode, version) {
49
49
  const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
50
50
  if (!context.isMounted())
51
51
  return;
52
- context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption });
52
+ context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption, cursor: 0 });
53
53
  }
54
54
  catch (error) {
55
55
  fail(context, error);
@@ -105,13 +105,32 @@ function handleFixedKey(context, mode, input, key) {
105
105
  else if (outcome.kind === "edit")
106
106
  context.setMode({ ...mode, value: outcome.value });
107
107
  }
108
+ /** What an adoptable preview shows, so the key handler and the panel page the same rows. */
109
+ export function adoptionPreview(mode) {
110
+ const { prepared } = mode.adoption;
111
+ const name = mode.entry.name;
112
+ return {
113
+ lines: [...prepared.preview, ...(prepared.runsExternalCommand ? ["This install runs an external command (see above)."] : [])],
114
+ chrome: { title: `Adopt ${name}`, action: `Adopt ${name}`, consequence: `Adopt ${name}? y/n` },
115
+ };
116
+ }
117
+ /** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
108
118
  function handleConfirmKey(context, mode, input, key) {
109
119
  if (!mode.adoption.verdict.adoptable) {
110
120
  if (key.escape || key.return || input === "n")
111
121
  cancel(context);
112
122
  return;
113
123
  }
124
+ const { lines, chrome } = adoptionPreview(mode);
125
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
126
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
127
+ if (cursor !== undefined) {
128
+ context.setMode({ ...mode, cursor });
129
+ return;
130
+ }
114
131
  answerYesNo(input, key, () => {
132
+ if (!viewport.reachedEnd)
133
+ return;
115
134
  if (mode.adoption.prepared.additionalHostExposure)
116
135
  context.setMode({ ...mode, kind: "confirm-exposure" });
117
136
  else
@@ -1,10 +1,14 @@
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 { formatStepLabel } from "../flow-progress.js";
4
+ import { commandLine } from "../preview-model.js";
3
5
  import { PROJECT_HOSTS } from "../project-manifest.js";
4
6
  import { errorText, runBatch } from "./batch.js";
7
+ import { isYes } from "./keys.js";
5
8
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
- import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
- import { previewViewport } from "./preview-window.js";
9
+ import { BorderPanel, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
10
+ import { usePreviewGeometry } from "./preview-window.js";
11
+ import { fieldLine, lineText, span, stepBlockLines, textLine } from "./preview-render.js";
8
12
  import { theme } from "./theme.js";
9
13
  import { operationStart } from "./working-model.js";
10
14
  /** Finished items shown under a running batch; older ones are counted. */
@@ -17,59 +21,65 @@ export function isAppMode(mode) {
17
21
  /** Recipe name, falling back to the file identity for invalid recipes. */
18
22
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
19
23
  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 };
24
+ if (mode.kind === "app-approve")
25
+ return recipeApprovalChrome(mode.entry);
26
+ if (mode.kind === "app-manual")
27
+ return { title: "App manual step", action: "Check now", consequence: "Manual step done, check now? y/n" };
28
+ const { action, entry } = mode.plan;
29
+ return {
30
+ title: `App ${action} confirmation`,
31
+ action: `${action[0].toUpperCase()}${action.slice(1)} ${appName(entry)}`,
32
+ consequence: `${action} ${appName(entry)}? y/n`,
33
+ };
34
+ }
35
+ /** Approving a recipe records consent to its commands; it installs nothing. */
36
+ export function recipeApprovalChrome(entry) {
37
+ return {
38
+ title: "Recipe approval · does not install",
39
+ action: `Approve ${appName(entry)}`,
40
+ consequence: `Approve ${appName(entry)}? y/n · recipe approval does not install the App.`,
41
+ };
26
42
  }
27
- export function appStepLines(step) {
28
- const argv = step.argv;
43
+ /** What confirming an App step does: its name, Host and versions, then the step as one block. */
44
+ export function appPlanLines(plan) {
45
+ const { previousVersion, latestVersion } = plan;
46
+ const both = previousVersion !== undefined && latestVersion !== undefined;
29
47
  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] : []),
48
+ [
49
+ span(` App ${appName(plan.entry)} ${plan.action}${plan.host ? ` (${plan.host})` : ""}`, theme.value, true),
50
+ ...(both ? [span(` ${previousVersion} → ${latestVersion}`, theme.version)] : []),
51
+ ],
52
+ ...(!both && previousVersion !== undefined ? [fieldLine(" Previous version:", previousVersion)] : []),
53
+ ...(!both && latestVersion !== undefined ? [fieldLine(" Latest version:", latestVersion)] : []),
54
+ textLine(" "),
55
+ ...stepBlockLines([{ ...plan, step: plan.host ? `${plan.action}.${plan.host}` : plan.action }]),
42
56
  ];
43
57
  }
58
+ /** The recipe's identity and approval status, then one block per step. */
44
59
  export function appRecipePreviewLines(entry, status, steps) {
45
60
  const recipe = entry.recipe;
46
61
  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)]),
62
+ [
63
+ span(` ${appName(entry)}`, theme.value, true),
64
+ span(` ${recipe?.platform ?? "all"}`, theme.field),
65
+ span(` ● ${status}`, status === "approved" ? theme.success : theme.warning),
66
+ ],
67
+ [span(` ${[entry.file, ...(recipe?.homepage ? [recipe.homepage] : [])].join(" · ")}`, theme.legend)],
68
+ ...(recipe?.skills ? [fieldLine(" Owned Skills:", commandLine(recipe.skills, { terminal: true }))] : []),
69
+ textLine(" "),
70
+ ...stepBlockLines(steps),
54
71
  ];
55
72
  }
56
73
  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
- ];
74
+ return mode.kind === "app-approve" ? mode.lines : appPlanLines(mode.plan);
65
75
  }
66
76
  /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
67
77
  function appBusy(label, previous, done) {
68
78
  return { kind: "app-busy", label, startedAt: operationStart(previous), ...(done?.length ? { done } : {}) };
69
79
  }
70
- /** `Running App step: name (2/5)…`; the position is left out for a batch of one. */
80
+ /** `Running App step: name (2/5)…`; `finished` counts the steps already done. */
71
81
  function stepLabel(verb, entry, finished, total) {
72
- return `${verb} App step: ${appName(entry)}${total > 1 ? ` (${finished + 1}/${total})` : ""}…`;
82
+ return formatStepLabel(`${verb} App step: ${appName(entry)}`, finished + 1, total);
73
83
  }
74
84
  async function prepareNext(context, requests, results = [], failures = 0, previous) {
75
85
  const { manage, apps } = context;
@@ -182,8 +192,8 @@ export function handleAppKey(context, mode, input, key) {
182
192
  }
183
193
  }
184
194
  else {
185
- const lines = appPreviewLines(mode);
186
- const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, appPreviewChrome(mode));
195
+ const lines = appPreviewLines(mode).map(lineText);
196
+ const viewport = context.previewGeometry(lines, mode.cursor, appPreviewChrome(mode));
187
197
  const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
188
198
  if (cursor !== undefined) {
189
199
  manage.setMode({ ...mode, cursor });
@@ -197,7 +207,7 @@ export function handleAppKey(context, mode, input, key) {
197
207
  cancel();
198
208
  return;
199
209
  }
200
- if (input !== "y" || !viewport.reachedEnd)
210
+ if (!isYes(input, key) || !viewport.reachedEnd)
201
211
  return;
202
212
  if (mode.kind !== "app-approve") {
203
213
  void execute(context, mode);
@@ -209,7 +219,7 @@ export function handleAppKey(context, mode, input, key) {
209
219
  }
210
220
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
211
221
  export function AppPanel({ mode }) {
212
- const { rows, columns } = useWindowSize();
222
+ const geometry = usePreviewGeometry();
213
223
  if (mode.kind === "app-busy") {
214
224
  const done = mode.done ?? [];
215
225
  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 +227,8 @@ export function AppPanel({ mode }) {
217
227
  if (mode.kind === "app-hosts") {
218
228
  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
229
  }
220
- const lines = appPreviewLines(mode);
230
+ const styled = appPreviewLines(mode);
231
+ const lines = styled.map(lineText);
221
232
  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 }));
233
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
224
234
  }
@@ -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
@@ -1,8 +1,11 @@
1
1
  import { PROJECT_HOSTS } from "../project-manifest.js";
2
2
  import { assertManagedInstallationRecordsUnchanged } from "../skill-removal.js";
3
+ import { formatStepLabel } from "../flow-progress.js";
3
4
  import { errorText, runBatch } from "./batch.js";
4
5
  import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
5
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
6
+ import { answerPaged, answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
7
+ import { span } from "./preview-render.js";
8
+ import { theme } from "./theme.js";
6
9
  import { skillOf, sourceIdOf } from "./installations.js";
7
10
  import { prepareUnmanagedRemoval, runUnmanagedRemoval } from "./unmanaged-actions.js";
8
11
  import { busy } from "./working-model.js";
@@ -78,12 +81,12 @@ function resultOf(outcomes, skipped) {
78
81
  applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
79
82
  };
80
83
  }
81
- /** `Removing name (2/5)…` for each item as it starts. */
84
+ /** `Removing name (2/5)…` (no position for one item) for each item as it starts. */
82
85
  function startOf(verb, progress) {
83
86
  if (!progress)
84
87
  return undefined;
85
88
  return (item, position, total, finished) => {
86
- progress(`${verb} ${item.label} (${position}/${total})…`, finished.map(outcome => outcome.line));
89
+ progress(formatStepLabel(`${verb} ${item.label}`, position, total), finished.map(outcome => outcome.line));
87
90
  };
88
91
  }
89
92
  /** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
@@ -193,7 +196,7 @@ async function preparePlan(context, label, prepare, confirmation) {
193
196
  }
194
197
  /** `u` with marks: prepares every marked leaf, then asks once. */
195
198
  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 }));
199
+ return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}…`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan, cursor: 0 }));
197
200
  }
198
201
  /** `h` with marks: asks the hosts once, for the union of what the eligible items lack. */
199
202
  export function startBulkHostAddition(context, targets) {
@@ -207,7 +210,7 @@ export function startBulkHostAddition(context, targets) {
207
210
  context.setMode({ kind: "bulk-hosts", targets: ready, skipped, choices, cursor: 0, selected: [] });
208
211
  }
209
212
  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 }));
213
+ 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
214
  }
212
215
  async function apply(context, label, action) {
213
216
  const started = busy(label);
@@ -226,6 +229,30 @@ async function apply(context, label, action) {
226
229
  context.unmark(result.applied);
227
230
  await context.finish(result.message);
228
231
  }
232
+ function previewOf(item) {
233
+ if (!("kind" in item))
234
+ return item.prepared.preview;
235
+ return item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview;
236
+ }
237
+ /**
238
+ * One block per planned item (its label, then its preview), then the skipped ones, with the footer's action and question.
239
+ * Only the labels are styled; `styled` is sparse so the other lines keep the colours inferred from their text.
240
+ */
241
+ export function bulkConfirmPreview(mode) {
242
+ const { plan } = mode;
243
+ const items = count(plan.items.length, "item");
244
+ const blocks = plan.items.flatMap((item) => [
245
+ { text: item.label, label: true },
246
+ ...previewOf(item).map((text) => ({ text, label: false })),
247
+ ]);
248
+ const skipped = plan.skipped.map(({ label, reason }) => ({ text: `Skipped ${label}: ${reason}`, label: false }));
249
+ const entries = [...blocks, ...skipped];
250
+ const lines = entries.map(({ text }) => text);
251
+ const styled = entries.map(({ text, label }) => label ? [span(text, theme.group)] : undefined);
252
+ return mode.kind === "confirm-bulk-uninstall"
253
+ ? { lines, styled, chrome: { title: "Uninstall", action: `Uninstall ${items}`, consequence: `Uninstall ${items}? y/n` } }
254
+ : { lines, styled, chrome: { title: "Add hosts", action: `Add hosts to ${items}`, consequence: `Add hosts to ${items}? y/n` } };
255
+ }
229
256
  /** Handles a key in a bulk mode; returns false for any other mode. */
230
257
  export function handleBulkKey(context, mode, input, key) {
231
258
  const current = mode;
@@ -241,13 +268,20 @@ export function handleBulkKey(context, mode, input, key) {
241
268
  return true;
242
269
  }
243
270
  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"));
271
+ case "confirm-bulk-hosts": {
272
+ const { lines, chrome } = bulkConfirmPreview(current);
273
+ answerPaged(context.previewGeometry(lines, current.cursor, chrome), current.cursor, input, key, {
274
+ moveTo: (cursor) => context.setMode({ ...current, cursor }),
275
+ yes: () => {
276
+ if (current.kind === "confirm-bulk-hosts")
277
+ context.setMode({ kind: "confirm-bulk-exposure", plan: current.plan });
278
+ else
279
+ void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, (progress) => runBulkUninstall(current.plan, operations, env, progress));
280
+ },
281
+ no: () => cancel(context, current.kind === "confirm-bulk-hosts" ? "Add hosts cancelled" : "Uninstall cancelled"),
282
+ });
250
283
  return true;
284
+ }
251
285
  case "confirm-bulk-exposure":
252
286
  answerYesNo(input, key, () => {
253
287
  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));