@iseoane/agent-depot 0.6.4 → 0.6.6

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 (44) hide show
  1. package/CHANGELOG.md +94 -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/self-update.js +6 -1
  18. package/dist/src/source-state.js +2 -5
  19. package/dist/src/tui/adoption-actions.js +4 -3
  20. package/dist/src/tui/app-actions.js +16 -9
  21. package/dist/src/tui/bulk-actions.js +7 -6
  22. package/dist/src/tui/catalog-flow.js +3 -2
  23. package/dist/src/tui/catalog-panel.js +2 -2
  24. package/dist/src/tui/catalog-view.js +26 -6
  25. package/dist/src/tui/file-picker.js +52 -20
  26. package/dist/src/tui/installations-view.js +44 -11
  27. package/dist/src/tui/manage-actions.js +7 -6
  28. package/dist/src/tui/manage-panel.js +2 -2
  29. package/dist/src/tui/package-actions.js +63 -31
  30. package/dist/src/tui/panel-parts.js +20 -0
  31. package/dist/src/tui/profile-view.js +84 -15
  32. package/dist/src/tui/sources-mode.js +2 -1
  33. package/dist/src/tui/sources-recipes.js +22 -10
  34. package/dist/src/tui/sources-view.js +13 -12
  35. package/dist/src/tui/text-wrap.js +13 -0
  36. package/dist/src/tui/unmanaged-actions.js +3 -2
  37. package/dist/src/tui/updates-flow.js +10 -7
  38. package/dist/src/tui/updates-keys.js +3 -2
  39. package/dist/src/tui/updates-model.js +2 -2
  40. package/dist/src/tui/updates-panel.js +3 -2
  41. package/dist/src/tui/updates-view.js +6 -3
  42. package/dist/src/tui/view-state.js +12 -0
  43. package/dist/src/tui/working-model.js +33 -0
  44. package/package.json +2 -2
@@ -5,6 +5,7 @@ import { errorText } from "./batch.js";
5
5
  import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
6
6
  import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
7
7
  import { handleUnmanagedKey, UNMANAGED_KINDS } from "./unmanaged-actions.js";
8
+ import { busy } from "./working-model.js";
8
9
  const MANAGE_KINDS = new Set([
9
10
  "remove-scope",
10
11
  "hosts-add",
@@ -36,7 +37,7 @@ export function startUninstall(context, skill) {
36
37
  void startFullUninstall(context, skill);
37
38
  }
38
39
  async function startFullUninstall(context, skill) {
39
- context.setMode({ kind: "busy", label: `Preparing uninstall of ${skill.name}...` });
40
+ context.setMode(busy(`Preparing uninstall of ${skill.name}…`));
40
41
  try {
41
42
  const prepared = await prepareUninstall(context.operations, context.env, skill, context.installed);
42
43
  if (!context.isMounted())
@@ -66,7 +67,7 @@ export function startHostAddition(context, skill) {
66
67
  }
67
68
  async function startHostChange(context, mode) {
68
69
  const { operations, env, installed } = context;
69
- context.setMode({ kind: "busy", label: `Preparing host change of ${mode.skill.name}...` });
70
+ context.setMode(busy(`Preparing host change of ${mode.skill.name}…`));
70
71
  try {
71
72
  if (mode.kind === "hosts-add") {
72
73
  const prepared = await prepareHostAddition(operations, env, mode.skill, installed, mode.selected);
@@ -90,7 +91,7 @@ async function startHostChange(context, mode) {
90
91
  }
91
92
  /** Runs a confirmed step in the busy mode and finishes with its outcome. */
92
93
  async function execute(context, label, action) {
93
- context.setMode({ kind: "busy", label });
94
+ context.setMode(busy(label));
94
95
  let result;
95
96
  try {
96
97
  result = { kind: "ok", text: await action() };
@@ -126,17 +127,17 @@ function handleConfirmKey(context, mode, input, key) {
126
127
  return;
127
128
  case "confirm-host-exposure":
128
129
  answerYesNo(input, key, () => {
129
- void execute(context, `Changing hosts of ${mode.skill.name}...`, () => runHostAddition(mode.prepared, operations, env));
130
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostAddition(mode.prepared, operations, env));
130
131
  }, cancelWith("Install cancelled"));
131
132
  return;
132
133
  case "confirm-host-remove":
133
134
  answerYesNo(input, key, () => {
134
- void execute(context, `Changing hosts of ${mode.skill.name}...`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
135
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
135
136
  }, cancelWith("Uninstall cancelled"));
136
137
  return;
137
138
  case "confirm-uninstall":
138
139
  answerYesNo(input, key, () => {
139
- void execute(context, `Removing ${mode.skill.name}...`, async () => {
140
+ void execute(context, `Removing ${mode.skill.name}…`, async () => {
140
141
  await runUninstall(mode.prepared, operations, env);
141
142
  return `Removed ${mode.skill.name} (${mode.skill.path})`;
142
143
  });
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import { AppPanel } from "./app-actions.js";
4
4
  import { PackagePanel } from "./package-actions.js";
5
- import { ChecklistLines, DecisionPrompt, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
5
+ import { ChecklistLines, DecisionPrompt, HostChecklist, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
6
6
  import { theme } from "./theme.js";
7
7
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
8
8
  function SkippedLines({ skipped }) {
@@ -66,6 +66,6 @@ export function ManagePanel({ mode }) {
66
66
  case "confirm-bulk-exposure":
67
67
  return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.plan.items.map((item) => item.label).join(", "), " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
68
68
  case "busy":
69
- return _jsx(Text, { children: mode.label });
69
+ return _jsx(Working, { label: mode.label, since: mode.startedAt });
70
70
  }
71
71
  }
@@ -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
  }