@iseoane/agent-depot 0.5.2 → 0.6.1

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 +47 -0
  2. package/README.es.md +137 -34
  3. package/README.md +120 -29
  4. package/dist/src/app-flow.js +56 -24
  5. package/dist/src/profile-export.js +72 -9
  6. package/dist/src/profile-import.js +3 -2
  7. package/dist/src/profile.js +71 -7
  8. package/dist/src/tui/app-actions.js +82 -33
  9. package/dist/src/tui/app.js +19 -14
  10. package/dist/src/tui/catalog-browse.js +6 -1
  11. package/dist/src/tui/catalog-panel.js +11 -11
  12. package/dist/src/tui/catalog-state.js +28 -0
  13. package/dist/src/tui/catalog-tree.js +22 -5
  14. package/dist/src/tui/catalog-view.js +59 -16
  15. package/dist/src/tui/file-picker-model.js +87 -0
  16. package/dist/src/tui/file-picker.js +142 -0
  17. package/dist/src/tui/header-layout.js +33 -0
  18. package/dist/src/tui/installations-panel.js +3 -3
  19. package/dist/src/tui/installations-rows.js +20 -5
  20. package/dist/src/tui/installations-tree.js +4 -7
  21. package/dist/src/tui/installations-view.js +15 -6
  22. package/dist/src/tui/installations.js +9 -7
  23. package/dist/src/tui/manage-panel.js +7 -6
  24. package/dist/src/tui/mode-keys.js +9 -0
  25. package/dist/src/tui/package-rows.js +17 -5
  26. package/dist/src/tui/panel-parts.js +119 -6
  27. package/dist/src/tui/preview-window.js +51 -0
  28. package/dist/src/tui/profile-view.js +394 -136
  29. package/dist/src/tui/sources-mode.js +3 -1
  30. package/dist/src/tui/sources-recipes.js +50 -19
  31. package/dist/src/tui/sources-view.js +50 -14
  32. package/dist/src/tui/text-wrap.js +53 -0
  33. package/dist/src/tui/theme.js +14 -7
  34. package/dist/src/tui/updates-flow.js +2 -2
  35. package/dist/src/tui/updates-keys.js +34 -5
  36. package/dist/src/tui/updates-model.js +2 -0
  37. package/dist/src/tui/updates-panel.js +40 -15
  38. package/dist/src/tui/updates-preview.js +65 -0
  39. package/dist/src/tui/updates-view.js +17 -2
  40. package/dist/src/tui/updates.js +14 -5
  41. package/dist/src/tui/window.js +8 -6
  42. package/package.json +6 -6
  43. package/skills/doctor-md-agents/README.md +3 -1
  44. package/skills/doctor-md-skill/README.md +2 -2
@@ -9,8 +9,10 @@ export function modeReducer(mode, event) {
9
9
  return mode.kind === "input" ? { kind: "input", value: event.value } : mode;
10
10
  case "confirm":
11
11
  return event.action === "refresh"
12
- ? { kind: "confirm", action: "refresh", sources: event.sources }
12
+ ? { kind: "confirm", action: "refresh", sources: event.sources, cursor: 0 }
13
13
  : { kind: "confirm", action: "remove", source: event.source };
14
+ case "preview-cursor":
15
+ return mode.kind === "confirm" && mode.action === "refresh" ? { ...mode, cursor: event.cursor } : mode;
14
16
  case "busy":
15
17
  return { kind: "busy" };
16
18
  case "done":
@@ -7,16 +7,31 @@ import { BUILT_IN_SOURCE } from "../sources.js";
7
7
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
8
  import { ActionPanel } from "./catalog-panel.js";
9
9
  import { hasUnmanagedCopy, isInstalled, loadInstalledSkills } from "./catalog-installs.js";
10
- import { appName, appStepLines } from "./app-actions.js";
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 { PreviewConfirm } from "./panel-parts.js";
14
- import { answerYesNo } from "./mode-keys.js";
13
+ import { BorderPanel, DecisionPrompt, PreviewConfirm } from "./panel-parts.js";
14
+ import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
15
+ import { previewViewport } from "./preview-window.js";
15
16
  import { theme } from "./theme.js";
17
+ function recipeApprovalChrome(entry) {
18
+ return {
19
+ title: "Recipe approval · does not install",
20
+ questions: [`Approve ${appName(entry)}? y/n · recipe approval does not install the App.`, "Scroll to end before y; n cancels."],
21
+ footerRows: 2,
22
+ };
23
+ }
24
+ function uninstallChrome(mode) {
25
+ return {
26
+ title: mode.kind === "uninstall-manual" ? "App manual uninstall" : "App uninstall confirmation",
27
+ 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."],
28
+ footerRows: 2,
29
+ };
30
+ }
16
31
  /**
17
32
  * Recipe loading and captured actions; Sources owns the single list and cursor.
18
33
  */
19
- export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, }) {
34
+ export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, terminalRows, terminalColumns, }) {
20
35
  const homeDirectory = environment?.homeDirectory;
21
36
  const appEnvironment = environment?.appEnvironment;
22
37
  const apps = useMemo(() => createAppOperations({
@@ -48,13 +63,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
48
63
  if (mounted.current) {
49
64
  setMode({
50
65
  kind: "approve",
51
- entry, removeAfterApproval,
52
- lines: [
53
- `File: ${entry.file}`,
54
- `Platform: ${entry.recipe?.platform ?? "all"}`,
55
- `Status: ${status}`,
56
- ...steps.flatMap(step => [step.step, ...appStepLines(step)]),
57
- ],
66
+ entry, removeAfterApproval, cursor: 0,
67
+ lines: appRecipePreviewLines(entry, status, steps),
58
68
  });
59
69
  }
60
70
  }
@@ -183,7 +193,13 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
183
193
  }
184
194
  const cancel = () => setMode({ kind: "browse" });
185
195
  if (current.kind === "approve") {
186
- answerYesNo(input, key, () => {
196
+ const viewport = previewViewport(current.lines, current.cursor, terminalRows, terminalColumns, recipeApprovalChrome(current.entry));
197
+ const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
198
+ if (cursor !== undefined)
199
+ setMode({ ...current, cursor });
200
+ else if (input === "n" || key.escape)
201
+ cancel();
202
+ else if (input === "y" && viewport.reachedEnd) {
187
203
  setMode({ kind: "busy" });
188
204
  void apps.approve(current.entry).then(async () => {
189
205
  await reload();
@@ -199,7 +215,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
199
215
  cancel();
200
216
  }
201
217
  });
202
- }, cancel);
218
+ }
203
219
  return true;
204
220
  }
205
221
  if (current.kind === "uninstall-question") {
@@ -207,7 +223,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
207
223
  setMode({ kind: "busy" });
208
224
  void apps.planLifecycle(current.removal.entry, "uninstall").then(plan => {
209
225
  if (mounted.current)
210
- setMode({ kind: !plan.argv && plan.manual ? "uninstall-manual" : "uninstall-confirm", removal: current.removal, plan });
226
+ setMode({ kind: !plan.argv && plan.manual ? "uninstall-manual" : "uninstall-confirm", removal: current.removal, plan, cursor: 0 });
211
227
  }, error => { if (mounted.current) {
212
228
  setError(errorText(error));
213
229
  cancel();
@@ -216,7 +232,15 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
216
232
  return true;
217
233
  }
218
234
  if (current.kind === "uninstall-confirm" || current.kind === "uninstall-manual") {
219
- answerYesNo(input, key, () => { void uninstall(current); }, cancel);
235
+ const lines = appStepLines(current.plan);
236
+ const viewport = previewViewport(lines, current.cursor, terminalRows, terminalColumns, uninstallChrome(current));
237
+ const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
238
+ if (cursor !== undefined)
239
+ setMode({ ...current, cursor });
240
+ else if (input === "n" || key.escape)
241
+ cancel();
242
+ else if (input === "y" && viewport.reachedEnd)
243
+ void uninstall(current);
220
244
  return true;
221
245
  }
222
246
  if (current.kind === "remove") {
@@ -272,8 +296,15 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
272
296
  }
273
297
  }, [apps, mounted]);
274
298
  useEffect(() => { void reload(); }, [reload]);
275
- 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(Text, { children: ["1 With the AI skill · install if missing, then copy the prompt\n", "2 Manual · write a recipe using the schema\nn or Esc closes"] })) : null, mode.kind === "manual" ? (_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory, "\n", "Schema: agent-depot app schema \u00B7 n or Esc closes"] })) : null, mode.kind === "prompt" ? (_jsxs(Text, { children: ["Use the agent-depot-apprecipe skill to create the recipe for <app>", "\n", "Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(Text, { children: "Working..." }) : null, mode.kind === "approve" ? (_jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` })) : null, mode.kind === "uninstall-question" ? (_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", ". Uninstall it before removing the recipe? y/n (n cancels removal)"] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (_jsx(PreviewConfirm, { lines: appStepLines(mode.plan), question: mode.kind === "uninstall-manual"
276
- ? "Complete the manual uninstall, then check now? y/n"
277
- : `Uninstall ${appName(mode.removal.entry)}? y/n` })) : null, mode.kind === "remove" ? (_jsx(PreviewConfirm, { 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] }));
278
- return { rows, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory };
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" ? (() => {
300
+ const chrome = recipeApprovalChrome(mode.entry);
301
+ const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
302
+ return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
303
+ })() : 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" ? (() => {
304
+ const chrome = uninstallChrome(mode);
305
+ const lines = appStepLines(mode.plan);
306
+ const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
307
+ return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
308
+ })() : 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" };
279
310
  }
@@ -1,11 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { useCallback, useEffect, useReducer, useRef, useState } from "react";
4
4
  import { initialMode, modeReducer } from "./sources-mode.js";
5
5
  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";
10
+ import { previewViewport } from "./preview-window.js";
11
+ import { movePreviewCursor } from "./mode-keys.js";
9
12
  /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
10
13
  export function sourceRowHints(row) {
11
14
  if (row === undefined)
@@ -37,7 +40,18 @@ async function refreshEach(operations, targets) {
37
40
  function errorText(error) {
38
41
  return error instanceof Error ? error.message : String(error);
39
42
  }
43
+ function refreshPreviewLines(sources) {
44
+ return sources.map(source => `Source: refresh Git Source ${source.id} from ${source.url}`);
45
+ }
46
+ function refreshViewport(mode, rows, columns) {
47
+ return previewViewport(refreshPreviewLines(mode.sources), mode.cursor, rows, columns, {
48
+ title: "Refresh Git Sources",
49
+ questions: ["Scroll to end; n cancels.", "Refresh these Sources? y/n"],
50
+ footerRows: 2,
51
+ });
52
+ }
40
53
  export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange }) {
54
+ const { columns, rows: terminalRows } = useWindowSize();
41
55
  const height = useListHeight(listHeight, 9);
42
56
  const [state, setState] = useState({ status: "loading" });
43
57
  // The highlight follows the source id; the index is only the fallback once that source is gone.
@@ -111,6 +125,8 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
111
125
  const row = rows[resolveIndex(rows, selectionRef.current)];
112
126
  return row?.kind === "app" ? row.recipe.entry.file : undefined;
113
127
  },
128
+ terminalRows: terminalRows || 24,
129
+ terminalColumns: columns || 80,
114
130
  });
115
131
  const rows = [
116
132
  ...sources.map((source) => ({ id: source.id, kind: "skills", source })),
@@ -119,7 +135,9 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
119
135
  rowsRef.current = rows;
120
136
  const selected = resolveIndex(rows, selection);
121
137
  const selectedRow = rows[selected];
122
- const hints = sourceRowHints(selectedRow);
138
+ const hints = mode.kind === "confirm" && mode.action === "refresh"
139
+ ? "↑↓/Pg page · y refresh · n cancel"
140
+ : sourceRowHints(selectedRow);
123
141
  useEffect(() => { onHintsChange?.(hints); }, [hints, onHintsChange]);
124
142
  const select = (index) => {
125
143
  setMessage(undefined);
@@ -221,6 +239,16 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
221
239
  return;
222
240
  }
223
241
  if (mode.kind === "confirm") {
242
+ if (mode.action === "refresh") {
243
+ const viewport = refreshViewport(mode, terminalRows || 24, columns || 80);
244
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
245
+ if (cursor !== undefined) {
246
+ dispatch({ type: "preview-cursor", cursor });
247
+ return;
248
+ }
249
+ if (input === "y" && !viewport.reachedEnd)
250
+ return;
251
+ }
224
252
  if (input === "y") {
225
253
  if (mode.action === "refresh") {
226
254
  const { sources: targets } = mode;
@@ -296,28 +324,36 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
296
324
  // Sources removed since they were marked no longer count.
297
325
  const selectedCount = sources.filter((source) => marked.has(source.id)).length;
298
326
  if (state.status === "loading")
299
- return _jsx(Text, { children: "Loading sources..." });
327
+ return _jsx(BorderPanel, { title: "Sources", children: _jsx(FieldLine, { label: "Status", value: "Loading sources..." }) });
300
328
  if (state.status === "error")
301
- return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
329
+ return _jsx(BorderPanel, { title: "Source error", children: _jsx(Text, { color: theme.error, children: state.message }) });
302
330
  const window = computeWindow(rows.length, selected, height);
303
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: [" ", "ID KIND LOCATION / STATUS"] }), builtin ? _jsxs(Text, { color: theme.muted, children: [" ", builtin.id, " skills 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) => {
331
+ if (mode.kind === "confirm")
332
+ return _jsx(Prompt, { mode: mode, terminalRows: terminalRows || 24, terminalColumns: columns || 80 });
333
+ if (recipes.capturing)
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) => {
304
336
  const index = window.start + offset;
305
337
  const id = row.kind === "skills" ? row.source.id : row.recipe.entry.recipe?.name ?? row.recipe.entry.file.split(/[\\/]/u).pop();
306
- return (_jsxs(Text, { ...rowStyle(index === selected), children: [index === selected ? "> " : " ", row.kind === "skills" ? (marked.has(row.id) ? "[x] " : "[ ] ") : " ", id, " ", row.kind, " ", row.kind === "skills" ? row.source.url : `${row.recipe.entry.file} · ${row.recipe.status}`] }, row.id));
307
- }), 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(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) : null] }));
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
+ ? _jsx(Text, { color: theme.value, children: row.source.url })
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] }));
308
342
  }
309
- function Prompt({ mode }) {
343
+ function Prompt({ mode, terminalRows = 24, terminalColumns = 80 }) {
310
344
  if (mode.kind === "add-kind")
311
345
  return _jsx(Text, { children: "Add: 1 Git Source (skills + Packages) \u00B7 2 App recipe (Esc cancel)" });
312
346
  if (mode.kind === "input")
313
347
  return _jsxs(Text, { children: ["URL: ", mode.value] });
314
348
  if (mode.kind === "busy")
315
- return _jsx(Text, { children: "Working..." });
349
+ return _jsx(BorderPanel, { title: "Sources", children: _jsx(FieldLine, { label: "Status", value: "Working..." }) });
316
350
  if (mode.kind === "confirm") {
317
- const lines = mode.action === "refresh"
318
- ? mode.sources.map((source) => `refresh Git Source ${source.id} from ${source.url}`)
319
- : [`remove Git Source ${mode.source.id} from ${mode.source.url}`];
320
- return _jsxs(Text, { children: [lines.join("\n"), "\n", "[y/n]"] });
351
+ return mode.action === "refresh" ? (() => {
352
+ const viewport = refreshViewport(mode, terminalRows, terminalColumns);
353
+ 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
354
+ ? _jsx(DecisionPrompt, { children: "Refresh these Sources?" })
355
+ : _jsx(Text, { color: theme.legend, children: "Scroll to end; n cancels." })] }));
356
+ })() : (_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?" })] }));
321
357
  }
322
358
  return null;
323
359
  }
@@ -0,0 +1,53 @@
1
+ import stringWidth from "string-width";
2
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ /** Split text on grapheme boundaries so wide and combining characters wrap at `width` columns. */
4
+ export function wrapText(value, width) {
5
+ const lines = [];
6
+ let line = "";
7
+ let lineWidth = 0;
8
+ for (const { segment } of graphemes.segment(value)) {
9
+ const segmentWidth = stringWidth(segment);
10
+ if (line && lineWidth + segmentWidth > width) {
11
+ lines.push(line);
12
+ line = "";
13
+ lineWidth = 0;
14
+ }
15
+ line += segment;
16
+ lineWidth += segmentWidth;
17
+ }
18
+ return [...lines, line];
19
+ }
20
+ /** `lines` capped to `maxRows`, with the last visible row shortened to `width` plus an ellipsis. */
21
+ export function fitLines(lines, maxRows, width) {
22
+ if (maxRows <= 0)
23
+ return [];
24
+ if (lines.length <= maxRows)
25
+ return lines;
26
+ const visible = lines.slice(0, maxRows);
27
+ let last = "";
28
+ let used = 0;
29
+ for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
30
+ const segmentWidth = stringWidth(segment);
31
+ if (used + segmentWidth > width - 1)
32
+ break;
33
+ last += segment;
34
+ used += segmentWidth;
35
+ }
36
+ visible[maxRows - 1] = `${last}…`;
37
+ return visible;
38
+ }
39
+ /** One line shortened to `width` columns plus an ellipsis, for single-row labels. */
40
+ export function clipText(value, width) {
41
+ if (stringWidth(value) <= width)
42
+ return value;
43
+ let last = "";
44
+ let used = 0;
45
+ for (const { segment } of graphemes.segment(value)) {
46
+ const segmentWidth = stringWidth(segment);
47
+ if (used + segmentWidth > Math.max(width - 1, 0))
48
+ break;
49
+ last += segment;
50
+ used += segmentWidth;
51
+ }
52
+ return `${last}…`;
53
+ }
@@ -1,19 +1,26 @@
1
1
  /** Central xeo palette for the TUI; views take every colour from here. */
2
2
  export const theme = {
3
- /** Title and active tab. */
3
+ /** Panel titles and active labels use Xeo accent pink. */
4
+ title: "#ff2e9a",
4
5
  accent: "#ff2e9a",
5
- /** Group nodes and selection text. */
6
+ /** Borders and group nodes. */
7
+ border: "#7aa2f7",
6
8
  group: "#7aa2f7",
7
- /** Markers such as hosts and versions. */
8
- marker: "#449dab",
9
+ /** Field names and markers such as hosts. */
10
+ field: "#7aa2f7",
11
+ marker: "#7aa2f7",
12
+ /** Primary and emphasized values. */
13
+ text: "#a9b1d6",
14
+ value: "#c0caf5",
15
+ version: "#ff9e64",
9
16
  success: "#9ece6a",
10
17
  warning: "#e0af68",
11
18
  error: "#f7768e",
12
- /** Hints and footer. */
19
+ /** Legends, hints and footer. */
20
+ legend: "#565f89",
13
21
  muted: "#414868",
14
- /** Inactive tabs. */
22
+ /** Inactive tabs and selected-row background. */
15
23
  inactive: "#565f89",
16
- /** Background of the selected row. */
17
24
  selection: "#292e42",
18
25
  };
19
26
  /** Columns assumed when the terminal width is unknown. */
@@ -49,7 +49,7 @@ export function useUpdatesFlow(inputs) {
49
49
  try {
50
50
  const prepared = await prepareUpdates(current, selected);
51
51
  if (mounted.current)
52
- setMode({ kind: "preview", prepared });
52
+ setMode({ kind: "preview", prepared, cursor: 0 });
53
53
  }
54
54
  catch (error) {
55
55
  if (!mounted.current)
@@ -72,7 +72,7 @@ export function useUpdatesFlow(inputs) {
72
72
  resolve(done);
73
73
  };
74
74
  pendingAnswer.current = answer;
75
- setMode({ kind: "manual", plan, reason, answer });
75
+ setMode({ kind: "manual", plan, reason, answer, cursor: 0 });
76
76
  }));
77
77
  result = { kind: outcome.failed === 0 ? "ok" : "error", lines: outcome.lines };
78
78
  }
@@ -1,4 +1,4 @@
1
- import { answerYesNo } from "./mode-keys.js";
1
+ import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
2
2
  import { pageStep } from "./window.js";
3
3
  const SCOPE_KEYS = new Map([["t", "all"], ["p", "project"], ["g", "user-global"]]);
4
4
  /** Moves the cursor with the arrows, j/k and paging; returns whether the key was one of them. */
@@ -61,18 +61,28 @@ function handleBrowseKey(context, input, key) {
61
61
  context.changeScope(scope);
62
62
  else if (input === "r")
63
63
  context.recheck();
64
+ else if (input === "u")
65
+ context.startPreview();
64
66
  else
65
67
  foldKey(context, snapshot, key);
66
68
  }
67
69
  function handlePreviewKey(context, mode, input, key) {
70
+ const metrics = context.previewMetrics(mode);
71
+ const cursor = movePreviewCursor(mode.cursor, metrics.length, input, key, metrics.pageSize);
72
+ if (cursor !== undefined) {
73
+ context.setMode({ ...mode, cursor });
74
+ return;
75
+ }
68
76
  if (mode.prepared.applicable.length === 0) {
69
77
  if (key.escape || key.return || input === "n")
70
78
  context.cancel();
71
79
  return;
72
80
  }
73
81
  answerYesNo(input, key, () => {
82
+ if (!context.previewAtEnd(mode))
83
+ return;
74
84
  if (mode.prepared.nonCanonicalPaths.length > 0)
75
- context.setMode({ ...mode, kind: "confirm-paths" });
85
+ context.setMode({ ...mode, kind: "confirm-paths", cursor: 0 });
76
86
  else
77
87
  context.apply(mode.prepared, []);
78
88
  }, context.cancel);
@@ -80,8 +90,16 @@ function handlePreviewKey(context, mode, input, key) {
80
90
  /** Handles a key in the current Updates mode; `busy` ignores keys. */
81
91
  export function handleUpdatesKey(context, mode, input, key) {
82
92
  switch (mode.kind) {
83
- case "manual":
93
+ case "manual": {
94
+ const metrics = context.previewMetrics(mode);
95
+ const cursor = movePreviewCursor(mode.cursor, metrics.length, input, key, metrics.pageSize);
96
+ if (cursor !== undefined) {
97
+ context.setMode({ ...mode, cursor });
98
+ return;
99
+ }
84
100
  answerYesNo(input, key, () => {
101
+ if (!context.previewAtEnd(mode))
102
+ return;
85
103
  context.setMode({ kind: "busy", label: "Checking App version..." });
86
104
  mode.answer(true);
87
105
  }, () => {
@@ -89,15 +107,26 @@ export function handleUpdatesKey(context, mode, input, key) {
89
107
  mode.answer(false);
90
108
  });
91
109
  return;
110
+ }
92
111
  case "browse":
93
112
  handleBrowseKey(context, input, key);
94
113
  return;
95
114
  case "preview":
96
115
  handlePreviewKey(context, mode, input, key);
97
116
  return;
98
- case "confirm-paths":
99
- answerYesNo(input, key, () => context.apply(mode.prepared, mode.prepared.nonCanonicalPaths), context.cancel);
117
+ case "confirm-paths": {
118
+ const metrics = context.previewMetrics(mode);
119
+ const cursor = movePreviewCursor(mode.cursor, metrics.length, input, key, metrics.pageSize);
120
+ if (cursor !== undefined) {
121
+ context.setMode({ ...mode, cursor });
122
+ return;
123
+ }
124
+ answerYesNo(input, key, () => {
125
+ if (context.previewAtEnd(mode))
126
+ context.apply(mode.prepared, mode.prepared.nonCanonicalPaths);
127
+ }, context.cancel);
100
128
  return;
129
+ }
101
130
  default:
102
131
  return;
103
132
  }
@@ -5,9 +5,11 @@ export const PHASE_LABEL = { refreshing: REFRESHING, checking: CHECKING };
5
5
  export function describeUnknownReasons(rows) {
6
6
  const newer = rows.filter((row) => /^installed by a newer agent depot/i.test(row.reason)).length;
7
7
  const pinned = rows.filter((row) => /^pinned to agent depot/i.test(row.reason)).length;
8
+ const localChanges = rows.filter((row) => row.kind === "package" && /local modifications|local changes/i.test(row.reason)).length;
8
9
  const parts = [
9
10
  ...(newer > 0 ? [`${newer} installed by a newer Agent Depot`] : []),
10
11
  ...(pinned > 0 ? [`${pinned} pinned to another Agent Depot`] : []),
12
+ ...(localChanges > 0 ? [`${localChanges} Package update comparison unavailable because the checkout has local changes`] : []),
11
13
  ];
12
14
  return parts.length === 0 ? "" : ` (${parts.join(", ")})`;
13
15
  }
@@ -1,13 +1,30 @@
1
- import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { rowStyle, theme } from "./theme.js";
4
+ import { BorderPanel, FieldLine, PreviewConfirm } from "./panel-parts.js";
5
+ import { previewViewport } from "./preview-window.js";
4
6
  import { CHECKING, describeRefresh, describeUnknownReasons, REFRESHING } from "./updates-model.js";
5
- import { formatAgo } from "./updates.js";
6
- import { PreviewLines } from "./panel-parts.js";
7
+ import { formatAgo, previewLineText } from "./updates.js";
8
+ import { updateModePreviewChrome, updateModePreviewLines, updateModeQuestion } from "./updates-preview.js";
9
+ function updateResultField(line, defaultOk) {
10
+ if (line.startsWith("Update summary: ")) {
11
+ const successful = /^Update summary: \d+ updated, 0 failed$/.test(line);
12
+ return { ok: successful, label: "Update summary:", value: line.slice("Update summary: ".length) };
13
+ }
14
+ if (line.startsWith("Updated "))
15
+ return { ok: true, label: "Updated", value: line.slice("Updated ".length) };
16
+ if (line.startsWith("Failed "))
17
+ return { ok: false, label: "Failed", value: line.slice("Failed ".length) };
18
+ if (line.startsWith("Cancelled "))
19
+ return { ok: false, label: "Cancelled", value: line.slice("Cancelled ".length) };
20
+ if (line === "Update cancelled")
21
+ return { ok: true, label: undefined, value: line };
22
+ return { ok: defaultOk, label: undefined, value: line };
23
+ }
7
24
  /** What the last check found out: its age, how the sources were refreshed, and what failed. */
8
25
  export function CheckStatus({ data, now }) {
9
26
  const errors = data.results.filter((result) => result.error !== undefined);
10
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["checked ", formatAgo(now - data.checkedAt), " \u00B7 ", describeRefresh(data)] }), data.refresh.failed.map((failure) => (_jsxs(Text, { color: theme.warning, children: ["refresh failed for ", failure.source, ": ", failure.reason] }, failure.source))), data.appError ? _jsxs(Text, { color: theme.error, children: ["Error (Apps): ", data.appError] }) : null, data.packageError ? _jsxs(Text, { color: theme.error, children: ["Error (Packages): ", data.packageError] }) : null, errors.map((result) => _jsxs(Text, { color: theme.error, children: ["Error (", result.scope, "): ", result.error] }, result.scope))] }));
27
+ return (_jsxs(BorderPanel, { title: "Update check", children: [_jsxs(Text, { color: theme.field, children: ["checked ", _jsx(Text, { color: theme.value, children: formatAgo(now - data.checkedAt) }), _jsx(Text, { color: theme.legend, children: " \u00B7 " }), _jsx(Text, { color: theme.value, children: describeRefresh(data) })] }), data.refresh.failed.map((failure) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: "[WARN] " }), _jsx(FieldLine, { label: "Refresh failed for", value: `${failure.source}: ${failure.reason}` })] }, failure.source))), data.appError ? _jsxs(Text, { children: [_jsx(Text, { color: theme.error, children: "[FAIL] " }), _jsx(FieldLine, { label: "Error (Apps):", value: data.appError })] }) : null, data.packageError ? _jsxs(Text, { children: [_jsx(Text, { color: theme.error, children: "[FAIL] " }), _jsx(FieldLine, { label: "Error (Packages):", value: data.packageError })] }) : null, errors.map((result) => _jsxs(Text, { children: [_jsx(Text, { color: theme.error, children: "[FAIL] " }), _jsx(FieldLine, { label: `Error (${result.scope}):`, value: result.error ?? "" })] }, result.scope))] }));
11
28
  }
12
29
  /** One line of the list: an update, the fold of unassessable items, or one of those when unfolded. */
13
30
  export function EntryLine({ entry, selected, checked, view, showUnknown }) {
@@ -17,28 +34,36 @@ export function EntryLine({ entry, selected, checked, view, showUnknown }) {
17
34
  }
18
35
  const { row } = entry;
19
36
  if (entry.kind === "unknown") {
20
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...rowStyle(selected), children: [cursor, " ", row.path, " ", row.scope] }), _jsxs(Text, { color: theme.muted, children: [" ", row.reason] })] }));
37
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...rowStyle(selected), children: [cursor, " ", _jsx(Text, { color: theme.field, children: row.path }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: row.scope })] }), _jsxs(Text, { color: theme.muted, children: [" ", row.kind === "package" && /local modifications|local changes/i.test(row.reason)
38
+ ? `Update comparison unavailable: ${row.reason}`
39
+ : row.reason] })] }));
21
40
  }
22
- return (_jsxs(Text, { ...rowStyle(selected), children: [cursor, checked ? "[x]" : "[ ]", " ", row.path, " ", row.scope, " ", row.policy, " ", row.installed, " ", "->", " ", row.available] }));
41
+ return (_jsxs(Text, { ...rowStyle(selected), children: [cursor, checked ? "[x]" : "[ ]", " ", _jsx(Text, { color: theme.field, children: row.path }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: row.scope }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.field, children: row.policy }), _jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: theme.value, children: row.installed }), _jsx(Text, { color: theme.legend, children: " -> " }), _jsx(Text, { color: theme.value, children: row.available })] }));
23
42
  }
24
43
  /** Under the list: the window indicator, what is up to date or selected, and the last result. */
25
44
  export function UpdatesFooter({ indicator, upToDate, updatableCount, checkedCount, message }) {
26
- return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, upToDate > 0 ? _jsxs(Text, { color: theme.muted, children: [upToDate, " up to date"] }) : null, updatableCount > 0 ? _jsxs(Text, { color: theme.muted, children: [checkedCount, " selected"] }) : null, message ? (_jsx(Box, { flexDirection: "column", children: message.lines.map((line, position) => _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: line }, position)) })) : null] }));
45
+ return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, upToDate > 0 ? _jsxs(Text, { color: theme.muted, children: [upToDate, " up to date"] }) : null, updatableCount > 0 ? _jsxs(Text, { color: theme.muted, children: [checkedCount, " selected"] }) : null, message ? (_jsx(BorderPanel, { title: "Update result", children: message.lines.map((line, position) => {
46
+ const result = updateResultField(line, message.kind === "ok");
47
+ return _jsxs(Text, { children: [_jsxs(Text, { color: result.ok ? theme.success : theme.error, children: ["[", result.ok ? "OK" : "FAIL", "] "] }), result.label === undefined ? _jsx(Text, { color: theme.value, children: result.value }) : _jsx(FieldLine, { label: result.label, value: result.value })] }, position);
48
+ }) })) : null] }));
49
+ }
50
+ function CapturedUpdatePreview({ mode }) {
51
+ const { rows, columns } = useWindowSize();
52
+ const lines = updateModePreviewLines(mode).map(previewLineText);
53
+ const chrome = updateModePreviewChrome(mode);
54
+ const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
55
+ return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: updateModeQuestion(mode, viewport.reachedEnd), chrome: chrome }));
27
56
  }
28
- /** The prompt of the preview and confirmation modes; browsing and the check itself show nothing. */
29
57
  export function ModePanel({ mode }) {
30
58
  switch (mode.kind) {
31
59
  case "manual":
32
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["App: ", mode.plan.entry.recipe.name] }), _jsxs(Text, { children: ["Environment: ", mode.plan.environment] }), _jsxs(Text, { children: ["Working directory: ", mode.plan.cwd] }), _jsxs(Text, { children: ["Manual: ", mode.plan.manual] }), mode.reason ? _jsx(Text, { color: theme.warning, children: mode.reason }) : null, _jsx(Text, { children: "Manual step done, check now? y/n" })] }));
60
+ return _jsx(CapturedUpdatePreview, { mode: mode });
33
61
  case "browse":
34
62
  return null;
35
63
  case "busy":
36
- return mode.label === REFRESHING || mode.label === CHECKING ? null : _jsx(Text, { children: mode.label });
64
+ return mode.label === REFRESHING || mode.label === CHECKING ? null : (_jsx(BorderPanel, { title: "Updates", children: _jsx(FieldLine, { label: "Status", value: mode.label }) }));
37
65
  case "preview":
38
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), _jsx(PreviewLines, { lines: mode.prepared.appFailures }), _jsx(PreviewLines, { lines: mode.prepared.packageFailures }), mode.prepared.failures.map(({ item, message }) => (_jsxs(Text, { color: theme.error, children: ["Preview failed for ", JSON.stringify(item.id), ": ", message] }, item.id))), mode.prepared.applicable.length === 0
39
- ? _jsx(Text, { children: "Nothing can be applied. Enter/Esc to go back" })
40
- : _jsxs(Text, { children: ["Apply ", mode.prepared.applicable.length, " ", mode.prepared.applicable.length === 1 ? "update" : "updates", "? y/n"] })] }));
41
66
  case "confirm-paths":
42
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "Confirm replacing non-canonical locations (outside .agents/skills and .claude/skills):" }), mode.prepared.nonCanonicalPaths.map((location, position) => _jsxs(Text, { color: theme.warning, children: [" [", location.scope, "] ", JSON.stringify(location.path)] }, position)), _jsx(Text, { children: "Replace exactly these paths? y/n" })] }));
67
+ return _jsx(CapturedUpdatePreview, { mode: mode });
43
68
  }
44
69
  }
@@ -0,0 +1,65 @@
1
+ import { previewText } from "./updates.js";
2
+ export function updateModePreviewLines(mode) {
3
+ switch (mode.kind) {
4
+ 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
+ ];
11
+ 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
+ ];
19
+ case "confirm-paths":
20
+ 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
+ })),
27
+ ];
28
+ default:
29
+ return [];
30
+ }
31
+ }
32
+ export function updateModePreviewChrome(mode) {
33
+ switch (mode.kind) {
34
+ case "preview": {
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
+ };
42
+ }
43
+ 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
+ };
49
+ 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
+ };
55
+ 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.";
62
+ }
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
+ }