@iseoane/agent-depot 0.6.4 → 0.6.5

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 (43) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.es.md +14 -10
  3. package/README.md +22 -8
  4. package/dist/src/app-cli.js +10 -5
  5. package/dist/src/app-flow.js +2 -2
  6. package/dist/src/atomic-file.js +9 -0
  7. package/dist/src/cli.js +98 -67
  8. package/dist/src/executable-resolution.js +9 -3
  9. package/dist/src/git-source.js +2 -4
  10. package/dist/src/package-cli.js +9 -4
  11. package/dist/src/package-flow.js +2 -1
  12. package/dist/src/package-host-state.js +44 -6
  13. package/dist/src/package-state.js +2 -5
  14. package/dist/src/process-runner.js +234 -16
  15. package/dist/src/profile-cli.js +8 -6
  16. package/dist/src/progress.js +157 -0
  17. package/dist/src/source-state.js +2 -5
  18. package/dist/src/tui/adoption-actions.js +4 -3
  19. package/dist/src/tui/app-actions.js +16 -9
  20. package/dist/src/tui/bulk-actions.js +7 -6
  21. package/dist/src/tui/catalog-flow.js +3 -2
  22. package/dist/src/tui/catalog-panel.js +2 -2
  23. package/dist/src/tui/catalog-view.js +26 -6
  24. package/dist/src/tui/file-picker.js +52 -20
  25. package/dist/src/tui/installations-view.js +44 -11
  26. package/dist/src/tui/manage-actions.js +7 -6
  27. package/dist/src/tui/manage-panel.js +2 -2
  28. package/dist/src/tui/package-actions.js +63 -31
  29. package/dist/src/tui/panel-parts.js +20 -0
  30. package/dist/src/tui/profile-view.js +84 -15
  31. package/dist/src/tui/sources-mode.js +2 -1
  32. package/dist/src/tui/sources-recipes.js +22 -10
  33. package/dist/src/tui/sources-view.js +13 -12
  34. package/dist/src/tui/text-wrap.js +13 -0
  35. package/dist/src/tui/unmanaged-actions.js +3 -2
  36. package/dist/src/tui/updates-flow.js +10 -7
  37. package/dist/src/tui/updates-keys.js +3 -2
  38. package/dist/src/tui/updates-model.js +2 -2
  39. package/dist/src/tui/updates-panel.js +3 -2
  40. package/dist/src/tui/updates-view.js +6 -3
  41. package/dist/src/tui/view-state.js +12 -0
  42. package/dist/src/tui/working-model.js +33 -0
  43. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { describeCoordinates } from "../package-model.js";
3
3
  import { errorText } from "./batch.js";
4
- import { answerYesNo } from "./mode-keys.js";
4
+ import { movePreviewCursor } from "./mode-keys.js";
5
5
  import { installedEvidenceLabel, packagePlanLines } from "./package-rows.js";
6
6
  import { PreviewConfirm } from "./panel-parts.js";
7
+ import { previewViewport } from "./preview-window.js";
8
+ import { busy } from "./working-model.js";
7
9
  /** The per-row keys of a Package; Enter is approval, handled beside these. */
8
10
  export const PACKAGE_ACTION_KEYS = new Map([
9
11
  ["i", "install"],
@@ -22,16 +24,16 @@ export async function startPackageAction(context, target, input) {
22
24
  manage.setMessage(undefined);
23
25
  try {
24
26
  if (input === "approve") {
25
- manage.setMode({ kind: "busy", label: `Preparing the declaration of ${target.name}...` });
27
+ manage.setMode(busy(`Preparing the declaration of ${target.name}…`));
26
28
  const plan = await packages.planLifecycle(target.selection, "install");
27
29
  if (manage.isMounted()) {
28
- manage.setMode({ kind: "package-approve", target, lines: packagePlanLines(plan, "needs approval") });
30
+ manage.setMode({ kind: "package-approve", target, lines: packagePlanLines(plan, "needs approval"), cursor: 0 });
29
31
  }
30
32
  return;
31
33
  }
32
34
  const action = PACKAGE_ACTION_KEYS.get(input);
33
35
  if (action === "forget") {
34
- manage.setMode({ kind: "busy", label: `Preparing to forget ${target.name}...` });
36
+ manage.setMode(busy(`Preparing to forget ${target.name}…`));
35
37
  const inspection = await packages.inspect(target.selection);
36
38
  if (manage.isMounted()) {
37
39
  manage.setMode({
@@ -43,16 +45,17 @@ export async function startPackageAction(context, target, input) {
43
45
  `Installed: ${installedEvidenceLabel(inspection.installed)}`,
44
46
  "Forgetting drops the Agent Depot selection record only; the host install is left as it is and no host command runs.",
45
47
  ],
48
+ cursor: 0,
46
49
  });
47
50
  }
48
51
  return;
49
52
  }
50
53
  if (action !== undefined) {
51
- manage.setMode({ kind: "busy", label: `Preparing the ${action} of ${target.name}...` });
54
+ manage.setMode(busy(`Preparing the ${action} of ${target.name}…`));
52
55
  const plan = await packages.planLifecycle(target.selection, action);
53
56
  const approval = await packages.approvalStatus(target.selection, action);
54
57
  if (manage.isMounted())
55
- manage.setMode({ kind: "package-confirm", plan, approval });
58
+ manage.setMode({ kind: "package-confirm", plan, approval, cursor: 0 });
56
59
  return;
57
60
  }
58
61
  await manage.finish({ kind: "error", text: `The action ${JSON.stringify(input)} is not available for Packages` });
@@ -64,7 +67,7 @@ export async function startPackageAction(context, target, input) {
64
67
  /** The `y` of a lifecycle confirmation whose receipt is missing approves it first, then asks again. */
65
68
  async function approveThenConfirm(context, mode) {
66
69
  const { manage, packages } = context;
67
- manage.setMode({ kind: "busy", label: `Approving the ${mode.plan.action} declaration of ${mode.plan.descriptor.name}...` });
70
+ manage.setMode(busy(`Approving the ${mode.plan.action} declaration of ${mode.plan.descriptor.name}…`));
68
71
  try {
69
72
  await packages.approve(mode.plan.selection, mode.plan.action);
70
73
  if (manage.isMounted())
@@ -100,7 +103,7 @@ function resultLine(plan, result) {
100
103
  }
101
104
  async function executePackagePlan(context, plan) {
102
105
  const { manage, packages } = context;
103
- manage.setMode({ kind: "busy", label: `Running the ${plan.action} of ${plan.descriptor.name}...` });
106
+ manage.setMode(busy(`Running the ${plan.action} of ${plan.descriptor.name}…`));
104
107
  let result;
105
108
  try {
106
109
  result = await packages.executeLifecycle(plan, true);
@@ -114,7 +117,7 @@ async function executePackagePlan(context, plan) {
114
117
  }
115
118
  async function forgetSelection(context, mode) {
116
119
  const { manage, packages } = context;
117
- manage.setMode({ kind: "busy", label: `Forgetting ${mode.target.name}...` });
120
+ manage.setMode(busy(`Forgetting ${mode.target.name}…`));
118
121
  try {
119
122
  await packages.forget(mode.target.selection, true);
120
123
  await manage.finish({ kind: "ok", text: `Forgot ${mode.target.name}; no host command ran` });
@@ -123,43 +126,72 @@ async function forgetSelection(context, mode) {
123
126
  await manage.finish({ kind: "error", text: errorText(error) });
124
127
  }
125
128
  }
126
- /** Applies the y/n keys of every Package confirmation; `n` and Esc run nothing. */
129
+ /** What the preview shows for a mode, so the key handler and the panel page the same rows. */
130
+ function packagePreview(mode) {
131
+ switch (mode.kind) {
132
+ case "package-approve":
133
+ return {
134
+ lines: mode.lines,
135
+ chrome: reviewChrome("Package approval", `Approve this install declaration for ${mode.target.name}? y/n`),
136
+ };
137
+ case "package-forget":
138
+ return {
139
+ lines: mode.lines,
140
+ chrome: reviewChrome("Forget Package selection", `Forget the selection record of ${mode.target.name}? y/n`),
141
+ };
142
+ case "package-confirm":
143
+ return {
144
+ lines: packagePlanLines(mode.plan, mode.approval),
145
+ chrome: reviewChrome(`Package ${mode.plan.action} confirmation`, mode.approval === "needs approval"
146
+ ? "Approve this declaration and continue? y/n"
147
+ : `${mode.plan.action} ${mode.plan.descriptor.name}? y/n`),
148
+ };
149
+ }
150
+ }
151
+ function reviewChrome(title, question) {
152
+ return { title, questions: [question], footerRows: 2 };
153
+ }
154
+ /**
155
+ * Pages the declaration with the arrows, PgUp/PgDn and j/k. `y` answers from any page: the panel shows
156
+ * the `Review N of M` position and the row window while more remains. `n` and Esc run nothing.
157
+ */
127
158
  export function handlePackageKey(context, mode, input, key) {
128
159
  const { manage, packages } = context;
129
160
  const cancel = (text) => {
130
161
  manage.setMessage({ kind: "ok", text });
131
162
  manage.browse();
132
163
  };
164
+ const { lines, chrome } = packagePreview(mode);
165
+ const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, chrome);
166
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
167
+ if (cursor !== undefined) {
168
+ manage.setMode({ ...mode, cursor });
169
+ return;
170
+ }
171
+ if (input === "n" || key.escape) {
172
+ cancel("Package action cancelled");
173
+ return;
174
+ }
175
+ if (input !== "y")
176
+ return;
133
177
  switch (mode.kind) {
134
178
  case "package-approve":
135
- answerYesNo(input, key, () => {
136
- manage.setMode({ kind: "busy", label: `Approving the install declaration of ${mode.target.name}...` });
137
- void packages.approve(mode.target.selection, "install").then(() => manage.finish({ kind: "ok", text: `Approved the install declaration of ${mode.target.name}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
138
- }, () => cancel("Package action cancelled"));
179
+ manage.setMode(busy(`Approving the install declaration of ${mode.target.name}…`));
180
+ void packages.approve(mode.target.selection, "install").then(() => manage.finish({ kind: "ok", text: `Approved the install declaration of ${mode.target.name}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
139
181
  return;
140
182
  case "package-forget":
141
- answerYesNo(input, key, () => void forgetSelection(context, mode), () => cancel("Package action cancelled"));
183
+ void forgetSelection(context, mode);
142
184
  return;
143
185
  case "package-confirm":
144
- answerYesNo(input, key, () => {
145
- if (mode.approval === "needs approval")
146
- void approveThenConfirm(context, mode);
147
- else
148
- void executePackagePlan(context, mode.plan);
149
- }, () => cancel("Package action cancelled"));
186
+ if (mode.approval === "needs approval")
187
+ void approveThenConfirm(context, mode);
188
+ else
189
+ void executePackagePlan(context, mode.plan);
150
190
  return;
151
191
  }
152
192
  }
153
193
  /** Renders a Package prompt with the existing preview panel and y/n confirmation. */
154
194
  export function PackagePanel({ mode }) {
155
- switch (mode.kind) {
156
- case "package-approve":
157
- return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve this install declaration for ${mode.target.name}? y/n` });
158
- case "package-forget":
159
- return _jsx(PreviewConfirm, { lines: mode.lines, question: `Forget the selection record of ${mode.target.name}? y/n` });
160
- case "package-confirm":
161
- return (_jsx(PreviewConfirm, { lines: packagePlanLines(mode.plan, mode.approval), question: mode.approval === "needs approval"
162
- ? "Approve this declaration and continue? y/n"
163
- : `${mode.plan.action} ${mode.plan.descriptor.name}? y/n` }));
164
- }
195
+ const { lines, chrome } = packagePreview(mode);
196
+ return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: chrome.questions[0], chrome: chrome });
165
197
  }
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from "ink";
3
+ import { useEffect, useState } from "react";
3
4
  import { theme } from "./theme.js";
4
5
  import { previewViewport } from "./preview-window.js";
6
+ import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./working-model.js";
5
7
  /** A titled, color-independent boundary for a focused interaction or result. */
6
8
  export function BorderPanel({ title, children }) {
7
9
  return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.title, bold: true, children: title }), children] }));
@@ -9,6 +11,24 @@ export function BorderPanel({ title, children }) {
9
11
  export function FieldLine({ label, value }) {
10
12
  return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
11
13
  }
14
+ /**
15
+ * A spinner, the label and, from one second on, the time since `since` (when the operation began, not
16
+ * when this line mounted, so the clock survives label changes). Without animation it is a static line.
17
+ */
18
+ export function Working({ label, since, now, animate }) {
19
+ const animated = animate ?? animationEnabled();
20
+ const [tick, setTick] = useState(0);
21
+ useEffect(() => {
22
+ if (!animated)
23
+ return undefined;
24
+ const timer = setInterval(() => setTick((value) => value + 1), SPINNER_TICK_MS);
25
+ // A redraw timer alone must never keep the process alive.
26
+ timer.unref();
27
+ return () => clearInterval(timer);
28
+ }, [animated]);
29
+ const elapsed = animated ? formatElapsed((now ?? Date.now)() - since) : "";
30
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: animated ? SPINNER_FRAMES[tick % SPINNER_FRAMES.length] : STATIC_GLYPH }), _jsxs(Text, { color: theme.value, children: [" ", label] }), elapsed ? _jsxs(Text, { color: theme.legend, children: [" ", elapsed] }) : null] }));
31
+ }
12
32
  export function DecisionPrompt({ children }) {
13
33
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: children }), " ", _jsx(Text, { color: theme.success, bold: true, children: "y" }), _jsx(Text, { color: theme.legend, children: "/" }), _jsx(Text, { color: theme.error, children: "n" })] }));
14
34
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { Box, Text, useWindowSize } from "ink";
4
4
  import stringWidth from "string-width";
@@ -12,12 +12,14 @@ import { packageOperationsOf } from "./environment.js";
12
12
  import { DEFAULT_PROFILE_FILE_NAME, exportDestination, fileNameNotice, pathExists, pickerStartDirectory, resolveExportFileName, } from "./file-picker-model.js";
13
13
  import { FilePicker } from "./file-picker.js";
14
14
  import { useKeys } from "./keys.js";
15
+ import { previewViewport } from "./preview-window.js";
15
16
  import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
16
- import { BorderPanel, PreviewConfirm, PreviewLines } from "./panel-parts.js";
17
- import { fitLines, wrapText } from "./text-wrap.js";
17
+ import { BorderPanel, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
18
+ import { elideMiddle, fitLines, wrapText } from "./text-wrap.js";
18
19
  import { theme } from "./theme.js";
19
20
  import { useMirrored, useMounted } from "./view-state.js";
20
21
  import { computeWindow, useListHeight } from "./window.js";
22
+ import { busy } from "./working-model.js";
21
23
  function rowsFromExport(plan) {
22
24
  return [
23
25
  ...plan.profile.sources.map((value, index) => ({
@@ -105,6 +107,37 @@ function physicalListWindow(rows, rowHeights, selected, height) {
105
107
  return { ...range, indicator: hidden ? `${range.start + 1}–${range.end} of ${rows.length}` : undefined, stickyGroup };
106
108
  }
107
109
  /** The four import blocks in one indexed list, so the checklist and the confirmation share positions. */
110
+ /** An error naming a long path keeps its first and last rows, where the path starts and the reason ends. */
111
+ const NAME_ERROR_ROWS = 3;
112
+ const NAME_HINT = "Type to edit · Ctrl+U clear · Enter continue · Esc back to folder";
113
+ /**
114
+ * A `Label: value` line of the export name stage, wrapped to the panel and kept within `rows` rows. The
115
+ * end of a path (its last folder, the file name) and the end of the typed name (where the cursor is) are
116
+ * what matter, so with fewer than three rows the field keeps its label and its tail behind a `…`; with
117
+ * three or more a path keeps its first row too, around a count of the rows left out.
118
+ */
119
+ function NameField({ label, value, rows, width, tail = false }) {
120
+ const wrapped = wrapText(`${label}${value}`, width);
121
+ const cut = wrapped.length > rows;
122
+ const keepsMiddleCount = cut && !tail && rows > 2;
123
+ let shown = wrapped;
124
+ if (keepsMiddleCount)
125
+ shown = elideMiddle(wrapped, rows, width);
126
+ else if (cut) {
127
+ const characters = Array.from(value);
128
+ for (let dropped = 1; dropped <= characters.length; dropped += 1) {
129
+ shown = wrapText(`${label}…${characters.slice(dropped).join("")}`, width);
130
+ if (shown.length <= rows)
131
+ break;
132
+ }
133
+ }
134
+ return _jsx(_Fragment, { children: shown.map((line, index) => {
135
+ if (index === 0 && line.startsWith(label)) {
136
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsx(Text, { color: theme.value, children: line.slice(label.length) })] }, index);
137
+ }
138
+ return _jsx(Text, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
139
+ }) });
140
+ }
108
141
  function planEntries(plan) {
109
142
  return [...plan.items, ...plan.packages];
110
143
  }
@@ -159,6 +192,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
159
192
  const [cursor, cursorRef, cursorTo] = useMirrored(0);
160
193
  const [text, typed, setText] = useMirrored("");
161
194
  const [nameError, setNameError] = useState(undefined);
195
+ // What the busy mode is doing, and when the name check of the export file began, if one is running.
196
+ const [working, setWorking] = useState(undefined);
197
+ const [nameChecking, setNameChecking] = useState(undefined);
162
198
  const [importNotice, setImportNotice] = useState(undefined);
163
199
  const [exportDirectory, setExportDirectory] = useState(undefined);
164
200
  const [importDirectory, setImportDirectory] = useState(undefined);
@@ -170,7 +206,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
170
206
  const [profile, setProfile] = useState();
171
207
  const [plan, setPlan] = useState();
172
208
  const diagnostics = useRef([]);
173
- const { columns: terminalColumns } = useWindowSize();
209
+ const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
174
210
  const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
175
211
  const hintCursorRow = (mode === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows)[cursorRef.current];
176
212
  const enterHint = hintCursorRow === undefined ? undefined
@@ -184,13 +220,24 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
184
220
  const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
185
221
  const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
186
222
  const height = useListHeight(listHeight, 4 + titleRows + hintRows);
223
+ const confirmChrome = () => ({
224
+ title: "Confirmation",
225
+ questions: [confirmRequest.current?.kind === "export"
226
+ ? "Save/Export Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"
227
+ : "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"],
228
+ footerRows: 2,
229
+ });
187
230
  const showLines = (next, nextMode) => {
188
231
  setLines(next);
189
232
  cursorTo(0);
190
233
  setMode(nextMode);
191
234
  };
192
- const run = async (work) => {
235
+ const startBusy = (label) => {
236
+ setWorking(busy(label));
193
237
  setMode("busy");
238
+ };
239
+ const run = async (label, work) => {
240
+ startBusy(label);
194
241
  try {
195
242
  await work();
196
243
  }
@@ -240,7 +287,10 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
240
287
  setNameError(undefined);
241
288
  setMode("export-folder");
242
289
  };
243
- const abandonNameCheck = () => { nameRequest.current += 1; };
290
+ const abandonNameCheck = () => {
291
+ nameRequest.current += 1;
292
+ setNameChecking(undefined);
293
+ };
244
294
  const chooseExportDirectory = (directory) => {
245
295
  abandonNameCheck();
246
296
  setExportDirectory(directory);
@@ -259,6 +309,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
259
309
  const fileName = outcome.fileName;
260
310
  const finalPath = exportDestination(directory, fileName);
261
311
  const id = ++nameRequest.current;
312
+ // The check never blocks keys, so a line beside the name field shows it is running.
313
+ setNameChecking(Date.now());
262
314
  void (async () => {
263
315
  let exists;
264
316
  try {
@@ -267,9 +319,12 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
267
319
  catch (error) {
268
320
  if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
269
321
  return;
322
+ setNameChecking(undefined);
270
323
  setNameError(`Error: ${error instanceof Error ? error.message : String(error)}`);
271
324
  return;
272
325
  }
326
+ if (mounted.current && nameRequest.current === id)
327
+ setNameChecking(undefined);
273
328
  const current = resolveExportFileName(typed.current);
274
329
  if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
275
330
  return;
@@ -288,7 +343,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
288
343
  const request = confirmRequest.current;
289
344
  if (request === undefined)
290
345
  return;
291
- void run(async () => {
346
+ void run(request.kind === "export" ? "Exporting…" : "Applying import…", async () => {
292
347
  let result;
293
348
  if (request.kind === "export") {
294
349
  await writeProfileExport(request.file, chosenProfile());
@@ -304,7 +359,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
304
359
  });
305
360
  };
306
361
  const previewImport = () => {
307
- void run(async () => {
362
+ void run("Preparing import preview…", async () => {
308
363
  // Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
309
364
  const entries = selectedPlanEntries(plan, selectedRef.current);
310
365
  const subset = selectedImportProfile(profile, entries);
@@ -320,7 +375,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
320
375
  return;
321
376
  setImportDirectory(selection.directory);
322
377
  const id = ++importRequest.current;
323
- setMode("busy");
378
+ startBusy("Loading Profile…");
324
379
  void (async () => {
325
380
  try {
326
381
  const importedProfile = parseProfile(JSON.parse(await readFile(selection.file, "utf8")));
@@ -347,7 +402,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
347
402
  return;
348
403
  if (current === "browse" || current === "results") {
349
404
  if (input === "e") {
350
- void run(async () => {
405
+ void run("Loading Profile…", async () => {
351
406
  const exported = await buildProfileExport(operations, apps, {
352
407
  homeDirectory: environment.homeDirectory, packageOperations: packages,
353
408
  });
@@ -416,7 +471,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
416
471
  answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
417
472
  if (input === "y" || input === "n" || key.escape)
418
473
  return;
419
- const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
474
+ // The preview cursor counts wrapped rows, not source lines, or a long destination would cap the paging.
475
+ const viewport = previewViewport(lines, cursorRef.current, terminalRows || 24, terminalColumns || 80, confirmChrome());
476
+ const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
420
477
  if (next !== undefined)
421
478
  cursorTo(Math.max(0, next));
422
479
  return;
@@ -521,9 +578,21 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
521
578
  const liveDestination = nameOutcome.kind === "ok"
522
579
  ? exportDestination(exportDirectory ?? startDirectory, nameOutcome.fileName) : undefined;
523
580
  const nameNote = nameOutcome.kind === "ok" ? fileNameNotice(nameOutcome.fileName) : undefined;
581
+ const extensionNote = nameOutcome.kind === "ok" && nameOutcome.appendedExtension
582
+ ? `A name without an extension is saved as ${nameOutcome.fileName}.` : undefined;
583
+ const textRows = (value) => value === undefined ? 0 : wrapText(value, panelWidth).length;
584
+ const nameErrorLines = nameError === undefined ? [] : wrapText(nameError, panelWidth);
585
+ // Keys type into the name, so the stage cannot scroll; instead each long field keeps its end (and, given
586
+ // three rows or more, its first row) within a share of the terminal. The confirmation pages the full destination.
587
+ const nameFixedRows = 3 + textRows(nameNote) + textRows(extensionNote) + Math.min(nameErrorLines.length, NAME_ERROR_ROWS)
588
+ + (nameChecking === undefined ? 0 : 1) + textRows(NAME_HINT);
589
+ const fieldRows = useListHeight(listHeight, nameFixedRows, 3);
590
+ // Leftover rows go to the paths, which are the long fields, before the typed name.
591
+ const folderRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 2 ? 1 : 0);
592
+ const destinationRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 1 ? 1 : 0);
593
+ const nameRows = fieldRows - folderRows - destinationRows;
524
594
  return _jsxs(Box, { flexDirection: "column", children: [mode === "export-folder" ? _jsx(FilePicker, { purpose: "directory", listHeight: listHeight, startDirectory: exportDirectory ?? startDirectory, onCancel: () => setMode("export-list"), onChoose: selection => { if (selection.kind === "directory")
525
- chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Folder: " }), _jsx(Text, { color: theme.value, children: exportDirectory ?? startDirectory })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Name: " }), _jsxs(Text, { color: theme.value, children: [text, "_"] })] }), liveDestination === undefined ? null : _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: "Destination: " }), _jsx(Text, { color: theme.value, children: liveDestination })] }), nameNote === undefined ? null : _jsx(Text, { color: theme.warning, children: nameNote }), nameOutcome.kind === "ok" && nameOutcome.appendedExtension
526
- ? _jsxs(Text, { color: theme.muted, children: ["A name without an extension is saved as ", nameOutcome.fileName, "."] }) : null, nameError === undefined ? null : _jsx(Text, { color: theme.error, children: nameError }), _jsx(Text, { color: theme.legend, children: "Type to edit \u00B7 Ctrl+U clear \u00B7 Enter continue \u00B7 Esc back to folder" })] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, question: confirmRequest.current?.kind === "export"
527
- ? "Save/Export Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"
528
- : "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line" }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
595
+ chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" && working ? _jsx(Working, { label: working.label, since: working.startedAt }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsx(NameField, { label: "Folder: ", value: exportDirectory ?? startDirectory, rows: folderRows, width: panelWidth }), _jsx(NameField, { label: "Name: ", value: `${text}_`, rows: nameRows, width: panelWidth, tail: true }), liveDestination === undefined ? null : _jsx(NameField, { label: "Destination: ", value: liveDestination, rows: destinationRows, width: panelWidth }), nameNote === undefined ? null : _jsx(Text, { color: theme.warning, children: nameNote }), extensionNote === undefined ? null : _jsx(Text, { color: theme.muted, children: extensionNote }), nameError === undefined ? null : nameErrorLines.length <= NAME_ERROR_ROWS
596
+ ? _jsx(Text, { color: theme.error, children: nameError })
597
+ : elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(Text, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, chrome: confirmChrome(), question: confirmChrome().questions[0] }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
529
598
  }
@@ -1,3 +1,4 @@
1
+ import { busy } from "./working-model.js";
1
2
  export const initialMode = { kind: "browse" };
2
3
  export function modeReducer(mode, event) {
3
4
  switch (event.type) {
@@ -14,7 +15,7 @@ export function modeReducer(mode, event) {
14
15
  case "preview-cursor":
15
16
  return mode.kind === "confirm" && mode.action === "refresh" ? { ...mode, cursor: event.cursor } : mode;
16
17
  case "busy":
17
- return { kind: "busy" };
18
+ return busy(event.label, mode, () => event.startedAt);
18
19
  case "done":
19
20
  return initialMode;
20
21
  }
@@ -10,10 +10,11 @@ import { hasUnmanagedCopy, isInstalled, loadInstalledSkills } from "./catalog-in
10
10
  import { appName, appRecipePreviewLines, appStepLines } from "./app-actions.js";
11
11
  import { errorText } from "./batch.js";
12
12
  import { useMounted } from "./view-state.js";
13
- import { BorderPanel, DecisionPrompt, PreviewConfirm } from "./panel-parts.js";
13
+ import { BorderPanel, DecisionPrompt, PreviewConfirm, Working } from "./panel-parts.js";
14
14
  import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
15
15
  import { previewViewport } from "./preview-window.js";
16
16
  import { theme } from "./theme.js";
17
+ import { busy } from "./working-model.js";
17
18
  function recipeApprovalChrome(entry) {
18
19
  return {
19
20
  title: "Recipe approval · does not install",
@@ -43,6 +44,9 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
43
44
  const [error, setError] = useState();
44
45
  const [notice, setNotice] = useState();
45
46
  const [loadError, setLoadError] = useState();
47
+ // When the oldest running reload began; reloads can overlap, so a count decides when loading ends.
48
+ const [loadingSince, setLoadingSince] = useState(() => Date.now());
49
+ const reloading = useRef(0);
46
50
  const [mode, setModeState] = useState({ kind: "browse" });
47
51
  const modeRef = useRef(mode);
48
52
  const setMode = (next) => {
@@ -50,6 +54,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
50
54
  onCapturingChange?.(next.kind !== "browse");
51
55
  setModeState(next);
52
56
  };
57
+ /** Shows the busy panel; an operation already busy keeps its start time. */
58
+ const setBusy = (label) => setMode(busy(label, modeRef.current));
53
59
  const finish = async () => {
54
60
  await reload();
55
61
  if (mounted.current)
@@ -57,7 +63,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
57
63
  };
58
64
  const preview = async (entry, status, removeAfterApproval = false) => {
59
65
  setError(undefined);
60
- setMode({ kind: "busy" });
66
+ setBusy("Preparing App recipe preview…");
61
67
  try {
62
68
  const steps = await apps.preview(entry);
63
69
  if (mounted.current) {
@@ -95,7 +101,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
95
101
  },
96
102
  };
97
103
  const startAI = async () => {
98
- setMode({ kind: "busy" });
104
+ setBusy("Checking the authoring Skill…");
99
105
  try {
100
106
  if (!operations.discoverSkills)
101
107
  throw new Error("Discovery is not supported by the configured operations");
@@ -124,7 +130,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
124
130
  };
125
131
  const startRemoval = async (entry) => {
126
132
  setError(undefined);
127
- setMode({ kind: "busy" });
133
+ setBusy("Checking App recipe…");
128
134
  try {
129
135
  const approval = await apps.approvalStatus(entry);
130
136
  if (approval.status === "needs approval") {
@@ -143,7 +149,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
143
149
  }
144
150
  };
145
151
  const uninstall = async (current) => {
146
- setMode({ kind: "busy" });
152
+ setBusy(current.kind === "uninstall-manual" ? "Checking App uninstall…" : "Uninstalling App…");
147
153
  try {
148
154
  const result = current.kind === "uninstall-manual"
149
155
  ? await apps.completeManual(current.plan, true)
@@ -200,7 +206,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
200
206
  else if (input === "n" || key.escape)
201
207
  cancel();
202
208
  else if (input === "y" && viewport.reachedEnd) {
203
- setMode({ kind: "busy" });
209
+ setBusy("Approving App recipe…");
204
210
  void apps.approve(current.entry).then(async () => {
205
211
  await reload();
206
212
  if (!mounted.current)
@@ -220,7 +226,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
220
226
  }
221
227
  if (current.kind === "uninstall-question") {
222
228
  answerYesNo(input, key, () => {
223
- setMode({ kind: "busy" });
229
+ setBusy("Preparing App uninstall…");
224
230
  void apps.planLifecycle(current.removal.entry, "uninstall").then(plan => {
225
231
  if (mounted.current)
226
232
  setMode({ kind: !plan.argv && plan.manual ? "uninstall-manual" : "uninstall-confirm", removal: current.removal, plan, cursor: 0 });
@@ -245,7 +251,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
245
251
  }
246
252
  if (current.kind === "remove") {
247
253
  answerYesNo(input, key, () => {
248
- setMode({ kind: "busy" });
254
+ setBusy("Removing App recipe…");
249
255
  void removeAppRecipe(apps, current.removal, true).then(async () => {
250
256
  if (mounted.current)
251
257
  setNotice(`Removed App recipe: ${current.removal.file}`);
@@ -276,6 +282,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
276
282
  };
277
283
  /** Reloads parsing and approval receipts, never version/latest commands. */
278
284
  const reload = useCallback(async () => {
285
+ if (reloading.current++ === 0 && mounted.current)
286
+ setLoadingSince(Date.now());
279
287
  try {
280
288
  const entries = await apps.load();
281
289
  const rows = await Promise.all(entries.map(async (entry) => {
@@ -294,9 +302,13 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
294
302
  if (mounted.current)
295
303
  setLoadError(errorText(error));
296
304
  }
305
+ finally {
306
+ if (--reloading.current === 0 && mounted.current)
307
+ setLoadingSince(undefined);
308
+ }
297
309
  }, [apps, mounted]);
298
310
  useEffect(() => { void reload(); }, [reload]);
299
- const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Text, { children: "Working..." }) }) : null, mode.kind === "approve" ? (() => {
311
+ const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) }) : null, mode.kind === "approve" ? (() => {
300
312
  const chrome = recipeApprovalChrome(mode.entry);
301
313
  const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
302
314
  return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
@@ -306,5 +318,5 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
306
318
  const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
307
319
  return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
308
320
  })() : null, mode.kind === "remove" ? (_jsx(PreviewConfirm, { title: "Recipe removal", lines: [`Delete recipe: ${mode.removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."], question: `Remove recipe for ${appName(mode.removal.entry)}? y/n` })) : null] }));
309
- return { rows, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
321
+ return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
310
322
  }