@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.
- package/CHANGELOG.md +61 -0
- package/README.md +3 -1
- package/dist/src/app-flow.js +8 -1
- package/dist/src/atomic-file.js +29 -3
- package/dist/src/cli.js +2 -0
- package/dist/src/flow-progress.js +9 -0
- package/dist/src/git-source.js +257 -15
- package/dist/src/preview-model.js +37 -0
- package/dist/src/process-runner.js +1 -0
- package/dist/src/profile-cli.js +12 -4
- package/dist/src/profile-export.js +23 -3
- package/dist/src/skill-update.js +1 -0
- package/dist/src/source-refresh-session.js +139 -0
- package/dist/src/sources.js +4 -4
- package/dist/src/tui/adoption-actions.js +21 -2
- package/dist/src/tui/app-actions.js +57 -47
- package/dist/src/tui/app.js +22 -4
- package/dist/src/tui/bulk-actions.js +45 -11
- package/dist/src/tui/catalog-actions.js +12 -0
- package/dist/src/tui/catalog-flow.js +31 -11
- package/dist/src/tui/catalog-panel.js +15 -13
- package/dist/src/tui/catalog-tree.js +7 -2
- package/dist/src/tui/catalog-view.js +12 -6
- package/dist/src/tui/file-picker.js +2 -1
- package/dist/src/tui/installations-browse.js +1 -1
- package/dist/src/tui/installations-panel.js +12 -4
- package/dist/src/tui/installations-rows.js +2 -2
- package/dist/src/tui/installations-tree.js +1 -1
- package/dist/src/tui/installations-view.js +16 -7
- package/dist/src/tui/keys.js +35 -4
- package/dist/src/tui/manage-actions.js +55 -27
- package/dist/src/tui/manage-panel.js +15 -29
- package/dist/src/tui/mode-keys.js +11 -1
- package/dist/src/tui/package-actions.js +25 -18
- package/dist/src/tui/package-rows.js +9 -4
- package/dist/src/tui/panel-parts.js +54 -37
- package/dist/src/tui/preview-render.js +93 -0
- package/dist/src/tui/preview-window.js +185 -20
- package/dist/src/tui/profile-view.js +54 -44
- package/dist/src/tui/render.js +13 -1
- package/dist/src/tui/safe-text.js +25 -0
- package/dist/src/tui/sources-mode.js +2 -2
- package/dist/src/tui/sources-recipes.js +53 -32
- package/dist/src/tui/sources-view.js +85 -39
- package/dist/src/tui/text-wrap.js +47 -3
- package/dist/src/tui/unmanaged-actions.js +17 -5
- package/dist/src/tui/updates-flow.js +11 -5
- package/dist/src/tui/updates-keys.js +2 -2
- package/dist/src/tui/updates-model.js +5 -2
- package/dist/src/tui/updates-panel.js +10 -9
- package/dist/src/tui/updates-preview.js +27 -44
- package/dist/src/tui/updates-view.js +15 -11
- package/dist/src/tui/updates.js +117 -33
- package/dist/src/update-flow.js +31 -15
- package/package.json +1 -1
package/dist/src/skill-update.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/sources.js
CHANGED
|
@@ -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
|
|
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,
|
|
7
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return {
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
...
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
] : []),
|
|
36
|
-
|
|
37
|
-
...(step.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
...(recipe?.
|
|
53
|
-
...
|
|
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
|
-
|
|
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)…`;
|
|
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)}
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
230
|
+
const styled = appPreviewLines(mode);
|
|
231
|
+
const lines = styled.map(lineText);
|
|
221
232
|
const chrome = appPreviewChrome(mode);
|
|
222
|
-
|
|
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
|
}
|
package/dist/src/tui/app.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box,
|
|
3
|
-
import {
|
|
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}
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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));
|