@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
@@ -3,6 +3,7 @@ import { runInstall } from "./catalog-installs.js";
3
3
  import { errorText } from "./batch.js";
4
4
  import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
5
5
  import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
6
+ import { busy } from "./working-model.js";
6
7
  function cancel(context) {
7
8
  context.setMessage({ kind: "ok", text: "Adoption cancelled" });
8
9
  context.browse();
@@ -22,7 +23,7 @@ export async function startAdoption(context, group) {
22
23
  return;
23
24
  }
24
25
  context.setMessage(undefined);
25
- context.setMode({ kind: "busy", label: `Looking for a Source Skill named ${entry.name}...` });
26
+ context.setMode(busy(`Looking for a Source Skill named ${entry.name}…`));
26
27
  try {
27
28
  const candidates = await findAdoptionCandidates(context.operations, entry);
28
29
  if (!context.isMounted())
@@ -43,7 +44,7 @@ export async function startAdoption(context, group) {
43
44
  }
44
45
  }
45
46
  async function prepare(context, mode, version) {
46
- context.setMode({ kind: "busy", label: `Refreshing source and checking that ${mode.entry.name} matches...` });
47
+ context.setMode(busy(`Refreshing source and checking that ${mode.entry.name} matches…`));
47
48
  try {
48
49
  const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
49
50
  if (!context.isMounted())
@@ -55,7 +56,7 @@ async function prepare(context, mode, version) {
55
56
  }
56
57
  }
57
58
  async function confirm(context, mode, exposure) {
58
- context.setMode({ kind: "busy", label: `Adopting ${mode.entry.name}...` });
59
+ context.setMode(busy(`Adopting ${mode.entry.name}…`));
59
60
  let result;
60
61
  try {
61
62
  result = { kind: "ok", text: (await runInstall(mode.adoption.prepared, context.operations, exposure)).join(" ") };
@@ -3,8 +3,9 @@ import { Text, useWindowSize } from "ink";
3
3
  import { PROJECT_HOSTS } from "../project-manifest.js";
4
4
  import { errorText, runBatch } from "./batch.js";
5
5
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
- import { BorderPanel, HostChecklist, PreviewConfirm } from "./panel-parts.js";
6
+ import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
7
  import { previewViewport } from "./preview-window.js";
8
+ import { operationStart } from "./working-model.js";
8
9
  export const APP_KINDS = ["app-busy", "app-approve", "app-hosts", "app-confirm", "app-manual"];
9
10
  /** Narrows the view's mode without asserting a partial mode is an App mode. */
10
11
  export function isAppMode(mode) {
@@ -59,9 +60,14 @@ function appPreviewLines(mode) {
59
60
  ...appStepLines(mode.plan),
60
61
  ];
61
62
  }
62
- async function prepareNext(context, requests, results = [], failures = 0) {
63
+ /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
64
+ function appBusy(label, previous) {
65
+ return { kind: "app-busy", label, startedAt: operationStart(previous) };
66
+ }
67
+ async function prepareNext(context, requests, results = [], failures = 0, previous) {
63
68
  const { manage, apps } = context;
64
- manage.setMode({ kind: "app-busy", label: "Preparing App step..." });
69
+ const running = appBusy("Preparing App step…", previous);
70
+ manage.setMode(running);
65
71
  // Independent planning failures never stop the remaining marked Apps.
66
72
  const [request, ...remaining] = requests;
67
73
  if (!request) {
@@ -79,7 +85,7 @@ async function prepareNext(context, requests, results = [], failures = 0) {
79
85
  }
80
86
  catch (error) {
81
87
  if (manage.isMounted()) {
82
- await prepareNext(context, remaining, [...results, `${appName(request.entry)}: failed: ${errorText(error)}`], failures + 1);
88
+ await prepareNext(context, remaining, [...results, `${appName(request.entry)}: failed: ${errorText(error)}`], failures + 1, running);
83
89
  }
84
90
  }
85
91
  }
@@ -92,7 +98,7 @@ export async function startAppAction(context, entries, input) {
92
98
  const entry = entries[0];
93
99
  if (!entry)
94
100
  throw new Error("No App selected");
95
- manage.setMode({ kind: "app-busy", label: "Preparing recipe approval..." });
101
+ manage.setMode(appBusy("Preparing recipe approval…"));
96
102
  const [steps, approval] = await Promise.all([apps.preview(entry), apps.approvalStatus(entry)]);
97
103
  if (manage.isMounted()) {
98
104
  manage.setMode({ kind: "app-approve", entry, lines: appRecipePreviewLines(entry, approval.status, steps), cursor: 0 });
@@ -118,7 +124,8 @@ export async function startAppAction(context, entries, input) {
118
124
  }
119
125
  async function execute(context, mode) {
120
126
  const { manage, apps } = context;
121
- manage.setMode({ kind: "app-busy", label: "Running App step..." });
127
+ const running = appBusy("Running App step…");
128
+ manage.setMode(running);
122
129
  let manualRequired = false;
123
130
  const [outcome] = await runBatch([mode.plan], async (plan) => {
124
131
  const result = mode.kind === "app-manual"
@@ -136,7 +143,7 @@ async function execute(context, mode) {
136
143
  return `${appName(plan.entry)}: ${result.status}${result.installedVersion ? ` ${result.installedVersion}` : ""}`;
137
144
  }, (plan, error) => `${appName(plan.entry)}: failed: ${errorText(error)}`);
138
145
  if (manage.isMounted() && outcome && !manualRequired) {
139
- await prepareNext(context, mode.remaining, [...mode.results, outcome.line], mode.failures + (outcome.ok ? 0 : 1));
146
+ await prepareNext(context, mode.remaining, [...mode.results, outcome.line], mode.failures + (outcome.ok ? 0 : 1), running);
140
147
  }
141
148
  }
142
149
  /** Applies checklist and y/n keys, continuing independent batch steps after cancellation or failure. */
@@ -187,7 +194,7 @@ export function handleAppKey(context, mode, input, key) {
187
194
  void execute(context, mode);
188
195
  return;
189
196
  }
190
- manage.setMode({ kind: "app-busy", label: "Approving recipe..." });
197
+ manage.setMode(appBusy("Approving recipe…"));
191
198
  void apps.approve(mode.entry).then(() => manage.finish({ kind: "ok", text: `Approved ${appName(mode.entry)}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
192
199
  }
193
200
  }
@@ -195,7 +202,7 @@ export function handleAppKey(context, mode, input, key) {
195
202
  export function AppPanel({ mode }) {
196
203
  const { rows, columns } = useWindowSize();
197
204
  if (mode.kind === "app-busy")
198
- return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Text, { children: mode.label }) });
205
+ return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
199
206
  if (mode.kind === "app-hosts") {
200
207
  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 })] }));
201
208
  }
@@ -5,6 +5,7 @@ import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, r
5
5
  import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
6
6
  import { skillOf, sourceIdOf } from "./installations.js";
7
7
  import { prepareUnmanagedRemoval, runUnmanagedRemoval } from "./unmanaged-actions.js";
8
+ import { busy } from "./working-model.js";
8
9
  export const BULK_KINDS = ["bulk-hosts", "confirm-bulk-uninstall", "confirm-bulk-hosts", "confirm-bulk-exposure"];
9
10
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
10
11
  function skip(target, label, reason) {
@@ -161,7 +162,7 @@ function cancel(context, text) {
161
162
  }
162
163
  /** Runs a prepare step in the busy mode; a plan with items goes to its confirmation, otherwise the reasons are reported. */
163
164
  async function preparePlan(context, label, prepare, confirmation) {
164
- context.setMode({ kind: "busy", label });
165
+ context.setMode(busy(label));
165
166
  try {
166
167
  const plan = await prepare();
167
168
  if (!context.isMounted())
@@ -182,7 +183,7 @@ async function preparePlan(context, label, prepare, confirmation) {
182
183
  }
183
184
  /** `u` with marks: prepares every marked leaf, then asks once. */
184
185
  export function startBulkUninstall(context, targets) {
185
- return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}...`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan }));
186
+ return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}…`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan }));
186
187
  }
187
188
  /** `h` with marks: asks the hosts once, for the union of what the eligible items lack. */
188
189
  export function startBulkHostAddition(context, targets) {
@@ -196,10 +197,10 @@ export function startBulkHostAddition(context, targets) {
196
197
  context.setMode({ kind: "bulk-hosts", targets: ready, skipped, choices, cursor: 0, selected: [] });
197
198
  }
198
199
  function prepareHosts(context, mode) {
199
- 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 }));
200
+ 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 }));
200
201
  }
201
202
  async function apply(context, label, action) {
202
- context.setMode({ kind: "busy", label });
203
+ context.setMode(busy(label));
203
204
  let result;
204
205
  try {
205
206
  result = await action();
@@ -226,7 +227,7 @@ export function handleBulkKey(context, mode, input, key) {
226
227
  }
227
228
  case "confirm-bulk-uninstall":
228
229
  answerYesNo(input, key, () => {
229
- void apply(context, `Removing ${count(current.plan.items.length, "item")}...`, () => runBulkUninstall(current.plan, operations, env));
230
+ void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, () => runBulkUninstall(current.plan, operations, env));
230
231
  }, () => cancel(context, "Uninstall cancelled"));
231
232
  return true;
232
233
  case "confirm-bulk-hosts":
@@ -234,7 +235,7 @@ export function handleBulkKey(context, mode, input, key) {
234
235
  return true;
235
236
  case "confirm-bulk-exposure":
236
237
  answerYesNo(input, key, () => {
237
- void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}...`, () => runBulkHostAddition(current.plan, operations, env));
238
+ void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, () => runBulkHostAddition(current.plan, operations, env));
238
239
  }, () => cancel(context, "Add hosts cancelled"));
239
240
  return true;
240
241
  default:
@@ -3,12 +3,13 @@ import { prepareInstall, runInstall } from "./catalog-installs.js";
3
3
  import { skillKey } from "./catalog-tree.js";
4
4
  import { errorText, runBatch } from "./batch.js";
5
5
  import { answerYesNo, applyChecklistOutcome, hostChecklistKey, versionInputKey } from "./mode-keys.js";
6
+ import { busy } from "./working-model.js";
6
7
  function cancel(context) {
7
8
  context.setMessage({ kind: "ok", text: "Install cancelled" });
8
9
  context.setAction(BROWSE);
9
10
  }
10
11
  async function startInstall(context, skills, choice) {
11
- context.setAction({ kind: "busy", label: `Refreshing source and preparing install of ${describeSkills(skills)}...` });
12
+ context.setAction(busy(`Refreshing source and preparing install of ${describeSkills(skills)}…`));
12
13
  const items = [];
13
14
  for (const skill of skills) {
14
15
  try {
@@ -30,7 +31,7 @@ async function startInstall(context, skills, choice) {
30
31
  }
31
32
  /** Applies the prepared items one by one; a failure is reported for that item and the rest still run. */
32
33
  async function confirmInstall(context, items, exposure) {
33
- context.setAction({ kind: "busy", label: `Installing ${describeSkills(items.map((item) => item.skill))}...` });
34
+ context.setAction(busy(`Installing ${describeSkills(items.map((item) => item.skill))}…`));
34
35
  const outcomes = await runBatch(items, async ({ prepared, error }) => {
35
36
  if (prepared === undefined)
36
37
  throw new Error(error);
@@ -6,7 +6,7 @@ import { skillKey } from "./catalog-tree.js";
6
6
  import { hasUnmanagedCopy } from "./catalog-installs.js";
7
7
  import { describeRow, truncateDescription, UNMANAGED_MARKER } from "./catalog-tree.js";
8
8
  import { isPackageMode, PackagePanel } from "./package-actions.js";
9
- import { BorderPanel, DecisionPrompt, HostChecklist, PreviewLines } from "./panel-parts.js";
9
+ import { BorderPanel, DecisionPrompt, HostChecklist, PreviewLines, Working } from "./panel-parts.js";
10
10
  import { rowStyle } from "./theme.js";
11
11
  import { theme } from "./theme.js";
12
12
  /** Scope and counts, the filter line, and why the list may be empty. */
@@ -54,6 +54,6 @@ export function ActionPanel({ mode }) {
54
54
  return (_jsx(BorderPanel, { title: `Skill Host exposure · ${names.length} confirmation`, children: _jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation. ", _jsx(DecisionPrompt, { children: "Confirm?" })] }) }));
55
55
  }
56
56
  case "busy":
57
- return _jsx(BorderPanel, { title: "Skill install", children: _jsx(Text, { children: mode.label }) });
57
+ return _jsx(BorderPanel, { title: "Skill install", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
58
58
  }
59
59
  }
@@ -11,13 +11,13 @@ import { AppPanel, handleAppKey, startAppAction } from "./app-actions.js";
11
11
  import { ActionPanel, CatalogHeader, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
12
12
  import { packageOwnedSkillNotice } from "./catalog-ownership.js";
13
13
  import { packageOperationsOf } from "./environment.js";
14
- import { handlePackageKey, isPackageMode, startPackageAction } from "./package-actions.js";
15
- import { ListFooter } from "./panel-parts.js";
14
+ import { handlePackageKey, isPackageMode, PackagePanel, startPackageAction } from "./package-actions.js";
15
+ import { ListFooter, Working } from "./panel-parts.js";
16
16
  import { useCatalogApps, useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
17
17
  import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
18
18
  import { theme } from "./theme.js";
19
19
  import { useTreeNavigation } from "./tree-navigation.js";
20
- import { useMarks, useMounted } from "./view-state.js";
20
+ import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
21
21
  import { computeWindow, useListHeight } from "./window.js";
22
22
  import { useKeys } from "./keys.js";
23
23
  const NO_ENVIRONMENT = {};
@@ -89,6 +89,21 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
89
89
  ? Math.max(1, ...state.activeBundles.map((owner) => wrappedLineCount(packageOwnedSkillNotice(owner), Math.max(columns, 1))))
90
90
  : 1;
91
91
  const height = useListHeight(listHeight, 6 + helpLines);
92
+ // A reload that ends an action continues that action's clock.
93
+ const runningSince = appMode?.kind === "app-busy" ? appMode.startedAt : action.kind === "busy" ? action.startedAt : undefined;
94
+ const loading = state.status === "loading" || appCatalog.status === "loading" || (state.status === "ready" && !installedReady);
95
+ // A Package action reloads after its mode has ended, so its clock is kept until that reload is done.
96
+ const [resumedSince, setResumedSince] = useState();
97
+ const wasLoading = useRef(false);
98
+ useEffect(() => {
99
+ if (loading)
100
+ wasLoading.current = true;
101
+ else if (wasLoading.current) {
102
+ wasLoading.current = false;
103
+ setResumedSince(undefined);
104
+ }
105
+ }, [loading]);
106
+ const loadingSince = useLoadingSince(loading, runningSince ?? resumedSince);
92
107
  const flow = {
93
108
  operations,
94
109
  env,
@@ -130,12 +145,14 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
130
145
  await reloadInstalled();
131
146
  if (!mounted.current)
132
147
  return;
148
+ const running = actionRef.current;
149
+ setResumedSince(running.kind === "busy" ? running.startedAt : undefined);
133
150
  setReloadKey((value) => value + 1);
134
151
  setMessage(result);
135
152
  setAction(BROWSE);
136
153
  },
137
154
  };
138
- const packageContext = { packages, manage: packageHost };
155
+ const packageContext = { packages, manage: packageHost, terminalRows: terminalRows || 24, terminalColumns: columns || 80 };
139
156
  const browse = {
140
157
  navigation,
141
158
  height,
@@ -166,13 +183,16 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
166
183
  }
167
184
  });
168
185
  const scope = all || sourceId === undefined ? "all sources" : sourceId;
169
- if (state.status === "loading" || appCatalog.status === "loading" || (state.status === "ready" && !installedReady))
170
- return _jsx(Text, { children: "Loading catalog..." });
186
+ if (loading)
187
+ return _jsx(Working, { label: "Loading catalog\u2026", since: loadingSince });
171
188
  if (state.status === "error")
172
189
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
173
190
  const window = computeWindow(rows.length, index, height);
174
191
  const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
175
192
  if (appMode !== undefined)
176
193
  return _jsx(AppPanel, { mode: appMode });
194
+ // A paged declaration owns the view: rows of the tree above it would not be counted in its height.
195
+ if (isPackageMode(action))
196
+ return _jsx(PackagePanel, { mode: action });
177
197
  return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
178
198
  }
@@ -4,9 +4,9 @@ import { Text, useWindowSize } from "ink";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { pickerEntries, readDirectoryListing, PROFILE_EXTENSION } from "./file-picker-model.js";
6
6
  import { useKeys } from "./keys.js";
7
- import { moveCursor } from "./mode-keys.js";
8
- import { BorderPanel } from "./panel-parts.js";
9
- import { clipText, wrapText } from "./text-wrap.js";
7
+ import { moveCursor, movePreviewCursor } from "./mode-keys.js";
8
+ import { BorderPanel, Working } from "./panel-parts.js";
9
+ import { clipText, fitLines, wrapText } from "./text-wrap.js";
10
10
  import { theme } from "./theme.js";
11
11
  import { useMirrored, useMounted } from "./view-state.js";
12
12
  import { computeWindow, useListHeight } from "./window.js";
@@ -23,18 +23,22 @@ function settle(action) {
23
23
  * request generation, so a slow listing never overwrites a newer directory or an unmount.
24
24
  */
25
25
  export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose, listHeight, readDirectory = readDirectoryListing }) {
26
- const [state, setState] = useState({ status: "loading", directory: startDirectory });
26
+ const [state, setState] = useState(() => ({ status: "loading", directory: startDirectory, startedAt: Date.now() }));
27
27
  const [cursor, cursorRef, cursorTo] = useMirrored(0);
28
28
  const [showAllFiles, showAllFilesRef, setShowAllFiles] = useMirrored(false);
29
- const [showFullPath, setShowFullPath] = useState(false);
29
+ const [showFullPath, showFullPathRef, setShowFullPath] = useMirrored(false);
30
+ const [pathCursor, pathCursorRef, pathCursorTo] = useMirrored(0);
31
+ // The page size of the full path view, which the key handler needs before the render computes it.
32
+ const pathPageRef = useRef(1);
30
33
  const requestId = useRef(0);
34
+ const pathLineCount = useRef(1);
31
35
  const mounted = useMounted();
32
36
  const { columns } = useWindowSize();
33
37
  const panelWidth = Math.max((columns || 80) - 4, 1);
34
38
  const navigate = useCallback((directory) => {
35
39
  const id = ++requestId.current;
36
40
  cursorTo(0);
37
- setState({ status: "loading", directory });
41
+ setState({ status: "loading", directory, startedAt: Date.now() });
38
42
  void readDirectory(directory).then(listing => {
39
43
  if (!mounted.current || requestId.current !== id)
40
44
  return;
@@ -76,9 +80,14 @@ export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose
76
80
  settle(onCancel);
77
81
  return;
78
82
  }
79
- if (showFullPath) {
83
+ if (showFullPathRef.current) {
80
84
  if (input === "p")
81
85
  setShowFullPath(false);
86
+ else {
87
+ const next = movePreviewCursor(pathCursorRef.current, pathLineCount.current, input, key, pathPageRef.current);
88
+ if (next !== undefined)
89
+ pathCursorTo(next);
90
+ }
82
91
  return;
83
92
  }
84
93
  if (state.status !== "ready") {
@@ -98,6 +107,7 @@ export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose
98
107
  return;
99
108
  }
100
109
  if (input === "p") {
110
+ pathCursorTo(0);
101
111
  setShowFullPath(true);
102
112
  return;
103
113
  }
@@ -111,32 +121,54 @@ export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose
111
121
  });
112
122
  const directory = state.directory;
113
123
  const wrappedDirectory = wrapText(`Directory: ${directory}`, panelWidth);
114
- const directoryLines = showFullPath || wrappedDirectory.length <= 3
115
- ? wrappedDirectory
116
- : [wrappedDirectory[0], ...wrapText(`… ${wrappedDirectory.length - 2} lines · press p for full path`, panelWidth), wrappedDirectory.at(-1)];
117
- const ordinaryFooterLines = (purpose === "directory"
124
+ const ordinaryFooter = purpose === "directory"
118
125
  ? ["j/k move · Enter open/use · ←", "parent · p full path · Esc back"]
119
- : ["j/k move · Enter open · ← parent", `f ${showAllFiles ? "json only" : "all files"} · p full path · Esc back`])
120
- .flatMap(line => wrapText(line, panelWidth));
126
+ : ["j/k move · Enter open · ← parent", `f ${showAllFiles ? "json only" : "all files"} · p full path · Esc back`];
121
127
  const noticeLines = notice === undefined ? [] : wrapText(notice, panelWidth);
122
- const filterNoteLines = !showFullPath && purpose === "file" && state.status === "ready" && !showAllFiles
123
- ? wrapText(`Showing ${PROFILE_EXTENSION} only · f shows all files`, panelWidth)
124
- : [];
125
128
  const statusLineCount = !showFullPath && (state.status === "loading" || state.status === "error" || (state.status === "ready" && rows.length === 0)) ? 1 : 0;
126
- const visibleNoticeLines = showFullPath ? [] : noticeLines;
127
- const footerLines = showFullPath ? wrapText("p hide path · Esc back", panelWidth) : ordinaryFooterLines;
129
+ const listRowsNeeded = rows.length > 0 ? 1 : 0;
130
+ // The roomy layout collapses a long path to three rows and wraps the key hints; the compact one keeps a
131
+ // folder list on screen when that roomy chrome alone would fill a short terminal.
132
+ const roomyDirectoryLines = wrappedDirectory.length <= 3
133
+ ? wrappedDirectory
134
+ : [wrappedDirectory[0], ...wrapText(`… ${wrappedDirectory.length - 2} lines · press p for full path`, panelWidth), wrappedDirectory.at(-1)];
135
+ const roomyFooterLines = ordinaryFooter.flatMap(line => wrapText(line, panelWidth));
136
+ const filterNote = purpose === "file" && state.status === "ready" && !showAllFiles
137
+ ? `Showing ${PROFILE_EXTENSION} only · f shows all files`
138
+ : undefined;
139
+ const roomyFilterNoteLines = filterNote === undefined ? [] : wrapText(filterNote, panelWidth);
140
+ // The compact legend keeps the keys a user would not guess (the filter, the full path, going up).
141
+ const compactFooter = purpose === "directory" ? "← parent · p full path · Esc back"
142
+ : `f ${showAllFiles ? "json only" : "all files"} · p full path · Esc back`;
143
+ const roomyReserved = 3 + roomyDirectoryLines.length + statusLineCount + roomyFilterNoteLines.length + noticeLines.length
144
+ + roomyFooterLines.length + (rows.length > 0 && listHeight === undefined ? 1 : 0);
145
+ const roomyHeight = useListHeight(listHeight, roomyReserved, 0);
146
+ const compact = !showFullPath && listHeight === undefined && roomyHeight < listRowsNeeded;
147
+ const directoryLines = compact ? [clipText(`Directory: ${directory}`, panelWidth)] : roomyDirectoryLines;
148
+ const footerLines = showFullPath ? wrapText("j/k scroll · p hide path · Esc back", panelWidth)
149
+ : compact ? [clipText(compactFooter, panelWidth)] : roomyFooterLines;
150
+ const filterNoteLines = showFullPath ? [] : compact ? (filterNote === undefined ? [] : [clipText(filterNote, panelWidth)]) : roomyFilterNoteLines;
151
+ const visibleNoticeLines = showFullPath ? [] : compact ? fitLines(noticeLines, 2, panelWidth) : noticeLines;
128
152
  const reservesWindowIndicator = !showFullPath && rows.length > 0 && listHeight === undefined ? 1 : 0;
129
153
  const reserved = 3 + directoryLines.length + statusLineCount + filterNoteLines.length + visibleNoticeLines.length + footerLines.length + reservesWindowIndicator;
130
154
  const available = useListHeight(listHeight, reserved, 1);
131
155
  const height = rows.length > available && listHeight !== undefined ? Math.max(available - 1, 1) : available;
132
156
  const window = computeWindow(rows.length, cursor, height);
133
157
  const listedRows = showFullPath ? [] : rows.slice(window.start, window.end);
134
- return (_jsxs(BorderPanel, { title: purpose === "directory" ? "Choose export folder" : "Choose import file", children: [directoryLines.map((line, index) => _jsx(Text, { color: theme.value, children: line }, index)), !showFullPath && state.status === "loading" ? _jsx(Text, { color: theme.muted, children: "Loading\u2026" }) : null, !showFullPath && state.status === "error" ? _jsxs(Text, { color: theme.error, children: ["Error: ", clipText(state.message, panelWidth)] }) : null, !showFullPath && state.status === "ready" && rows.length === 0 ? _jsx(Text, { color: theme.muted, children: "Empty folder" }) : null, filterNoteLines.map((line, index) => _jsx(Text, { color: theme.muted, children: line }, `filter-${index}`)), listedRows.map((row, position) => {
158
+ // The full path view pages the wrapped path, so a path longer than the terminal is still readable.
159
+ const pathAvailable = useListHeight(listHeight, 3 + footerLines.length, 1);
160
+ const pathHeight = wrappedDirectory.length > pathAvailable ? Math.max(pathAvailable - 1, 1) : pathAvailable;
161
+ const pathWindow = computeWindow(wrappedDirectory.length, pathCursor, pathHeight);
162
+ pathLineCount.current = wrappedDirectory.length;
163
+ pathPageRef.current = pathHeight;
164
+ const shownDirectoryLines = showFullPath ? wrappedDirectory.slice(pathWindow.start, pathWindow.end) : directoryLines;
165
+ return (_jsxs(BorderPanel, { title: purpose === "directory" ? "Choose export folder" : "Choose import file", children: [shownDirectoryLines.map((line, index) => _jsx(Text, { color: theme.value, children: line }, index)), !showFullPath && state.status === "loading" ? _jsx(Working, { label: "Loading folder\u2026", since: state.startedAt }) : null, !showFullPath && state.status === "error" ? _jsxs(Text, { color: theme.error, children: ["Error: ", clipText(state.message, panelWidth)] }) : null, !showFullPath && state.status === "ready" && rows.length === 0 ? _jsx(Text, { color: theme.muted, children: "Empty folder" }) : null, filterNoteLines.map((line, index) => _jsx(Text, { color: theme.muted, children: line }, `filter-${index}`)), listedRows.map((row, position) => {
135
166
  const index = window.start + position;
136
167
  const marker = index === cursor ? "> " : " ";
137
168
  const label = row.kind === "use-folder" ? `Use this folder: ${directory}`
138
169
  : row.kind === "parent" ? ".. (parent folder)"
139
170
  : row.entry.kind === "directory" ? `${row.entry.name}/` : row.entry.name;
140
171
  return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: marker }), _jsx(Text, { color: theme.value, children: clipText(label, Math.max(panelWidth - 2, 1)) })] }, index);
141
- }), visibleNoticeLines.map((line, index) => _jsx(Text, { color: theme.warning, children: line }, `notice-${index}`)), footerLines.map((line, index) => _jsx(Text, { color: theme.legend, children: line }, `footer-${index}`)), !showFullPath && window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }));
172
+ }), visibleNoticeLines.map((line, index) => _jsx(Text, { color: theme.warning, children: line }, `notice-${index}`)), footerLines.map((line, index) => _jsx(Text, { color: theme.legend, children: line }, `footer-${index}`)), showFullPath ? (pathWindow.indicator ? _jsx(Text, { color: theme.muted, children: pathWindow.indicator }) : null)
173
+ : window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }));
142
174
  }
@@ -3,7 +3,7 @@ import { Box, Text, useWindowSize } from "ink";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { createAppOperations } from "../app-flow.js";
5
5
  import { AppPanel, handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
6
- import { handlePackageKey, isPackageMode } from "./package-actions.js";
6
+ import { handlePackageKey, isPackageMode, PackagePanel } from "./package-actions.js";
7
7
  import { packageOperationsOf } from "./environment.js";
8
8
  import { loadInstallations } from "./installations.js";
9
9
  import { BROWSE } from "./installations-mode.js";
@@ -14,11 +14,12 @@ import { handleManageKey } from "./manage-actions.js";
14
14
  import { ModePanel } from "./installations-panel.js";
15
15
  import { TreeRowLine } from "./installations-rows.js";
16
16
  import { buildTree, leavesOf, markersOf } from "./installations-tree.js";
17
- import { ListFooter } from "./panel-parts.js";
17
+ import { ListFooter, Working } from "./panel-parts.js";
18
18
  import { useTreeNavigation } from "./tree-navigation.js";
19
19
  import { defaultExpanded } from "./tree.js";
20
20
  import { theme } from "./theme.js";
21
- import { useMarks, useMounted } from "./view-state.js";
21
+ import { busy } from "./working-model.js";
22
+ import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
22
23
  import { computeWindow, useListHeight } from "./window.js";
23
24
  import { useKeys } from "./keys.js";
24
25
  const NO_ENVIRONMENT = {};
@@ -41,9 +42,16 @@ export function InstallationsView({ operations, environment, onCapturingChange,
41
42
  onCapturingChange?.(next.kind !== "browse");
42
43
  setModeState(next);
43
44
  };
45
+ // Reloads and App checks can overlap, so counts decide when each indicator ends.
46
+ const [refreshingSince, setRefreshingSince] = useState();
47
+ const reloads = useRef(0);
48
+ const [checkingSince, setCheckingSince] = useState();
49
+ const checks = useRef(0);
44
50
  const loadGeneration = useRef(0);
45
51
  const reload = useCallback(async () => {
46
52
  const generation = ++loadGeneration.current;
53
+ if (reloads.current++ === 0 && mounted.current)
54
+ setRefreshingSince(Date.now());
47
55
  try {
48
56
  const data = await loadInstallations(operations, env, apps, packages);
49
57
  if (mounted.current && generation === loadGeneration.current)
@@ -53,6 +61,10 @@ export function InstallationsView({ operations, environment, onCapturingChange,
53
61
  if (mounted.current && generation === loadGeneration.current)
54
62
  setState({ status: "error", message: errorText(error) });
55
63
  }
64
+ finally {
65
+ if (--reloads.current === 0 && mounted.current)
66
+ setRefreshingSince(undefined);
67
+ }
56
68
  }, [operations, env, apps, packages, mounted]);
57
69
  useEffect(() => {
58
70
  void reload();
@@ -63,15 +75,20 @@ export function InstallationsView({ operations, environment, onCapturingChange,
63
75
  const { rows, index } = navigation;
64
76
  const checked = useRef(new Set());
65
77
  const highlighted = rows[index]?.node.data;
78
+ // The entry is kept when the tree is rebuilt with a check's result, and replaced only by a reload,
79
+ // so the effect below does not re-run for the node objects `buildTree` makes anew.
80
+ const highlightedEntry = highlighted?.kind === "app" ? highlighted.row.entry : undefined;
66
81
  useEffect(() => {
67
- if (highlighted?.kind !== "app")
82
+ if (highlightedEntry === undefined)
68
83
  return;
69
- const { entry } = highlighted.row;
84
+ const entry = highlightedEntry;
70
85
  const identity = `${entry.file}:${entry.hash}`;
71
86
  if (checked.current.has(identity))
72
87
  return;
73
88
  checked.current.add(identity);
74
89
  const generation = loadGeneration.current;
90
+ if (checks.current++ === 0)
91
+ setCheckingSince(Date.now());
75
92
  void apps.approvalStatus(entry).then(approval => {
76
93
  if (approval.status !== "approved")
77
94
  return undefined;
@@ -79,6 +96,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
79
96
  }).then(inspection => {
80
97
  if (!inspection || !mounted.current || generation !== loadGeneration.current)
81
98
  return;
99
+ // An unknown result is asked again only when focus returns to the App, never by the rebuilt tree.
82
100
  if (inspection.status === "unknown")
83
101
  checked.current.delete(identity);
84
102
  setState(previous => previous.status !== "ready" ? previous : {
@@ -94,14 +112,25 @@ export function InstallationsView({ operations, environment, onCapturingChange,
94
112
  return;
95
113
  checked.current.delete(identity);
96
114
  setMessage({ kind: "error", text: errorText(error) });
115
+ }).finally(() => {
116
+ if (--checks.current === 0 && mounted.current)
117
+ setCheckingSince(undefined);
97
118
  });
98
- }, [highlighted, apps, mounted]);
119
+ }, [highlightedEntry, apps, mounted]);
99
120
  const installed = useMemo(() => ({
100
121
  global: (data?.global ?? []).map((row) => row.selection),
101
122
  project: (data?.project ?? []).map((row) => row.selection),
102
123
  sources: data?.sources ?? [],
103
124
  unmanagedNames: new Set(),
104
125
  }), [data]);
126
+ const loadingSince = useLoadingSince(state.status === "loading");
127
+ /** Reloads after an action: the busy panel, which keeps the action's clock, says what is now running. */
128
+ const refresh = async () => {
129
+ const current = modeRef.current;
130
+ if ("startedAt" in current && mounted.current)
131
+ setMode(busy("Refreshing installations…", current));
132
+ await reload();
133
+ };
105
134
  const manage = {
106
135
  operations,
107
136
  env,
@@ -113,7 +142,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
113
142
  unmark: (ids) => marks.setMarked(new Set([...marks.markedRef.current].filter((id) => !ids.includes(id)))),
114
143
  finish: async (result) => {
115
144
  checked.current.clear();
116
- await reload();
145
+ await refresh();
117
146
  if (!mounted.current)
118
147
  return;
119
148
  setMessage(result);
@@ -127,7 +156,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
127
156
  browse: manage.browse,
128
157
  setMessage,
129
158
  isMounted: manage.isMounted,
130
- reload,
159
+ reload: refresh,
131
160
  };
132
161
  const browse = {
133
162
  navigation,
@@ -145,18 +174,21 @@ export function InstallationsView({ operations, environment, onCapturingChange,
145
174
  if (current.kind === "browse")
146
175
  handleBrowseKey(browse, input, key);
147
176
  else if (isPackageMode(current))
148
- handlePackageKey({ packages, manage }, current, input, key);
177
+ handlePackageKey({ packages, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, current, input, key);
149
178
  else if (isAppMode(current))
150
179
  handleAppKey({ apps, manage, terminalRows: terminalRows || 24, terminalColumns: terminalColumns || 80 }, current, input, key);
151
180
  else if (!handleManageKey(manage, current, input, key))
152
181
  handleAdoptionKey(adoption, current, input, key);
153
182
  });
154
183
  if (state.status === "loading")
155
- return _jsx(Text, { children: "Loading installations..." });
184
+ return _jsx(Working, { label: "Loading installations\u2026", since: loadingSince });
156
185
  if (state.status === "error")
157
186
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
158
187
  if (isAppMode(mode))
159
188
  return _jsx(AppPanel, { mode: mode });
189
+ // A paged declaration owns the view: rows of the tree above it would not be counted in its height.
190
+ if (isPackageMode(mode))
191
+ return _jsx(PackagePanel, { mode: mode });
160
192
  const ready = state.data;
161
193
  const window = computeWindow(rows.length, index, height);
162
194
  const listed = rows.slice(window.start, window.end);
@@ -165,5 +197,6 @@ export function InstallationsView({ operations, environment, onCapturingChange,
165
197
  const markedApps = markedLeaves.filter(node => node.data.kind === "app").length;
166
198
  // The legend explains the markers, so it shows only while a rendered row carries one.
167
199
  const legendNeeded = listed.some((row) => row.node.data.kind === "installation" && markersOf(row.node.data.row) !== "");
168
- return (_jsxs(Box, { flexDirection: "column", children: [ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, _jsx(ModePanel, { mode: mode })] }));
200
+ return (_jsxs(Box, { flexDirection: "column", children: [refreshingSince !== undefined && mode.kind === "browse" ? _jsx(Working, { label: "Refreshing installations\u2026", since: refreshingSince }) : null, ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, checkingSince !== undefined && refreshingSince === undefined && mode.kind === "browse"
201
+ ? _jsx(Working, { label: "Checking App status\u2026", since: checkingSince }) : null, _jsx(ModePanel, { mode: mode })] }));
169
202
  }