@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
@@ -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
  }
@@ -6,7 +6,7 @@ import { rowStyle, theme } from "./theme.js";
6
6
  import { computeWindow, pageStep, useListHeight } from "./window.js";
7
7
  import { useKeys } from "./keys.js";
8
8
  import { useSourcesRecipes } from "./sources-recipes.js";
9
- import { BorderPanel, DecisionPrompt, FieldLine, PreviewLines } from "./panel-parts.js";
9
+ import { BorderPanel, DecisionPrompt, FieldLine, PreviewLines, Working } from "./panel-parts.js";
10
10
  import { previewViewport } from "./preview-window.js";
11
11
  import { movePreviewCursor } from "./mode-keys.js";
12
12
  /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
@@ -53,7 +53,7 @@ function refreshViewport(mode, rows, columns) {
53
53
  export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange }) {
54
54
  const { columns, rows: terminalRows } = useWindowSize();
55
55
  const height = useListHeight(listHeight, 9);
56
- const [state, setState] = useState({ status: "loading" });
56
+ const [state, setState] = useState(() => ({ status: "loading", startedAt: Date.now() }));
57
57
  // The highlight follows the source id; the index is only the fallback once that source is gone.
58
58
  // Selection and marks are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
59
59
  const [selection, setSelectionState] = useState({ index: 0 });
@@ -147,8 +147,8 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
147
147
  setSelection({ id: listed[bounded]?.id, index: bounded });
148
148
  };
149
149
  /** Runs a mutating action in the busy mode, reports its outcome and reloads the list. */
150
- const run = async (action) => {
151
- dispatch({ type: "busy" });
150
+ const run = async (label, action) => {
151
+ dispatch({ type: "busy", label, startedAt: Date.now() });
152
152
  setMessage(undefined);
153
153
  let outcome;
154
154
  try {
@@ -180,7 +180,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
180
180
  stop("Removing sources is not supported by the configured operations");
181
181
  return;
182
182
  }
183
- dispatch({ type: "busy" });
183
+ dispatch({ type: "busy", label: "Checking dependent Skills…", startedAt: Date.now() });
184
184
  setMessage(undefined);
185
185
  try {
186
186
  const installations = await operations.listUserGlobalInstallations();
@@ -226,7 +226,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
226
226
  const url = typed.current.trim();
227
227
  if (url === "")
228
228
  return;
229
- void run(async () => `Added Git Source: ${(await operations.addGitSource(url)).id}`);
229
+ void run("Adding Git Source…", async () => `Added Git Source: ${(await operations.addGitSource(url)).id}`);
230
230
  }
231
231
  else if (key.backspace || key.delete) {
232
232
  typed.current = typed.current.slice(0, -1);
@@ -252,7 +252,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
252
252
  if (input === "y") {
253
253
  if (mode.action === "refresh") {
254
254
  const { sources: targets } = mode;
255
- void run(async () => {
255
+ void run(targets.length === 1 ? "Refreshing Git Source…" : "Refreshing Git Sources…", async () => {
256
256
  const result = await refreshEach(operations, targets);
257
257
  setMarked(new Set());
258
258
  return result;
@@ -260,7 +260,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
260
260
  }
261
261
  else {
262
262
  const { source } = mode;
263
- void run(async () => {
263
+ void run("Removing Git Source…", async () => {
264
264
  await operations.removeGitSource?.(source.id);
265
265
  return `Removed Git Source: ${source.id}`;
266
266
  });
@@ -324,7 +324,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
324
324
  // Sources removed since they were marked no longer count.
325
325
  const selectedCount = sources.filter((source) => marked.has(source.id)).length;
326
326
  if (state.status === "loading")
327
- return _jsx(BorderPanel, { title: "Sources", children: _jsx(FieldLine, { label: "Status", value: "Loading sources..." }) });
327
+ return _jsx(BorderPanel, { title: "Sources", children: _jsx(Working, { label: "Loading sources\u2026", since: state.startedAt }) });
328
328
  if (state.status === "error")
329
329
  return _jsx(BorderPanel, { title: "Source error", children: _jsx(Text, { color: theme.error, children: state.message }) });
330
330
  const window = computeWindow(rows.length, selected, height);
@@ -332,13 +332,14 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
332
332
  return _jsx(Prompt, { mode: mode, terminalRows: terminalRows || 24, terminalColumns: columns || 80 });
333
333
  if (recipes.capturing)
334
334
  return _jsx(Box, { flexDirection: "column", children: recipes.panel });
335
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: [" ", "ID KIND LOCATION / STATUS"] }), builtin ? _jsxs(Text, { children: [" ", _jsx(Text, { color: theme.value, children: builtin.id }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: "skills" }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.warning, children: "included" })] }) : null, rows.length === 0 ? _jsx(Text, { children: "No sources or App recipes \u00B7 n to add" }) : null, rows.slice(window.start, window.end).map((row, offset) => {
335
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: [" ", "ID KIND LOCATION / STATUS"] }), builtin ? _jsxs(Text, { children: [" ", _jsx(Text, { color: theme.value, children: builtin.id }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: "skills" }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.warning, children: "included" })] }) : null, rows.length === 0 && recipes.loadingSince === undefined ? _jsx(Text, { children: "No sources or App recipes \u00B7 n to add" }) : null, rows.slice(window.start, window.end).map((row, offset) => {
336
336
  const index = window.start + offset;
337
337
  const id = row.kind === "skills" ? row.source.id : row.recipe.entry.recipe?.name ?? row.recipe.entry.file.split(/[\\/]/u).pop();
338
338
  return (_jsxs(Text, { ...rowStyle(index === selected), children: [index === selected ? "> " : " ", row.kind === "skills" ? (marked.has(row.id) ? "[x] " : "[ ] ") : " ", _jsx(Text, { color: theme.value, children: id }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: row.kind }), _jsx(Text, { color: theme.legend, children: " " }), row.kind === "skills"
339
339
  ? _jsx(Text, { color: theme.value, children: row.source.url })
340
340
  : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: row.recipe.entry.file }), _jsx(Text, { color: theme.legend, children: " \u00B7 " }), _jsx(Text, { color: row.recipe.status === "approved" ? theme.success : theme.warning, children: row.recipe.status })] })] }, row.id));
341
- }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, recipes.panel, _jsx(Prompt, { mode: mode }), message ? (_jsx(BorderPanel, { title: message.kind === "error" ? "Source action · failed" : "Source action · complete", children: _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) })) : null] }));
341
+ }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, recipes.loadingSince !== undefined && mode.kind !== "busy" && !recipes.capturing
342
+ ? _jsx(Working, { label: "Loading App recipes\u2026", since: recipes.loadingSince }) : null, recipes.panel, _jsx(Prompt, { mode: mode }), message ? (_jsx(BorderPanel, { title: message.kind === "error" ? "Source action · failed" : "Source action · complete", children: _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) })) : null] }));
342
343
  }
343
344
  function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
344
345
  if (mode.kind === "add-kind")
@@ -346,7 +347,7 @@ function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
346
347
  if (mode.kind === "input")
347
348
  return _jsxs(Text, { children: ["URL: ", mode.value] });
348
349
  if (mode.kind === "busy")
349
- return _jsx(BorderPanel, { title: "Sources", children: _jsx(FieldLine, { label: "Status", value: "Working..." }) });
350
+ return _jsx(BorderPanel, { title: "Sources", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
350
351
  if (mode.kind === "confirm") {
351
352
  return mode.action === "refresh" ? (() => {
352
353
  const viewport = refreshViewport(mode, terminalRows, terminalColumns);
@@ -36,6 +36,19 @@ export function fitLines(lines, maxRows, width) {
36
36
  visible[maxRows - 1] = `${last}…`;
37
37
  return visible;
38
38
  }
39
+ /**
40
+ * `lines` capped to `maxRows` by dropping the middle: the first and last rows stay (the start and end of
41
+ * a path) with a row counting what was left out. With two rows or fewer it falls back to `fitLines`.
42
+ */
43
+ export function elideMiddle(lines, maxRows, width) {
44
+ if (lines.length <= maxRows)
45
+ return lines;
46
+ if (maxRows <= 2)
47
+ return fitLines(lines, maxRows, width);
48
+ const tail = lines.slice(lines.length - (maxRows - 2));
49
+ const hidden = lines.length - 1 - tail.length;
50
+ return [lines[0], `… ${hidden} more ${hidden === 1 ? "row" : "rows"}`, ...tail];
51
+ }
39
52
  /** One line shortened to `width` columns plus an ellipsis, for single-row labels. */
40
53
  export function clipText(value, width) {
41
54
  if (stringWidth(value) <= width)
@@ -4,6 +4,7 @@ import { describeUnmanagedRemoval, describeUnmanagedSymlinkRemoval, removeInspec
4
4
  import { inspectUserGlobalSkillRemoval, inspectUserGlobalSymlinkRemoval, } from "../user-global-skill-inventory.js";
5
5
  import { answerYesNo, moveCursor } from "./mode-keys.js";
6
6
  import { homeOf } from "./installations.js";
7
+ import { busy } from "./working-model.js";
7
8
  export const UNMANAGED_KINDS = ["unmanaged-scope", "unmanaged-pick", "confirm-unmanaged"];
8
9
  const errorText = (error) => (error instanceof Error ? error.message : String(error));
9
10
  /** Links first, so a Claude link never outlives the canonical directory it points to. */
@@ -99,7 +100,7 @@ export async function runUnmanagedRemoval(prepared, operations, environment) {
99
100
  return { kind: failed ? "error" : "ok", text: lines.join("\n") };
100
101
  }
101
102
  async function startPreview(context, group, locations) {
102
- context.setMode({ kind: "busy", label: `Preparing removal of ${group.name}...` });
103
+ context.setMode(busy(`Preparing removal of ${group.name}…`));
103
104
  try {
104
105
  const prepared = await prepareUnmanagedRemoval(context.operations, context.env, group, locations);
105
106
  if (!context.isMounted())
@@ -125,7 +126,7 @@ function cancel(context) {
125
126
  context.browse();
126
127
  }
127
128
  async function execute(context, group, prepared) {
128
- context.setMode({ kind: "busy", label: `Removing ${group.name}...` });
129
+ context.setMode(busy(`Removing ${group.name}…`));
129
130
  await context.finish(await runUnmanagedRemoval(prepared, context.operations, context.env));
130
131
  }
131
132
  /** Handles a key in an unmanaged removal mode; returns false for any other mode. */
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { errorText } from "./batch.js";
3
3
  import { BROWSE } from "./updates-mode.js";
4
4
  import { CHECKING, emptyData, PHASE_LABEL, REFRESHING } from "./updates-model.js";
5
+ import { busy } from "./working-model.js";
5
6
  import { loadUpdateRows, prepareUpdates, runUpdates } from "./updates.js";
6
7
  export function useUpdatesFlow(inputs) {
7
- const { operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, setMessage } = inputs;
8
- const [state, setState] = useState({ status: "loading", label: REFRESHING });
8
+ const { operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, modeRef, setMessage } = inputs;
9
+ const [state, setState] = useState(() => ({ status: "loading", label: REFRESHING, startedAt: clock() }));
9
10
  const loadToken = useRef(0);
10
11
  const pendingAnswer = useRef(undefined);
11
12
  useEffect(() => () => {
@@ -13,14 +14,16 @@ export function useUpdatesFlow(inputs) {
13
14
  }, []);
14
15
  const load = useCallback(async (refresh) => {
15
16
  const token = ++loadToken.current;
17
+ // The check after an applied update continues that update's clock; a check from browsing starts one.
18
+ const running = busy(refresh ? REFRESHING : CHECKING, modeRef.current, clock);
16
19
  const phase = (next) => {
17
20
  if (!mounted.current || token !== loadToken.current)
18
21
  return;
19
- setState({ status: "loading", label: PHASE_LABEL[next] });
22
+ setState({ status: "loading", label: PHASE_LABEL[next], startedAt: running.startedAt });
20
23
  };
21
24
  setChecked([]);
22
25
  setCursor(0);
23
- setMode({ kind: "busy", label: refresh ? REFRESHING : CHECKING });
26
+ setMode(running);
24
27
  let next;
25
28
  try {
26
29
  next = { status: "ready", data: await loadUpdateRows(operations, env, { refresh, onPhase: phase, now: clock }) };
@@ -34,7 +37,7 @@ export function useUpdatesFlow(inputs) {
34
37
  return;
35
38
  setState(next);
36
39
  setMode(BROWSE);
37
- }, [operations, env, mounted, setMode, setChecked, setCursor, clock]);
40
+ }, [operations, env, mounted, setMode, modeRef, setChecked, setCursor, clock]);
38
41
  const startPreview = async () => {
39
42
  const { data: current, updatable: listed } = latest();
40
43
  if (!current)
@@ -45,7 +48,7 @@ export function useUpdatesFlow(inputs) {
45
48
  return;
46
49
  }
47
50
  setMessage(undefined);
48
- setMode({ kind: "busy", label: "Preparing preview..." });
51
+ setMode(busy("Preparing preview…", undefined, clock));
49
52
  try {
50
53
  const prepared = await prepareUpdates(current, selected);
51
54
  if (mounted.current)
@@ -59,7 +62,7 @@ export function useUpdatesFlow(inputs) {
59
62
  }
60
63
  };
61
64
  const apply = async (prepared, confirmedPaths) => {
62
- setMode({ kind: "busy", label: "Applying updates..." });
65
+ setMode(busy("Applying updates…", undefined, clock));
63
66
  let result;
64
67
  try {
65
68
  const outcome = await runUpdates(prepared, confirmedPaths, (plan, reason) => new Promise(resolve => {
@@ -1,4 +1,5 @@
1
1
  import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
2
+ import { busy } from "./working-model.js";
2
3
  import { pageStep } from "./window.js";
3
4
  const SCOPE_KEYS = new Map([["t", "all"], ["p", "project"], ["g", "user-global"]]);
4
5
  /** Moves the cursor with the arrows, j/k and paging; returns whether the key was one of them. */
@@ -100,10 +101,10 @@ export function handleUpdatesKey(context, mode, input, key) {
100
101
  answerYesNo(input, key, () => {
101
102
  if (!context.previewAtEnd(mode))
102
103
  return;
103
- context.setMode({ kind: "busy", label: "Checking App version..." });
104
+ context.setMode(busy("Checking App version…", undefined, context.now));
104
105
  mode.answer(true);
105
106
  }, () => {
106
- context.setMode({ kind: "busy", label: "Continuing updates..." });
107
+ context.setMode(busy("Continuing updates…", undefined, context.now));
107
108
  mode.answer(false);
108
109
  });
109
110
  return;
@@ -1,5 +1,5 @@
1
- export const REFRESHING = "Refreshing sources...";
2
- export const CHECKING = "Checking for updates...";
1
+ export const REFRESHING = "Refreshing sources…";
2
+ export const CHECKING = "Checking for updates…";
3
3
  export const PHASE_LABEL = { refreshing: REFRESHING, checking: CHECKING };
4
4
  /** Names the reasons worth knowing before expanding the collapsed "cannot be checked" row. */
5
5
  export function describeUnknownReasons(rows) {
@@ -3,7 +3,7 @@ import { Box, Text, useWindowSize } from "ink";
3
3
  import { rowStyle, theme } from "./theme.js";
4
4
  import { BorderPanel, FieldLine, PreviewConfirm } from "./panel-parts.js";
5
5
  import { previewViewport } from "./preview-window.js";
6
- import { CHECKING, describeRefresh, describeUnknownReasons, REFRESHING } from "./updates-model.js";
6
+ import { describeRefresh, describeUnknownReasons } from "./updates-model.js";
7
7
  import { formatAgo, previewLineText } from "./updates.js";
8
8
  import { updateModePreviewChrome, updateModePreviewLines, updateModeQuestion } from "./updates-preview.js";
9
9
  function updateResultField(line, defaultOk) {
@@ -61,7 +61,8 @@ export function ModePanel({ mode }) {
61
61
  case "browse":
62
62
  return null;
63
63
  case "busy":
64
- return mode.label === REFRESHING || mode.label === CHECKING ? null : (_jsx(BorderPanel, { title: "Updates", children: _jsx(FieldLine, { label: "Status", value: mode.label }) }));
64
+ // The view renders the one indicator of a busy mode, so a check never shows two.
65
+ return null;
65
66
  case "preview":
66
67
  case "confirm-paths":
67
68
  return _jsx(CapturedUpdatePreview, { mode: mode });
@@ -13,7 +13,7 @@ import { CheckStatus, EntryLine, ModePanel, UpdatesFooter } from "./updates-pane
13
13
  import { previewLineText } from "./updates.js";
14
14
  import { updateModePreviewChrome, updateModePreviewLines } from "./updates-preview.js";
15
15
  import { useKeys } from "./keys.js";
16
- import { BorderPanel, FieldLine } from "./panel-parts.js";
16
+ import { BorderPanel, Working } from "./panel-parts.js";
17
17
  const NO_ENVIRONMENT = {};
18
18
  /** How often the age of the check is redrawn. */
19
19
  const CLOCK_TICK_MS = 10_000;
@@ -69,7 +69,7 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
69
69
  setMode(BROWSE);
70
70
  };
71
71
  const { state, load, startPreview, apply } = useUpdatesFlow({
72
- operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, setMessage,
72
+ operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, modeRef, setMessage,
73
73
  });
74
74
  // Entering the view refreshes the Git sources first, like `r`.
75
75
  useEffect(() => {
@@ -85,6 +85,7 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
85
85
  };
86
86
  const keyContext = {
87
87
  height,
88
+ now: clock,
88
89
  previewMetrics: current => {
89
90
  const geometry = previewGeometry(current);
90
91
  return { length: geometry.total, pageSize: geometry.height };
@@ -112,5 +113,7 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
112
113
  const view = { shown, updatable, unknown, upToDate, entries };
113
114
  const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
114
115
  const showingBrowse = mode.kind === "browse";
115
- return (_jsxs(Box, { flexDirection: "column", children: [showingBrowse ? _jsxs(Text, { color: theme.title, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global \u00B7 u update"] }) : null, state.status === "loading" ? _jsx(BorderPanel, { title: "Update check", children: _jsx(FieldLine, { label: "Status", value: state.label }) }) : null, showingBrowse && data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, showingBrowse && data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, showingBrowse ? entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: entry.kind === "update" && checked.includes(entry.row.key), view: view, showUnknown: showUnknown }, entry.kind === "summary" ? "summary" : entry.row.key))) : null, showingBrowse ? _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }) : null, _jsx(ModePanel, { mode: mode })] }));
116
+ // One indicator for the whole operation: a check's own phase label, else the busy mode's.
117
+ const working = state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
118
+ return (_jsxs(Box, { flexDirection: "column", children: [showingBrowse ? _jsxs(Text, { color: theme.title, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global \u00B7 u update"] }) : null, working ? _jsx(BorderPanel, { title: state.status === "loading" ? "Update check" : "Updates", children: _jsx(Working, { label: working.label, since: working.startedAt, now: clock }) }) : null, showingBrowse && data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, showingBrowse && data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, showingBrowse ? entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: entry.kind === "update" && checked.includes(entry.row.key), view: view, showUnknown: showUnknown }, entry.kind === "summary" ? "summary" : entry.row.key))) : null, showingBrowse ? _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }) : null, _jsx(ModePanel, { mode: mode })] }));
116
119
  }
@@ -10,6 +10,18 @@ export function useMounted() {
10
10
  }, []);
11
11
  return mounted;
12
12
  }
13
+ /**
14
+ * When the current stretch of loading began, so a loading line shows how long it has waited even
15
+ * though the view re-renders. `seed` continues an operation that was already running (its busy start).
16
+ */
17
+ export function useLoadingSince(loading, seed) {
18
+ const [since, setSince] = useState(() => (loading ? seed ?? Date.now() : undefined));
19
+ useEffect(() => {
20
+ setSince((previous) => (loading ? previous ?? seed ?? Date.now() : undefined));
21
+ }, [loading, seed]);
22
+ // Until the effect records it, the first loading render derives the same start.
23
+ return since ?? seed ?? Date.now();
24
+ }
13
25
  /** Marks by node id. The ref is updated first so keys in one burst see every mark. */
14
26
  export function useMarks() {
15
27
  const [marked, setMarkedState] = useState(new Set());
@@ -0,0 +1,33 @@
1
+ /** Braille spinner frames; one is shown per tick while an operation runs. */
2
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ /** Fixed glyph shown instead of the spinner when animation is off. */
4
+ export const STATIC_GLYPH = "·";
5
+ /** How often the spinner advances and the elapsed time is redrawn. */
6
+ export const SPINNER_TICK_MS = 80;
7
+ /** Elapsed time stays hidden until the operation has run this long. */
8
+ const ELAPSED_THRESHOLD_MS = 1000;
9
+ /** `12s` or `1m 05s`; empty under one second. */
10
+ export function formatElapsed(ms) {
11
+ if (!(ms >= ELAPSED_THRESHOLD_MS))
12
+ return "";
13
+ const seconds = Math.floor(ms / 1000);
14
+ if (seconds < 60)
15
+ return `${seconds}s`;
16
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
17
+ }
18
+ /** Animation is on unless `AGENT_DEPOT_NO_ANIMATION=1`, which tests set so frames stay static. */
19
+ export function animationEnabled(env = process.env) {
20
+ return env.AGENT_DEPOT_NO_ANIMATION !== "1";
21
+ }
22
+ /** Whether a rendered frame starts with a Working line, so tests can wait for the real content. */
23
+ export function isWorkingFrame(text) {
24
+ return /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏·] \S[^\n]*…/u.test(text.trimStart());
25
+ }
26
+ /** When an operation began: the start time of the busy mode it continues, otherwise now. Only busy modes carry one. */
27
+ export function operationStart(previous, now = Date.now) {
28
+ return previous?.startedAt ?? now();
29
+ }
30
+ /** The busy mode for `label`; it keeps the start time of an operation already busy, so the clock survives label changes. */
31
+ export function busy(label, previous, now = Date.now) {
32
+ return { kind: "busy", label, startedAt: operationStart(previous, now) };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",
@@ -74,7 +74,7 @@
74
74
  "typecheck": "tsc --noEmit",
75
75
  "lint": "eslint \"src/**/*.{ts,tsx}\" \"resources/pi/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\"",
76
76
  "test": "pnpm build && node --test --import ./dist/tests/isolated-env.js \"dist/tests/*.test.js\"",
77
- "test:single": "pnpm build && node -e \"const { spawnSync } = require('node:child_process'); const input = process.argv[1] || 'tests/version.test.ts'; const file = input.replace(/^tests[\\\\/]/, 'dist/tests/').replace(/\\\\.ts$/i, '.js'); process.exit(spawnSync(process.execPath, ['--import', './dist/tests/isolated-env.js', '--test', file], { stdio: 'inherit' }).status ?? 1);\"",
77
+ "test:single": "pnpm build && node -e \"const { spawnSync } = require('node:child_process'); const input = process.argv[1] || 'tests/version.test.ts'; const file = input.replace(/^tests[\\\\/]/, 'dist/tests/').replace(/\\\\.tsx?$/i, '.js'); process.exit(spawnSync(process.execPath, ['--import', './dist/tests/isolated-env.js', '--test', file], { stdio: 'inherit' }).status ?? 1);\"",
78
78
  "test:coverage": "node -e \"require('node:fs').rmSync('coverage',{recursive:true,force:true})\" && tsc -p tsconfig.coverage.json && node -e \"const { spawnSync } = require('node:child_process'); process.exit(spawnSync(process.execPath, ['--enable-source-maps','--import','./dist/tests/isolated-env.js','--test','--test-reporter=dot','dist/tests/*.test.js'], { stdio: 'inherit', env: { ...process.env, NODE_V8_COVERAGE: 'coverage/v8' } }).status ?? 1);\"",
79
79
  "audit:dead-code": "npx --yes fallow@3.30.0 dead-code --fail-on-issues",
80
80
  "audit:dupes": "npx --yes fallow@3.30.0 dupes",