@iseoane/agent-depot 0.6.13 → 0.6.14

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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -1
  3. package/dist/src/flow-progress.js +6 -0
  4. package/dist/src/git-source.js +18 -6
  5. package/dist/src/preview-model.js +34 -0
  6. package/dist/src/process-runner.js +1 -0
  7. package/dist/src/profile-cli.js +12 -4
  8. package/dist/src/profile-export.js +23 -3
  9. package/dist/src/skill-update.js +1 -0
  10. package/dist/src/source-refresh-session.js +139 -0
  11. package/dist/src/sources.js +4 -4
  12. package/dist/src/tui/adoption-actions.js +21 -2
  13. package/dist/src/tui/app-actions.js +54 -45
  14. package/dist/src/tui/app.js +22 -4
  15. package/dist/src/tui/bulk-actions.js +42 -9
  16. package/dist/src/tui/catalog-actions.js +12 -0
  17. package/dist/src/tui/catalog-flow.js +31 -11
  18. package/dist/src/tui/catalog-panel.js +15 -13
  19. package/dist/src/tui/catalog-tree.js +7 -2
  20. package/dist/src/tui/catalog-view.js +12 -6
  21. package/dist/src/tui/file-picker.js +2 -1
  22. package/dist/src/tui/installations-panel.js +12 -4
  23. package/dist/src/tui/installations-rows.js +1 -1
  24. package/dist/src/tui/installations-view.js +16 -7
  25. package/dist/src/tui/keys.js +27 -4
  26. package/dist/src/tui/manage-actions.js +55 -27
  27. package/dist/src/tui/manage-panel.js +15 -29
  28. package/dist/src/tui/mode-keys.js +11 -1
  29. package/dist/src/tui/package-actions.js +25 -18
  30. package/dist/src/tui/package-rows.js +2 -1
  31. package/dist/src/tui/panel-parts.js +45 -36
  32. package/dist/src/tui/preview-render.js +93 -0
  33. package/dist/src/tui/preview-window.js +141 -10
  34. package/dist/src/tui/profile-view.js +49 -40
  35. package/dist/src/tui/render.js +11 -1
  36. package/dist/src/tui/safe-text.js +25 -0
  37. package/dist/src/tui/sources-mode.js +2 -2
  38. package/dist/src/tui/sources-recipes.js +53 -32
  39. package/dist/src/tui/sources-view.js +86 -39
  40. package/dist/src/tui/unmanaged-actions.js +17 -5
  41. package/dist/src/tui/updates-flow.js +11 -5
  42. package/dist/src/tui/updates-keys.js +2 -2
  43. package/dist/src/tui/updates-model.js +5 -2
  44. package/dist/src/tui/updates-panel.js +10 -9
  45. package/dist/src/tui/updates-preview.js +27 -44
  46. package/dist/src/tui/updates-view.js +8 -10
  47. package/dist/src/tui/updates.js +117 -33
  48. package/dist/src/update-flow.js +31 -15
  49. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createAppOperations } from "../app-flow.js";
5
6
  import { planAppRecipeRemoval, removeAppRecipe } from "../app-recipe-removal.js";
@@ -7,12 +8,14 @@ import { BUILT_IN_SOURCE } from "../sources.js";
7
8
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
9
  import { ActionPanel } from "./catalog-panel.js";
9
10
  import { hasUnmanagedCopy, isInstalled, loadInstalledSkills } from "./catalog-installs.js";
10
- import { appName, appRecipePreviewLines, appStepLines } from "./app-actions.js";
11
+ import { appName, appPlanLines, appRecipePreviewLines, recipeApprovalChrome } from "./app-actions.js";
11
12
  import { errorText } from "./batch.js";
12
13
  import { useMounted } from "./view-state.js";
13
- import { BorderPanel, DecisionPrompt, PreviewConfirm, Working } from "./panel-parts.js";
14
+ import { BorderPanel, DecisionPrompt, PagedPreview, Working } from "./panel-parts.js";
15
+ import { isYes } from "./keys.js";
14
16
  import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
15
- import { previewViewport } from "./preview-window.js";
17
+ import { usePreviewGeometry } from "./preview-window.js";
18
+ import { lineText, textLine } from "./preview-render.js";
16
19
  import { theme } from "./theme.js";
17
20
  import { busy } from "./working-model.js";
18
21
  /**
@@ -34,24 +37,24 @@ export function groupRecipeRows(rows) {
34
37
  function recipeMemberLine(row) {
35
38
  return `${row.entry.recipe?.platform ?? "all platforms"} · ${row.status} · ${row.entry.file}${row.entry.applicable ? " (used here)" : ""}`;
36
39
  }
37
- function recipeApprovalChrome(entry) {
38
- return {
39
- title: "Recipe approval · does not install",
40
- questions: [`Approve ${appName(entry)}? y/n · recipe approval does not install the App.`, "Scroll to end before y; n cancels."],
41
- footerRows: 2,
42
- };
43
- }
44
40
  function uninstallChrome(mode) {
45
- return {
46
- title: mode.kind === "uninstall-manual" ? "App manual uninstall" : "App uninstall confirmation",
47
- questions: [mode.kind === "uninstall-manual" ? "Complete the manual uninstall, then check now? y/n" : `Uninstall ${appName(mode.removal.entry)}? y/n`, "Scroll to end before y; n cancels."],
48
- footerRows: 2,
49
- };
41
+ const name = appName(mode.removal.entry);
42
+ return mode.kind === "uninstall-manual"
43
+ ? { title: "App manual uninstall", action: "Check the uninstall now", consequence: "Complete the manual uninstall, then check now? y/n" }
44
+ : { title: "App uninstall confirmation", action: `Uninstall ${name}`, consequence: `Uninstall ${name}? y/n` };
45
+ }
46
+ function removeChrome(removal) {
47
+ const name = appName(removal.entry);
48
+ return { title: "Recipe removal", action: `Remove the ${name} recipe`, consequence: `Remove recipe for ${name}? y/n` };
49
+ }
50
+ function removeLines(removal) {
51
+ return [`Delete recipe: ${removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."];
50
52
  }
51
53
  /**
52
54
  * Recipe loading and captured actions; Sources owns the single list and cursor.
53
55
  */
54
- export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, terminalRows, terminalColumns, }) {
56
+ export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, }) {
57
+ const previewGeometry = usePreviewGeometry();
55
58
  const homeDirectory = environment?.homeDirectory;
56
59
  const appEnvironment = environment?.appEnvironment;
57
60
  const apps = useMemo(() => createAppOperations({
@@ -90,7 +93,9 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
90
93
  kind: "approve",
91
94
  entry, removeAfterApproval, cursor: 0,
92
95
  lines: [
93
- ...(members.length > 1 ? [`Recipes named ${appName(entry)}:`, ...members.map(row => ` ${recipeMemberLine(row)}`), ""] : []),
96
+ ...(members.length > 1
97
+ ? [textLine(`Recipes named ${appName(entry)}:`), ...members.map(row => textLine(` ${recipeMemberLine(row)}`)), textLine(" ")]
98
+ : []),
94
99
  ...appRecipePreviewLines(entry, status, steps),
95
100
  ],
96
101
  });
@@ -106,6 +111,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
106
111
  const flow = {
107
112
  operations,
108
113
  env: environment ?? {},
114
+ previewGeometry,
109
115
  isMounted: () => mounted.current,
110
116
  setAction: action => setMode(action.kind === "browse"
111
117
  ? { kind: "browse" }
@@ -161,7 +167,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
161
167
  }
162
168
  const removal = await planAppRecipeRemoval(apps, entry);
163
169
  if (mounted.current)
164
- setMode({ kind: removal.installed ? "uninstall-question" : "remove", removal });
170
+ setMode(removal.installed ? { kind: "uninstall-question", removal } : { kind: "remove", removal, cursor: 0 });
165
171
  }
166
172
  catch (error) {
167
173
  if (mounted.current) {
@@ -181,7 +187,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
181
187
  if (result.status === "manual required")
182
188
  setMode({ ...current, kind: "uninstall-manual" });
183
189
  else if (result.status === "untracked")
184
- setMode({ kind: "remove", removal: current.removal });
190
+ setMode({ kind: "remove", removal: current.removal, cursor: 0 });
185
191
  else {
186
192
  setError(`Uninstall failed: ${result.reason ?? result.status}; recipe retained`);
187
193
  setMode({ kind: "browse" });
@@ -229,13 +235,13 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
229
235
  return true;
230
236
  }
231
237
  if (current.kind === "approve") {
232
- const viewport = previewViewport(current.lines, current.cursor, terminalRows, terminalColumns, recipeApprovalChrome(current.entry));
238
+ const viewport = previewGeometry(current.lines.map(lineText), current.cursor, recipeApprovalChrome(current.entry));
233
239
  const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
234
240
  if (cursor !== undefined)
235
241
  setMode({ ...current, cursor });
236
242
  else if (input === "n" || key.escape)
237
243
  cancel();
238
- else if (input === "y" && viewport.reachedEnd) {
244
+ else if (isYes(input, key) && viewport.reachedEnd) {
239
245
  setBusy("Approving App recipe…");
240
246
  void apps.approve(current.entry).then(async () => {
241
247
  await reload();
@@ -268,18 +274,26 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
268
274
  return true;
269
275
  }
270
276
  if (current.kind === "uninstall-confirm" || current.kind === "uninstall-manual") {
271
- const lines = appStepLines(current.plan);
272
- const viewport = previewViewport(lines, current.cursor, terminalRows, terminalColumns, uninstallChrome(current));
277
+ const lines = appPlanLines(current.plan).map(lineText);
278
+ const viewport = previewGeometry(lines, current.cursor, uninstallChrome(current));
273
279
  const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
274
280
  if (cursor !== undefined)
275
281
  setMode({ ...current, cursor });
276
282
  else if (input === "n" || key.escape)
277
283
  cancel();
278
- else if (input === "y" && viewport.reachedEnd)
284
+ else if (isYes(input, key) && viewport.reachedEnd)
279
285
  void uninstall(current);
280
286
  return true;
281
287
  }
282
288
  if (current.kind === "remove") {
289
+ const viewport = previewGeometry(removeLines(current.removal), current.cursor, removeChrome(current.removal));
290
+ const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
291
+ if (cursor !== undefined) {
292
+ setMode({ ...current, cursor });
293
+ return true;
294
+ }
295
+ if (isYes(input, key) && !viewport.reachedEnd)
296
+ return true;
283
297
  answerYesNo(input, key, () => {
284
298
  setBusy("Removing App recipe…");
285
299
  void removeAppRecipe(apps, current.removal, true).then(async () => {
@@ -344,15 +358,22 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
344
358
  }
345
359
  }, [apps, mounted]);
346
360
  useEffect(() => { void reload(); }, [reload]);
347
- 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" ? (() => {
361
+ // A paged preview sizes itself to the whole terminal, so no message row may sit above it.
362
+ const pagedPreview = (mode.kind === "install" && mode.action.kind === "confirm-install")
363
+ || ["approve", "remove", "uninstall-confirm", "uninstall-manual"].includes(mode.kind);
364
+ const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError && !pagedPreview ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error && !pagedPreview ? _jsx(Text, { color: theme.error, children: error }) : null, notice && !pagedPreview ? _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" ? (() => {
348
365
  const chrome = recipeApprovalChrome(mode.entry);
349
- const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
350
- return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
366
+ const lines = mode.lines.map(lineText);
367
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: mode.lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
351
368
  })() : null, mode.kind === "pick" ? (_jsxs(BorderPanel, { title: "Remove which recipe?", children: [mode.members.map((row, index) => _jsxs(Text, { children: [index + 1, " ", recipeMemberLine(row)] }, row.entry.file)), _jsx(Text, { color: theme.legend, children: "Press a number to review that one file for removal \u00B7 n or Esc cancels" })] })) : null, mode.kind === "uninstall-question" ? (_jsxs(BorderPanel, { title: "Uninstall before recipe removal", children: [_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", "."] }), _jsxs(Text, { children: [_jsx(DecisionPrompt, { children: "Uninstall it before removing the recipe?" }), _jsx(Text, { color: theme.legend, children: "(n cancels removal)" })] })] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (() => {
352
369
  const chrome = uninstallChrome(mode);
353
- const lines = appStepLines(mode.plan);
354
- const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
355
- return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
356
- })() : 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] }));
370
+ const styled = appPlanLines(mode.plan);
371
+ const lines = styled.map(lineText);
372
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: previewGeometry(lines, mode.cursor, chrome) });
373
+ })() : null, mode.kind === "remove" ? (() => {
374
+ const chrome = removeChrome(mode.removal);
375
+ const lines = removeLines(mode.removal);
376
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
377
+ })() : null] }));
357
378
  return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
358
379
  }
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text, useWindowSize } from "ink";
2
+ import { Box, useWindowSize } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import path from "node:path";
4
- import { useCallback, useEffect, useReducer, useRef, useState } from "react";
5
+ import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
5
6
  import { initialMode, modeReducer } from "./sources-mode.js";
6
7
  import { rowStyle, theme } from "./theme.js";
7
8
  import { computeWindow, pageStep, useListHeight } from "./window.js";
8
- import { useKeys } from "./keys.js";
9
+ import { isYes, useKeys } from "./keys.js";
9
10
  import { groupRecipeRows, useSourcesRecipes } from "./sources-recipes.js";
10
- import { BorderPanel, DecisionPrompt, FieldLine, PreviewLines, Working } from "./panel-parts.js";
11
- import { previewViewport } from "./preview-window.js";
11
+ import { BorderPanel, PagedPreview, Working } from "./panel-parts.js";
12
+ import { usePreviewGeometry } from "./preview-window.js";
12
13
  import { movePreviewCursor } from "./mode-keys.js";
14
+ import { formatAgo } from "./updates.js";
15
+ import { animationEnabled, formatElapsed } from "./working-model.js";
13
16
  /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
14
17
  export function sourceRowHints(row) {
15
18
  if (row === undefined)
@@ -29,7 +32,7 @@ async function refreshEach(operations, targets) {
29
32
  let failed = false;
30
33
  for (const source of targets) {
31
34
  try {
32
- lines.push(`Refreshed Git Source: ${(await operations.refreshSource(source.id)).id}`);
35
+ lines.push(`Refreshed Git Source: ${(await operations.refreshSource(source.id, { force: true })).id}`);
33
36
  }
34
37
  catch (error) {
35
38
  failed = true;
@@ -41,19 +44,67 @@ async function refreshEach(operations, targets) {
41
44
  function errorText(error) {
42
45
  return error instanceof Error ? error.message : String(error);
43
46
  }
47
+ const NO_REFRESHES = new Map();
48
+ const NO_SUBSCRIPTION = () => () => undefined;
49
+ /** Re-renders on every session change and, when animation is on, every second so elapsed times move. */
50
+ function useRefreshStates(session) {
51
+ const states = useSyncExternalStore(session?.subscribe ?? NO_SUBSCRIPTION, session?.snapshot ?? (() => NO_REFRESHES));
52
+ const [, setTick] = useState(0);
53
+ useEffect(() => {
54
+ if (session === undefined || !animationEnabled())
55
+ return undefined;
56
+ const timer = setInterval(() => setTick((value) => value + 1), 1000);
57
+ // A redraw timer alone must never keep the process alive.
58
+ timer.unref();
59
+ return () => clearInterval(timer);
60
+ }, [session]);
61
+ return states;
62
+ }
63
+ /** The mark is text first; color only supports it. */
64
+ function RefreshMark({ state, now }) {
65
+ switch (state?.status) {
66
+ case undefined:
67
+ case "not-refreshed":
68
+ return _jsx(Text, { color: theme.muted, children: "\u00B7 not refreshed" });
69
+ case "refreshing":
70
+ return _jsxs(Text, { color: theme.warning, children: ["\u2026 refreshing ", formatElapsed(now - (state.startedAt ?? now))] });
71
+ case "refreshed":
72
+ return _jsxs(Text, { color: theme.success, children: ["\u2713 refreshed ", formatAgo(now - (state.finishedAt ?? now))] });
73
+ case "failed":
74
+ return _jsx(Text, { color: theme.error, children: "\u2717 refresh failed" });
75
+ }
76
+ }
44
77
  function refreshPreviewLines(sources) {
45
78
  return sources.map(source => `Source: refresh Git Source ${source.id} from ${source.url}`);
46
79
  }
47
- function refreshViewport(mode, rows, columns) {
48
- return previewViewport(refreshPreviewLines(mode.sources), mode.cursor, rows, columns, {
80
+ function refreshChrome(count) {
81
+ return {
49
82
  title: "Refresh Git Sources",
50
- questions: ["Scroll to end; n cancels.", "Refresh these Sources? y/n"],
51
- footerRows: 2,
52
- });
83
+ action: `Refresh ${count} ${count === 1 ? "Source" : "Sources"}`,
84
+ consequence: "Refresh these Sources? y/n",
85
+ };
86
+ }
87
+ function removeSourcePreviewLines(source) {
88
+ return [`Source: remove Git Source ${source.id} from ${source.url}`];
53
89
  }
54
- export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange }) {
55
- const { columns, rows: terminalRows } = useWindowSize();
56
- const height = useListHeight(listHeight, 9);
90
+ const REMOVE_SOURCE_CHROME = {
91
+ title: "Remove Git Source",
92
+ action: "Remove Git Source",
93
+ consequence: "Remove this Source? y/n",
94
+ };
95
+ /** The lines and chrome of the open confirmation preview, shared by key handling and rendering. */
96
+ function confirmPreview(mode) {
97
+ return mode.action === "refresh"
98
+ ? { lines: refreshPreviewLines(mode.sources), chrome: refreshChrome(mode.sources.length) }
99
+ : { lines: removeSourcePreviewLines(mode.source), chrome: REMOVE_SOURCE_CHROME };
100
+ }
101
+ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange, refreshSession }) {
102
+ const refreshStates = useRefreshStates(refreshSession);
103
+ const { rows: terminalRows } = useWindowSize();
104
+ const previewGeometry = usePreviewGeometry();
105
+ // With a refresh session, one more row is kept for the selected Source's refresh error; below 24 rows
106
+ // the list is already at its minimum, so that line is left out rather than pushing the footer away.
107
+ const height = useListHeight(listHeight, refreshSession === undefined ? 9 : 10);
57
108
  const [state, setState] = useState(() => ({ status: "loading", startedAt: Date.now() }));
58
109
  // The highlight follows the source id; the index is only the fallback once that source is gone.
59
110
  // Selection and marks are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
@@ -126,8 +177,6 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
126
177
  const row = rows[resolveIndex(rows, selectionRef.current)];
127
178
  return row?.kind === "app" ? row.group.primary.entry.file : undefined;
128
179
  },
129
- terminalRows: terminalRows || 24,
130
- terminalColumns: columns || 80,
131
180
  });
132
181
  const rows = [
133
182
  ...sources.map((source) => ({ id: source.id, kind: "skills", source })),
@@ -136,8 +185,8 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
136
185
  rowsRef.current = rows;
137
186
  const selected = resolveIndex(rows, selection);
138
187
  const selectedRow = rows[selected];
139
- const hints = mode.kind === "confirm" && mode.action === "refresh"
140
- ? "↑↓/Pg page · y refresh · n cancel"
188
+ const hints = mode.kind === "confirm"
189
+ ? `↑↓/Pg page · y ${mode.action} · n cancel`
141
190
  : sourceRowHints(selectedRow);
142
191
  useEffect(() => { onHintsChange?.(hints); }, [hints, onHintsChange]);
143
192
  const select = (index) => {
@@ -240,17 +289,17 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
240
289
  return;
241
290
  }
242
291
  if (mode.kind === "confirm") {
243
- if (mode.action === "refresh") {
244
- const viewport = refreshViewport(mode, terminalRows || 24, columns || 80);
245
- const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
246
- if (cursor !== undefined) {
247
- dispatch({ type: "preview-cursor", cursor });
248
- return;
249
- }
250
- if (input === "y" && !viewport.reachedEnd)
251
- return;
292
+ const { lines, chrome } = confirmPreview(mode);
293
+ const viewport = previewGeometry(lines, mode.cursor, chrome);
294
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
295
+ if (cursor !== undefined) {
296
+ dispatch({ type: "preview-cursor", cursor });
297
+ return;
252
298
  }
253
- if (input === "y") {
299
+ const yes = isYes(input, key);
300
+ if (yes && !viewport.reachedEnd)
301
+ return;
302
+ if (yes) {
254
303
  if (mode.action === "refresh") {
255
304
  const { sources: targets } = mode;
256
305
  void run(targets.length === 1 ? "Refreshing Git Source…" : "Refreshing Git Sources…", async () => {
@@ -329,17 +378,19 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
329
378
  if (state.status === "error")
330
379
  return _jsx(BorderPanel, { title: "Source error", children: _jsx(Text, { color: theme.error, children: state.message }) });
331
380
  const window = computeWindow(rows.length, selected, height);
381
+ const now = refreshSession?.now() ?? 0;
382
+ const selectedRefresh = selectedRow?.kind === "skills" ? refreshStates.get(selectedRow.source.id) : undefined;
332
383
  if (mode.kind === "confirm")
333
- return _jsx(Prompt, { mode: mode, terminalRows: terminalRows || 24, terminalColumns: columns || 80 });
384
+ return _jsx(Prompt, { mode: mode, previewGeometry: previewGeometry });
334
385
  if (recipes.capturing)
335
386
  return _jsx(Box, { flexDirection: "column", children: recipes.panel });
336
387
  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) => {
337
388
  const index = window.start + offset;
338
389
  const id = row.kind === "skills" ? row.source.id : row.group.primary.entry.recipe?.name ?? row.group.primary.entry.file.split(/[\\/]/u).pop();
339
390
  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"
340
- ? _jsx(Text, { color: theme.value, children: row.source.url })
391
+ ? _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: row.source.url }), refreshSession === undefined ? null : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(RefreshMark, { state: refreshStates.get(row.source.id), now: now })] })] })
341
392
  : _jsx(RecipeStatus, { group: row.group })] }, row.id));
342
- }), 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
393
+ }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedRefresh?.status === "failed" && (terminalRows || 24) >= 24 ? _jsxs(Text, { color: theme.error, wrap: "truncate-end", children: ["Refresh failed: ", selectedRefresh.error] }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, recipes.loadingSince !== undefined && mode.kind !== "busy" && !recipes.capturing
343
394
  ? _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] }));
344
395
  }
345
396
  /** Status of the recipe that applies here, led by its platform when it names one; other platforms follow, muted. */
@@ -348,20 +399,16 @@ function RecipeStatus({ group }) {
348
399
  const platform = primary.entry.recipe?.platform;
349
400
  return (_jsxs(_Fragment, { children: [platform === undefined ? null : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: platform }), _jsx(Text, { color: theme.legend, children: " \u00B7 " })] }), primary.entry.error === undefined || primary.entry.recipe === undefined ? null : _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.value, children: path.basename(primary.entry.file) }), _jsx(Text, { color: theme.legend, children: " \u00B7 " })] }), _jsx(Text, { color: primary.status === "approved" ? theme.success : theme.warning, children: primary.status === "approved" ? "✓ approved" : primary.status }), members.slice(1).map(other => (_jsxs(Text, { color: theme.muted, children: [" ", other.entry.recipe?.platform ?? "all platforms", " (other platform)"] }, other.entry.file)))] }));
350
401
  }
351
- function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
402
+ function Prompt({ mode, previewGeometry }) {
352
403
  if (mode.kind === "add-kind")
353
404
  return _jsx(Text, { children: "Add: 1 Git Source (skills + Packages) \u00B7 2 App recipe (Esc cancel)" });
354
405
  if (mode.kind === "input")
355
406
  return _jsxs(Text, { children: ["URL: ", mode.value] });
356
407
  if (mode.kind === "busy")
357
408
  return _jsx(BorderPanel, { title: "Sources", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
358
- if (mode.kind === "confirm") {
359
- return mode.action === "refresh" ? (() => {
360
- const viewport = refreshViewport(mode, terminalRows, terminalColumns);
361
- return (_jsxs(BorderPanel, { title: "Refresh Git Sources", children: [_jsx(PreviewLines, { lines: viewport.rows }), viewport.total > viewport.height ? _jsxs(Text, { color: theme.legend, children: [viewport.start + 1, "\u2013", viewport.end, " of ", viewport.total] }) : null, _jsxs(Text, { color: theme.legend, children: ["Review ", viewport.cursor + 1, " of ", viewport.total, " \u00B7 arrows/Pg page"] }), viewport.reachedEnd
362
- ? _jsx(DecisionPrompt, { children: "Refresh these Sources?" })
363
- : _jsx(Text, { color: theme.legend, children: "Scroll to end; n cancels." })] }));
364
- })() : (_jsxs(BorderPanel, { title: "Remove Git Source", children: [_jsx(FieldLine, { label: "Source", value: `remove Git Source ${mode.source.id} from ${mode.source.url}` }), _jsx(DecisionPrompt, { children: "Remove this Source?" })] }));
409
+ if (mode.kind === "confirm" && previewGeometry !== undefined) {
410
+ const { lines, chrome } = confirmPreview(mode);
411
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
365
412
  }
366
413
  return null;
367
414
  }
@@ -1,8 +1,9 @@
1
1
  import { realpath } from "node:fs/promises";
2
+ import { quoteText } from "../preview-model.js";
2
3
  import path from "node:path";
3
4
  import { describeUnmanagedRemoval, describeUnmanagedSymlinkRemoval, removeInspectedUnmanagedSkill, removeInspectedUnmanagedSymlink, } from "../unmanaged-removal.js";
4
5
  import { inspectUserGlobalSkillRemoval, inspectUserGlobalSymlinkRemoval, } from "../user-global-skill-inventory.js";
5
- import { answerYesNo, moveCursor } from "./mode-keys.js";
6
+ import { answerPaged, moveCursor } from "./mode-keys.js";
6
7
  import { homeOf } from "./installations.js";
7
8
  import { busy } from "./working-model.js";
8
9
  export const UNMANAGED_KINDS = ["unmanaged-scope", "unmanaged-pick", "confirm-unmanaged"];
@@ -52,7 +53,7 @@ export async function prepareUnmanagedRemoval(operations, environment, group, lo
52
53
  if (planned.has(link.path))
53
54
  continue;
54
55
  planned.set(link.path, link);
55
- preview.push(`also removes link ${JSON.stringify(link.path)} → ${JSON.stringify(chosen.path)} (it would dangle)`);
56
+ preview.push(`also removes link ${quoteText(link.path)} → ${quoteText(chosen.path)} (it would dangle)`);
56
57
  }
57
58
  }
58
59
  for (const location of linksFirst([...planned.values()])) {
@@ -105,7 +106,7 @@ async function startPreview(context, group, locations) {
105
106
  const prepared = await prepareUnmanagedRemoval(context.operations, context.env, group, locations);
106
107
  if (!context.isMounted())
107
108
  return;
108
- context.setMode({ kind: "confirm-unmanaged", group, prepared });
109
+ context.setMode({ kind: "confirm-unmanaged", group, prepared, cursor: 0 });
109
110
  }
110
111
  catch (error) {
111
112
  if (!context.isMounted())
@@ -125,6 +126,11 @@ function cancel(context) {
125
126
  context.setMessage({ kind: "ok", text: "Uninstall cancelled" });
126
127
  context.browse();
127
128
  }
129
+ /** What the confirmation shows, so the key handler and the panel page the same rows. */
130
+ export function unmanagedConfirmPreview(mode) {
131
+ const { name } = mode.group;
132
+ return { lines: mode.prepared.preview, chrome: { title: "Remove unmanaged skill", action: `Remove ${name}`, consequence: `Remove ${name}? y/n` } };
133
+ }
128
134
  async function execute(context, group, prepared) {
129
135
  context.setMode(busy(`Removing ${group.name}…`));
130
136
  await context.finish(await runUnmanagedRemoval(prepared, context.operations, context.env));
@@ -170,9 +176,15 @@ export function handleUnmanagedKey(context, mode, input, key) {
170
176
  }
171
177
  return true;
172
178
  }
173
- case "confirm-unmanaged":
174
- answerYesNo(input, key, () => void execute(context, current.group, current.prepared), () => cancel(context));
179
+ case "confirm-unmanaged": {
180
+ const { lines, chrome } = unmanagedConfirmPreview(current);
181
+ answerPaged(context.previewGeometry(lines, current.cursor, chrome), current.cursor, input, key, {
182
+ moveTo: (cursor) => context.setMode({ ...current, cursor }),
183
+ yes: () => void execute(context, current.group, current.prepared),
184
+ no: () => cancel(context),
185
+ });
175
186
  return true;
187
+ }
176
188
  default:
177
189
  return false;
178
190
  }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { formatProgressLabel } from "../flow-progress.js";
2
3
  import { errorText } from "./batch.js";
3
4
  import { BROWSE } from "./updates-mode.js";
4
5
  import { CHECKING, emptyData, PHASE_LABEL, REFRESHING } from "./updates-model.js";
@@ -12,7 +13,7 @@ export function useUpdatesFlow(inputs) {
12
13
  useEffect(() => () => {
13
14
  pendingAnswer.current?.(false);
14
15
  }, []);
15
- const load = useCallback(async (refresh) => {
16
+ const load = useCallback(async (refresh, force = false) => {
16
17
  const token = ++loadToken.current;
17
18
  // The check after an applied update continues that update's clock; a check from browsing starts one.
18
19
  const running = busy(refresh ? REFRESHING : CHECKING, modeRef.current, clock);
@@ -26,7 +27,7 @@ export function useUpdatesFlow(inputs) {
26
27
  setMode(running);
27
28
  let next;
28
29
  try {
29
- next = { status: "ready", data: await loadUpdateRows(operations, env, { refresh, onPhase: phase, now: clock }) };
30
+ next = { status: "ready", data: await loadUpdateRows(operations, env, { refresh, force, onPhase: phase, now: clock }) };
30
31
  }
31
32
  catch (error) {
32
33
  // The scopes are read separately, so this is unexpected; show it as the check's result.
@@ -62,9 +63,14 @@ export function useUpdatesFlow(inputs) {
62
63
  }
63
64
  };
64
65
  const apply = async (prepared, confirmedPaths) => {
65
- setMode(busy("Applying updates…", undefined, clock));
66
+ const running = busy("Applying updates…", undefined, clock);
67
+ setMode(running);
66
68
  let result;
67
69
  try {
70
+ const progress = (event) => {
71
+ if (mounted.current)
72
+ setMode(busy(formatProgressLabel(event), running, clock));
73
+ };
68
74
  const outcome = await runUpdates(prepared, confirmedPaths, (plan, reason) => new Promise(resolve => {
69
75
  if (!mounted.current) {
70
76
  resolve(false);
@@ -75,8 +81,8 @@ export function useUpdatesFlow(inputs) {
75
81
  resolve(done);
76
82
  };
77
83
  pendingAnswer.current = answer;
78
- setMode({ kind: "manual", plan, reason, answer, cursor: 0 });
79
- }));
84
+ setMode({ kind: "manual", plan, reason, answer, cursor: 0, startedAt: running.startedAt });
85
+ }), progress);
80
86
  result = { kind: outcome.failed === 0 ? "ok" : "error", lines: outcome.lines };
81
87
  }
82
88
  catch (error) {
@@ -120,10 +120,10 @@ export function handleUpdatesKey(context, mode, input, key) {
120
120
  answerYesNo(input, key, () => {
121
121
  if (!context.previewAtEnd(mode))
122
122
  return;
123
- context.setMode(busy("Checking App version…", undefined, context.now));
123
+ context.setMode(busy("Checking App version…", mode, context.now));
124
124
  mode.answer(true);
125
125
  }, () => {
126
- context.setMode(busy("Continuing updates…", undefined, context.now));
126
+ context.setMode(busy("Continuing updates…", mode, context.now));
127
127
  mode.answer(false);
128
128
  });
129
129
  return;
@@ -1,5 +1,6 @@
1
1
  import { buildUpdateTree } from "./updates-tree.js";
2
2
  import { flattenVisible } from "./tree.js";
3
+ import { formatAgo } from "./updates.js";
3
4
  export const REFRESHING = "Refreshing sources…";
4
5
  export const CHECKING = "Checking for updates…";
5
6
  export const PHASE_LABEL = { refreshing: REFRESHING, checking: CHECKING };
@@ -40,7 +41,7 @@ export function viewOf(data, scope, showUnknown, expanded) {
40
41
  return { shown, updatable, unknown, upToDate, entries };
41
42
  }
42
43
  export function describeRefresh(data) {
43
- const { attempted, refreshed, failed, unreadable } = data.refresh;
44
+ const { attempted, refreshed, failed, unreadable, reusedAt } = data.refresh;
44
45
  if (!attempted)
45
46
  return "sources not fetched again";
46
47
  if (unreadable.length > 0) {
@@ -49,7 +50,9 @@ export function describeRefresh(data) {
49
50
  if (failed.length > 0) {
50
51
  return `${failed.length} of ${failed.length + refreshed.length} sources could not be refreshed; their current data was used`;
51
52
  }
52
- return refreshed.length > 0 ? "sources refreshed" : "no Git sources to refresh";
53
+ if (refreshed.length === 0)
54
+ return "no Git sources to refresh";
55
+ return reusedAt === undefined ? "sources refreshed" : `sources refreshed ${formatAgo(data.checkedAt - reusedAt)}`;
53
56
  }
54
57
  /** Data for a check that failed as a whole, so the view can show the reason. */
55
58
  export function emptyData(reason, checkedAt) {
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text, useWindowSize } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { rowStyle, theme } from "./theme.js";
4
- import { BorderPanel, FieldLine, PreviewConfirm } from "./panel-parts.js";
5
- import { previewViewport } from "./preview-window.js";
5
+ import { BorderPanel, FieldLine, PagedPreview } from "./panel-parts.js";
6
+ import { usePreviewGeometry } from "./preview-window.js";
6
7
  import { describeRefresh, describeUnknownReasons } from "./updates-model.js";
7
- import { formatAgo, previewLineText } from "./updates.js";
8
+ import { formatAgo } from "./updates.js";
9
+ import { lineText } from "./preview-render.js";
8
10
  import { groupCheckState, packageLeafLabel } from "./updates-tree.js";
9
- import { updateModePreviewChrome, updateModePreviewLines, updateModeQuestion } from "./updates-preview.js";
11
+ import { updateModePreviewChrome, updateModePreviewLines } from "./updates-preview.js";
10
12
  function updateResultField(line, defaultOk) {
11
13
  if (line.startsWith("Update summary: ")) {
12
14
  const successful = /^Update summary: \d+ updated, 0 failed$/.test(line);
@@ -54,11 +56,10 @@ export function UpdatesFooter({ indicator, upToDate, updatableCount, checkedCoun
54
56
  }) })) : null] }));
55
57
  }
56
58
  function CapturedUpdatePreview({ mode }) {
57
- const { rows, columns } = useWindowSize();
58
- const lines = updateModePreviewLines(mode).map(previewLineText);
59
+ const styled = updateModePreviewLines(mode);
60
+ const lines = styled.map(lineText);
59
61
  const chrome = updateModePreviewChrome(mode);
60
- const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
61
- return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: updateModeQuestion(mode, viewport.reachedEnd), chrome: chrome }));
62
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
62
63
  }
63
64
  export function ModePanel({ mode }) {
64
65
  switch (mode.kind) {