@iseoane/agent-depot 0.6.13 → 0.6.15

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 (55) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/src/app-flow.js +8 -1
  4. package/dist/src/atomic-file.js +29 -3
  5. package/dist/src/cli.js +2 -0
  6. package/dist/src/flow-progress.js +9 -0
  7. package/dist/src/git-source.js +257 -15
  8. package/dist/src/preview-model.js +37 -0
  9. package/dist/src/process-runner.js +1 -0
  10. package/dist/src/profile-cli.js +12 -4
  11. package/dist/src/profile-export.js +23 -3
  12. package/dist/src/skill-update.js +1 -0
  13. package/dist/src/source-refresh-session.js +139 -0
  14. package/dist/src/sources.js +4 -4
  15. package/dist/src/tui/adoption-actions.js +21 -2
  16. package/dist/src/tui/app-actions.js +57 -47
  17. package/dist/src/tui/app.js +22 -4
  18. package/dist/src/tui/bulk-actions.js +45 -11
  19. package/dist/src/tui/catalog-actions.js +12 -0
  20. package/dist/src/tui/catalog-flow.js +31 -11
  21. package/dist/src/tui/catalog-panel.js +15 -13
  22. package/dist/src/tui/catalog-tree.js +7 -2
  23. package/dist/src/tui/catalog-view.js +12 -6
  24. package/dist/src/tui/file-picker.js +2 -1
  25. package/dist/src/tui/installations-browse.js +1 -1
  26. package/dist/src/tui/installations-panel.js +12 -4
  27. package/dist/src/tui/installations-rows.js +2 -2
  28. package/dist/src/tui/installations-tree.js +1 -1
  29. package/dist/src/tui/installations-view.js +16 -7
  30. package/dist/src/tui/keys.js +35 -4
  31. package/dist/src/tui/manage-actions.js +55 -27
  32. package/dist/src/tui/manage-panel.js +15 -29
  33. package/dist/src/tui/mode-keys.js +11 -1
  34. package/dist/src/tui/package-actions.js +25 -18
  35. package/dist/src/tui/package-rows.js +9 -4
  36. package/dist/src/tui/panel-parts.js +54 -37
  37. package/dist/src/tui/preview-render.js +93 -0
  38. package/dist/src/tui/preview-window.js +185 -20
  39. package/dist/src/tui/profile-view.js +54 -44
  40. package/dist/src/tui/render.js +13 -1
  41. package/dist/src/tui/safe-text.js +25 -0
  42. package/dist/src/tui/sources-mode.js +2 -2
  43. package/dist/src/tui/sources-recipes.js +53 -32
  44. package/dist/src/tui/sources-view.js +85 -39
  45. package/dist/src/tui/text-wrap.js +47 -3
  46. package/dist/src/tui/unmanaged-actions.js +17 -5
  47. package/dist/src/tui/updates-flow.js +11 -5
  48. package/dist/src/tui/updates-keys.js +2 -2
  49. package/dist/src/tui/updates-model.js +5 -2
  50. package/dist/src/tui/updates-panel.js +10 -9
  51. package/dist/src/tui/updates-preview.js +27 -44
  52. package/dist/src/tui/updates-view.js +15 -11
  53. package/dist/src/tui/updates.js +117 -33
  54. package/dist/src/update-flow.js +31 -15
  55. package/package.json +1 -1
@@ -1,15 +1,17 @@
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 { animationEnabled, formatElapsed } from "./working-model.js";
13
15
  /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
14
16
  export function sourceRowHints(row) {
15
17
  if (row === undefined)
@@ -29,7 +31,7 @@ async function refreshEach(operations, targets) {
29
31
  let failed = false;
30
32
  for (const source of targets) {
31
33
  try {
32
- lines.push(`Refreshed Git Source: ${(await operations.refreshSource(source.id)).id}`);
34
+ lines.push(`Refreshed Git Source: ${(await operations.refreshSource(source.id, { force: true })).id}`);
33
35
  }
34
36
  catch (error) {
35
37
  failed = true;
@@ -41,19 +43,67 @@ async function refreshEach(operations, targets) {
41
43
  function errorText(error) {
42
44
  return error instanceof Error ? error.message : String(error);
43
45
  }
46
+ const NO_REFRESHES = new Map();
47
+ const NO_SUBSCRIPTION = () => () => undefined;
48
+ /** Re-renders on every session change and, when animation is on, every second so elapsed times move. */
49
+ function useRefreshStates(session) {
50
+ const states = useSyncExternalStore(session?.subscribe ?? NO_SUBSCRIPTION, session?.snapshot ?? (() => NO_REFRESHES));
51
+ const [, setTick] = useState(0);
52
+ useEffect(() => {
53
+ if (session === undefined || !animationEnabled())
54
+ return undefined;
55
+ const timer = setInterval(() => setTick((value) => value + 1), 1000);
56
+ // A redraw timer alone must never keep the process alive.
57
+ timer.unref();
58
+ return () => clearInterval(timer);
59
+ }, [session]);
60
+ return states;
61
+ }
62
+ /** The mark is text first; color only supports it. */
63
+ function RefreshMark({ state, now }) {
64
+ switch (state?.status) {
65
+ case undefined:
66
+ case "not-refreshed":
67
+ return _jsx(Text, { color: theme.muted, children: "\u00B7 not refreshed" });
68
+ case "refreshing":
69
+ return _jsxs(Text, { color: theme.warning, children: ["\u2026 refreshing ", formatElapsed(now - (state.startedAt ?? now))] });
70
+ case "refreshed":
71
+ return _jsx(Text, { color: theme.success, children: "\u2713 refreshed" });
72
+ case "failed":
73
+ return _jsx(Text, { color: theme.error, children: "\u2717 refresh failed" });
74
+ }
75
+ }
44
76
  function refreshPreviewLines(sources) {
45
77
  return sources.map(source => `Source: refresh Git Source ${source.id} from ${source.url}`);
46
78
  }
47
- function refreshViewport(mode, rows, columns) {
48
- return previewViewport(refreshPreviewLines(mode.sources), mode.cursor, rows, columns, {
79
+ function refreshChrome(count) {
80
+ return {
49
81
  title: "Refresh Git Sources",
50
- questions: ["Scroll to end; n cancels.", "Refresh these Sources? y/n"],
51
- footerRows: 2,
52
- });
82
+ action: `Refresh ${count} ${count === 1 ? "Source" : "Sources"}`,
83
+ consequence: "Refresh these Sources? y/n",
84
+ };
85
+ }
86
+ function removeSourcePreviewLines(source) {
87
+ return [`Source: remove Git Source ${source.id} from ${source.url}`];
53
88
  }
54
- export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange }) {
55
- const { columns, rows: terminalRows } = useWindowSize();
56
- const height = useListHeight(listHeight, 9);
89
+ const REMOVE_SOURCE_CHROME = {
90
+ title: "Remove Git Source",
91
+ action: "Remove Git Source",
92
+ consequence: "Remove this Source? y/n",
93
+ };
94
+ /** The lines and chrome of the open confirmation preview, shared by key handling and rendering. */
95
+ function confirmPreview(mode) {
96
+ return mode.action === "refresh"
97
+ ? { lines: refreshPreviewLines(mode.sources), chrome: refreshChrome(mode.sources.length) }
98
+ : { lines: removeSourcePreviewLines(mode.source), chrome: REMOVE_SOURCE_CHROME };
99
+ }
100
+ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange, refreshSession }) {
101
+ const refreshStates = useRefreshStates(refreshSession);
102
+ const { rows: terminalRows } = useWindowSize();
103
+ const previewGeometry = usePreviewGeometry();
104
+ // With a refresh session, one more row is kept for the selected Source's refresh error; below 24 rows
105
+ // the list is already at its minimum, so that line is left out rather than pushing the footer away.
106
+ const height = useListHeight(listHeight, refreshSession === undefined ? 9 : 10);
57
107
  const [state, setState] = useState(() => ({ status: "loading", startedAt: Date.now() }));
58
108
  // The highlight follows the source id; the index is only the fallback once that source is gone.
59
109
  // Selection and marks are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
@@ -126,8 +176,6 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
126
176
  const row = rows[resolveIndex(rows, selectionRef.current)];
127
177
  return row?.kind === "app" ? row.group.primary.entry.file : undefined;
128
178
  },
129
- terminalRows: terminalRows || 24,
130
- terminalColumns: columns || 80,
131
179
  });
132
180
  const rows = [
133
181
  ...sources.map((source) => ({ id: source.id, kind: "skills", source })),
@@ -136,8 +184,8 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
136
184
  rowsRef.current = rows;
137
185
  const selected = resolveIndex(rows, selection);
138
186
  const selectedRow = rows[selected];
139
- const hints = mode.kind === "confirm" && mode.action === "refresh"
140
- ? "↑↓/Pg page · y refresh · n cancel"
187
+ const hints = mode.kind === "confirm"
188
+ ? `↑↓/Pg page · y ${mode.action} · n cancel`
141
189
  : sourceRowHints(selectedRow);
142
190
  useEffect(() => { onHintsChange?.(hints); }, [hints, onHintsChange]);
143
191
  const select = (index) => {
@@ -240,17 +288,17 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
240
288
  return;
241
289
  }
242
290
  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;
291
+ const { lines, chrome } = confirmPreview(mode);
292
+ const viewport = previewGeometry(lines, mode.cursor, chrome);
293
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
294
+ if (cursor !== undefined) {
295
+ dispatch({ type: "preview-cursor", cursor });
296
+ return;
252
297
  }
253
- if (input === "y") {
298
+ const yes = isYes(input, key);
299
+ if (yes && !viewport.reachedEnd)
300
+ return;
301
+ if (yes) {
254
302
  if (mode.action === "refresh") {
255
303
  const { sources: targets } = mode;
256
304
  void run(targets.length === 1 ? "Refreshing Git Source…" : "Refreshing Git Sources…", async () => {
@@ -329,17 +377,19 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
329
377
  if (state.status === "error")
330
378
  return _jsx(BorderPanel, { title: "Source error", children: _jsx(Text, { color: theme.error, children: state.message }) });
331
379
  const window = computeWindow(rows.length, selected, height);
380
+ const now = refreshSession?.now() ?? 0;
381
+ const selectedRefresh = selectedRow?.kind === "skills" ? refreshStates.get(selectedRow.source.id) : undefined;
332
382
  if (mode.kind === "confirm")
333
- return _jsx(Prompt, { mode: mode, terminalRows: terminalRows || 24, terminalColumns: columns || 80 });
383
+ return _jsx(Prompt, { mode: mode, previewGeometry: previewGeometry });
334
384
  if (recipes.capturing)
335
385
  return _jsx(Box, { flexDirection: "column", children: recipes.panel });
336
386
  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
387
  const index = window.start + offset;
338
388
  const id = row.kind === "skills" ? row.source.id : row.group.primary.entry.recipe?.name ?? row.group.primary.entry.file.split(/[\\/]/u).pop();
339
389
  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 })
390
+ ? _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
391
  : _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
392
+ }), 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
393
  ? _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
394
  }
345
395
  /** Status of the recipe that applies here, led by its platform when it names one; other platforms follow, muted. */
@@ -348,20 +398,16 @@ function RecipeStatus({ group }) {
348
398
  const platform = primary.entry.recipe?.platform;
349
399
  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
400
  }
351
- function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
401
+ function Prompt({ mode, previewGeometry }) {
352
402
  if (mode.kind === "add-kind")
353
403
  return _jsx(Text, { children: "Add: 1 Git Source (skills + Packages) \u00B7 2 App recipe (Esc cancel)" });
354
404
  if (mode.kind === "input")
355
405
  return _jsxs(Text, { children: ["URL: ", mode.value] });
356
406
  if (mode.kind === "busy")
357
407
  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?" })] }));
408
+ if (mode.kind === "confirm" && previewGeometry !== undefined) {
409
+ const { lines, chrome } = confirmPreview(mode);
410
+ return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
365
411
  }
366
412
  return null;
367
413
  }
@@ -1,11 +1,55 @@
1
1
  import stringWidth from "string-width";
2
2
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ // Control, format and line-separator characters the TUI prints as escapes; the zero-width joiner stays for emoji sequences.
4
+ export const UNSAFE_PATTERN = "(?!\\u200D)[\\p{Cc}\\p{Cf}\\u2028\\u2029]";
5
+ const UNSAFE_CHARACTER = new RegExp(UNSAFE_PATTERN, "u");
6
+ /** Text shaped like an escape the TUI prints (`x1b`, `u202e`, `u{1f600}`), ignoring case. */
7
+ export const ESCAPE_TEXT = "x[0-9a-f]{2}|u[0-9a-f]{4}|u\\{[0-9a-f]{1,6}\\}";
8
+ const ESCAPE_UNIT = new RegExp(`(\\\\+)(${ESCAPE_TEXT})`, "giu");
9
+ /** Whether `escape` (`x1b`, `u202e`, `u{1f600}`) names a character the TUI would print as an escape. */
10
+ export function namesEscapedCharacter(escape) {
11
+ const code = Number.parseInt(escape.replace(/^[xu]\{?|\}$/giu, ""), 16);
12
+ return code <= 0x10ffff && UNSAFE_CHARACTER.test(String.fromCodePoint(code));
13
+ }
14
+ /**
15
+ * The units text is cut at: graphemes, except around an escape written as text that names a character the TUI escapes. Cut inside,
16
+ * its doubled backslash would leave a `\x1b` that reads as a real ESC. The backslash run splits into pairs from the left, so a cut
17
+ * between pairs keeps its parity, and the last one or two backslashes stay with the escape text: a unit is at most 12 columns. A unit
18
+ * wider than `maxWidth` falls back to graphemes. `index` is the UTF-16 offset of the unit.
19
+ */
20
+ export function textUnits(value, maxWidth = Infinity) {
21
+ const units = [];
22
+ let from = 0;
23
+ const addGraphemes = (text, offset) => {
24
+ for (const { segment, index } of graphemes.segment(text))
25
+ units.push({ segment, index: offset + index });
26
+ };
27
+ for (const match of value.matchAll(ESCAPE_UNIT)) {
28
+ const [whole, backslashes = "", escape = ""] = match;
29
+ if (!namesEscapedCharacter(escape))
30
+ continue;
31
+ const tail = backslashes.length % 2 === 1 ? 1 : 2;
32
+ const pairs = backslashes.length - tail;
33
+ const unit = backslashes.slice(pairs) + escape;
34
+ addGraphemes(value.slice(from, match.index), from);
35
+ if (stringWidth(unit) > maxWidth)
36
+ addGraphemes(whole, match.index);
37
+ else {
38
+ for (let offset = 0; offset < pairs; offset += 2)
39
+ units.push({ segment: "\\\\", index: match.index + offset });
40
+ units.push({ segment: unit, index: match.index + pairs });
41
+ }
42
+ from = match.index + whole.length;
43
+ }
44
+ addGraphemes(value.slice(from), from);
45
+ return units;
46
+ }
3
47
  /** Split text on grapheme boundaries so wide and combining characters wrap at `width` columns. */
4
48
  export function wrapText(value, width) {
5
49
  const lines = [];
6
50
  let line = "";
7
51
  let lineWidth = 0;
8
- for (const { segment } of graphemes.segment(value)) {
52
+ for (const { segment } of textUnits(value, width)) {
9
53
  const segmentWidth = stringWidth(segment);
10
54
  if (line && lineWidth + segmentWidth > width) {
11
55
  lines.push(line);
@@ -26,7 +70,7 @@ export function fitLines(lines, maxRows, width) {
26
70
  const visible = lines.slice(0, maxRows);
27
71
  let last = "";
28
72
  let used = 0;
29
- for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
73
+ for (const { segment } of textUnits(visible[maxRows - 1])) {
30
74
  const segmentWidth = stringWidth(segment);
31
75
  if (used + segmentWidth > width - 1)
32
76
  break;
@@ -55,7 +99,7 @@ export function clipText(value, width) {
55
99
  return value;
56
100
  let last = "";
57
101
  let used = 0;
58
- for (const { segment } of graphemes.segment(value)) {
102
+ for (const { segment } of textUnits(value)) {
59
103
  const segmentWidth = stringWidth(segment);
60
104
  if (used + segmentWidth > Math.max(width - 1, 0))
61
105
  break;
@@ -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, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
62
63
  }
63
64
  export function ModePanel({ mode }) {
64
65
  switch (mode.kind) {
@@ -1,65 +1,48 @@
1
- import { previewText } from "./updates.js";
1
+ import { quoteText } from "../preview-model.js";
2
+ import { appPlanLines } from "./app-actions.js";
3
+ import { fieldLine, renderPreviewDocument, textLine } from "./preview-render.js";
2
4
  export function updateModePreviewLines(mode) {
3
5
  switch (mode.kind) {
4
6
  case "preview":
5
- return [
6
- ...mode.prepared.lines,
7
- ...mode.prepared.appFailures.map(previewText),
8
- ...mode.prepared.packageFailures.map(previewText),
9
- ...mode.prepared.failures.map(({ item, message }) => previewText(`Preview failed for ${JSON.stringify(item.id)}: ${message}`)),
10
- ];
7
+ return renderPreviewDocument(mode.prepared.document);
11
8
  case "manual":
12
- return [
13
- { kind: "field", label: "App:", value: mode.plan.entry.recipe?.name ?? mode.plan.entry.file },
14
- { kind: "field", label: "Environment:", value: mode.plan.environment },
15
- { kind: "field", label: "Working directory:", value: mode.plan.cwd },
16
- { kind: "field", label: "Manual:", value: mode.plan.manual ?? "" },
17
- ...(mode.reason === undefined ? [] : [previewText(mode.reason)]),
18
- ];
9
+ return [...appPlanLines(mode.plan), ...(mode.reason === undefined ? [] : [textLine(mode.reason)])];
19
10
  case "confirm-paths":
20
11
  return [
21
- previewText("Outside .agents/skills and .claude/skills:"),
22
- ...mode.prepared.nonCanonicalPaths.map(({ scope, path }) => ({
23
- kind: "field",
24
- label: "Path:",
25
- value: `[${scope}] ${JSON.stringify(path)}`,
26
- })),
12
+ textLine("Outside .agents/skills and .claude/skills:"),
13
+ ...mode.prepared.nonCanonicalPaths.map(({ scope, path }) => fieldLine("Path:", `[${scope}] ${quoteText(path)}`)),
27
14
  ];
28
15
  default:
29
16
  return [];
30
17
  }
31
18
  }
19
+ /**
20
+ * What else confirming does besides applying: replaced local changes and external commands. Kept to
21
+ * a bounded length so a narrow footer still fits the question after it; the warnings name every Skill.
22
+ */
23
+ function previewEffects(prepared) {
24
+ const replaced = prepared.groups.flatMap((group) => group.applicable.filter((item) => group.overwriteShown.has(item.id)));
25
+ const commands = prepared.document.sections.flatMap(({ items }) => items.flatMap(({ rows }) => rows.filter((row) => row.kind === "command"))).length;
26
+ return [
27
+ ...(replaced.length === 1 ? [`replaces ${replaced[0].selection.path}'s local changes`] : []),
28
+ ...(replaced.length > 1 ? [`replaces local changes in ${replaced.length} Skills`] : []),
29
+ ...(commands === 0 ? [] : [`runs ${commands} ${commands === 1 ? "command" : "commands"}`]),
30
+ ];
31
+ }
32
32
  export function updateModePreviewChrome(mode) {
33
33
  switch (mode.kind) {
34
34
  case "preview": {
35
35
  const count = mode.prepared.applicable.length;
36
- const question = `Apply ${count} ${count === 1 ? "update" : "updates"}? y/n`;
37
- return {
38
- title: "Update batch preview",
39
- questions: ["Scroll to end before confirming; n cancels.", question],
40
- footerRows: 2,
41
- };
36
+ if (count === 0)
37
+ return { title: "Update batch preview", consequence: "Nothing can be applied.", cancel: "[Enter/Esc] back" };
38
+ const action = mode.prepared.document.action;
39
+ return { title: "Update batch preview", action, consequence: [...previewEffects(mode.prepared), `${action}? y/n`].join(" · ") };
42
40
  }
43
41
  case "manual":
44
- return {
45
- title: "Manual App update",
46
- questions: ["Scroll to end before confirming; n cancels.", "Manual step done, check now? y/n"],
47
- footerRows: 2,
48
- };
42
+ return { title: "Manual App update", action: "Check now", consequence: "Manual step done, check now? y/n" };
49
43
  case "confirm-paths":
50
- return {
51
- title: "Confirm replacing non-canonical locations",
52
- questions: ["Scroll to end before confirming; n cancels.", "Replace exactly these paths? y/n"],
53
- footerRows: 2,
54
- };
44
+ return { title: "Confirm replacing non-canonical locations", action: "Replace these paths", consequence: "Replace exactly these paths? y/n" };
55
45
  default:
56
- return { title: "Updates", questions: ["Continue? y/n"] };
57
- }
58
- }
59
- export function updateModeQuestion(mode, reachedEnd) {
60
- if (mode.kind === "preview" && mode.prepared.applicable.length === 0) {
61
- return "Nothing can be applied. Enter or Esc to go back.";
46
+ return { title: "Updates" };
62
47
  }
63
- const chrome = updateModePreviewChrome(mode);
64
- return reachedEnd ? chrome.questions.at(-1) ?? "Continue? y/n" : chrome.questions[0] ?? "Scroll to end before confirming; n cancels.";
65
48
  }
@@ -1,8 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } 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 { useCallback, useEffect, useRef, useState } from "react";
4
5
  import { theme } from "./theme.js";
5
- import { previewViewport } from "./preview-window.js";
6
+ import { usePreviewGeometry } from "./preview-window.js";
6
7
  import { computeWindow, useListHeight } from "./window.js";
7
8
  import { BROWSE } from "./updates-mode.js";
8
9
  import { defaultExpanded } from "./tree.js";
@@ -11,16 +12,21 @@ import { useUpdatesFlow } from "./updates-flow.js";
11
12
  import { handleUpdatesKey } from "./updates-keys.js";
12
13
  import { entryId, viewOf } from "./updates-model.js";
13
14
  import { CheckStatus, EntryLine, ModePanel, UpdatesFooter } from "./updates-panel.js";
14
- import { previewLineText } from "./updates.js";
15
+ import { lineText } from "./preview-render.js";
15
16
  import { updateModePreviewChrome, updateModePreviewLines } from "./updates-preview.js";
16
17
  import { useKeys } from "./keys.js";
17
18
  import { BorderPanel, Working } from "./panel-parts.js";
19
+ /** Whether the working panel is drawn. A preview owns the screen: its height was computed without the panel, so the two never share it. */
20
+ export function showsWorkingPanel(state, mode) {
21
+ if (mode.kind === "preview" || mode.kind === "manual" || mode.kind === "confirm-paths")
22
+ return false;
23
+ return state.status === "loading" || mode.kind === "busy";
24
+ }
18
25
  const NO_ENVIRONMENT = {};
19
26
  /** How often the age of the check is redrawn. */
20
27
  const CLOCK_TICK_MS = 10_000;
21
28
  export function UpdatesView({ operations, environment, onCapturingChange, listHeight, now }) {
22
29
  const height = useListHeight(listHeight, 9);
23
- const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
24
30
  const env = environment ?? NO_ENVIRONMENT;
25
31
  const clock = now ?? Date.now;
26
32
  // Scope, cursor, fold and selection are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
@@ -73,7 +79,7 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
73
79
  const { state, load, startPreview, apply } = useUpdatesFlow({
74
80
  operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, modeRef, setMessage,
75
81
  });
76
- // Entering the view refreshes the Git sources first, like `r`.
82
+ // Entering the view refreshes the Git sources first, like `r`, but may reuse a recent refresh (ADR 0004).
77
83
  useEffect(() => {
78
84
  void load(true);
79
85
  }, [load]);
@@ -81,10 +87,8 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
81
87
  dataRef.current = data;
82
88
  const { shown, updatable, unknown, upToDate, entries } = viewOf(data, scope, showUnknown, expanded);
83
89
  const index = Math.min(cursor, Math.max(entries.length - 1, 0));
84
- const previewGeometry = (current) => {
85
- const cursor = "cursor" in current ? current.cursor : 0;
86
- return previewViewport(updateModePreviewLines(current).map(previewLineText), cursor, terminalRows || 24, terminalColumns || 80, updateModePreviewChrome(current));
87
- };
90
+ const geometry = usePreviewGeometry();
91
+ const previewGeometry = (current) => geometry(updateModePreviewLines(current).map(lineText), "cursor" in current ? current.cursor : 0, updateModePreviewChrome(current));
88
92
  const keyContext = {
89
93
  height,
90
94
  now: clock,
@@ -105,7 +109,7 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
105
109
  recheck: () => {
106
110
  setMessage(undefined);
107
111
  setShowUnknown(false);
108
- void load(true);
112
+ void load(true, true);
109
113
  },
110
114
  startPreview: () => void startPreview(),
111
115
  setMode,
@@ -118,6 +122,6 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
118
122
  const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
119
123
  const showingBrowse = mode.kind === "browse";
120
124
  // One indicator for the whole operation: a check's own phase label, else the busy mode's.
121
- const working = state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
125
+ const working = !showsWorkingPanel(state, mode) ? undefined : state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
122
126
  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: checked, view: view, showUnknown: showUnknown }, entryId(entry)))) : null, showingBrowse ? _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }) : null, _jsx(ModePanel, { mode: mode })] }));
123
127
  }