@iseoane/agent-depot 0.5.2 → 0.6.0

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 (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.es.md +51 -29
  3. package/README.md +44 -25
  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/header-layout.js +33 -0
  16. package/dist/src/tui/installations-panel.js +3 -3
  17. package/dist/src/tui/installations-rows.js +20 -5
  18. package/dist/src/tui/installations-tree.js +4 -7
  19. package/dist/src/tui/installations-view.js +15 -6
  20. package/dist/src/tui/installations.js +9 -7
  21. package/dist/src/tui/manage-panel.js +7 -6
  22. package/dist/src/tui/mode-keys.js +9 -0
  23. package/dist/src/tui/package-rows.js +17 -5
  24. package/dist/src/tui/panel-parts.js +38 -6
  25. package/dist/src/tui/preview-window.js +48 -0
  26. package/dist/src/tui/profile-view.js +261 -42
  27. package/dist/src/tui/sources-mode.js +3 -1
  28. package/dist/src/tui/sources-recipes.js +50 -19
  29. package/dist/src/tui/sources-view.js +50 -14
  30. package/dist/src/tui/theme.js +14 -7
  31. package/dist/src/tui/updates-flow.js +2 -2
  32. package/dist/src/tui/updates-keys.js +34 -5
  33. package/dist/src/tui/updates-model.js +2 -0
  34. package/dist/src/tui/updates-panel.js +40 -15
  35. package/dist/src/tui/updates-preview.js +65 -0
  36. package/dist/src/tui/updates-view.js +17 -2
  37. package/dist/src/tui/updates.js +14 -5
  38. package/dist/src/tui/window.js +6 -4
  39. package/package.json +4 -3
@@ -1,52 +1,176 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { readFile } from "node:fs/promises";
3
- import { Box, Text } from "ink";
3
+ import { Box, Text, useWindowSize } from "ink";
4
+ import stringWidth from "string-width";
4
5
  import { useEffect, useMemo, useRef, useState } from "react";
5
6
  import { createAppOperations } from "../app-flow.js";
6
- import { buildProfileExport, describeProfilePackage, profileExportPreview, writeProfileExport, } from "../profile-export.js";
7
+ import { buildProfileExport, describeProfileExportExclusion, describeProfilePackage, describeProfileReplica, describeProfileSkill, profileExportPreview, SKILL_EXPORT_EXPLANATION, writeProfileExport, } from "../profile-export.js";
7
8
  import { applyProfileImport, buildProfileImport, } from "../profile-import.js";
8
- import { PROFILE_FORMAT } from "../profile.js";
9
+ import { PROFILE_FORMAT, PROFILE_FORMAT_V2, parseProfile } from "../profile.js";
10
+ import { selectionKey } from "../package-model.js";
9
11
  import { packageOperationsOf } from "./environment.js";
10
12
  import { useKeys } from "./keys.js";
11
- import { answerYesNo, moveCursor, toggleChoice } from "./mode-keys.js";
12
- import { ChecklistLines, PreviewConfirm, PreviewLines } from "./panel-parts.js";
13
+ import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
14
+ import { BorderPanel, PreviewConfirm, PreviewLines } from "./panel-parts.js";
15
+ import { theme } from "./theme.js";
13
16
  import { computeWindow, useListHeight } from "./window.js";
14
17
  function rowsFromExport(plan) {
15
18
  return [
16
19
  ...plan.profile.sources.map((value, index) => ({
17
- block: "sources", index, label: `Sources: ${value.url}`, selectable: true,
20
+ id: `sources:${value.url}`, block: "sources", index, ordinal: index + 1,
21
+ label: value.url, selectable: true,
18
22
  })),
19
23
  ...plan.profile.skills.map((value, index) => ({
20
- block: "skills", index, label: `Skills: ${value.path}`, selectable: true,
24
+ id: `skills:${JSON.stringify([value.source, value.path])}`, block: "skills", index, ordinal: index + 1,
25
+ label: describeProfileSkill(value), selectable: true,
21
26
  })),
22
27
  ...plan.profile.apps.map((value, index) => ({
23
- block: "apps", index, label: `Apps: ${value.name} (${value.platform ?? "all platforms"})`, selectable: true,
28
+ id: `apps:${JSON.stringify([value.name, value.platform ?? null])}`, block: "apps", index, ordinal: index + 1,
29
+ label: `App recipe ${value.name} (${value.platform ?? "all platforms"})`, selectable: true,
24
30
  })),
25
31
  ...plan.profile.packages.map((value, index) => ({
26
- block: "packages", index, label: `Packages: ${describeProfilePackage(value)}`, selectable: true,
32
+ id: `packages:${JSON.stringify(value)}`, block: "packages", index, ordinal: index + 1,
33
+ label: describeProfilePackage(value), selectable: true,
34
+ })),
35
+ ...plan.profile.replicas.map((value, index) => ({
36
+ id: `replicas:${JSON.stringify(value)}`, block: "replicas", index, ordinal: index + 1,
37
+ label: describeProfileReplica(value), selectable: true,
27
38
  })),
28
39
  ...plan.exclusions.map((item, index) => ({
29
- block: "excluded", index, label: `Excluded ${item.label}: ${item.reason}`, selectable: false,
40
+ id: `excluded:${index}`, block: "excluded", index, label: describeProfileExportExclusion(item), selectable: false,
30
41
  })),
42
+ { id: "skill-export-explanation", block: "excluded", index: -1,
43
+ label: SKILL_EXPORT_EXPLANATION, selectable: false },
31
44
  ...plan.warnings.filter(warning => warning.kind === "load").map((warning, index) => ({
32
- block: "excluded", index: plan.exclusions.length + index, label: warning.message, selectable: false,
45
+ id: `warning:${index}`, block: "excluded", index: plan.exclusions.length + index,
46
+ label: warning.message, selectable: false,
33
47
  })),
34
48
  ];
35
49
  }
50
+ function exportDisplayRows(rows, expanded) {
51
+ const choices = (block) => rows.filter(row => row.block === block);
52
+ const group = (id, label, depth, children) => {
53
+ if (!children.length)
54
+ return [];
55
+ const header = { kind: "group", id, label, depth };
56
+ return expanded.has(id) ? [header, ...children] : [header];
57
+ };
58
+ const sourceChildren = [
59
+ ...group("group:sources", `Source choices (${choices("sources").length})`, 1, choices("sources")),
60
+ ...group("group:skills", `Skills (${choices("skills").length})`, 1, choices("skills")),
61
+ ...group("group:apps", `Apps (${choices("apps").length})`, 1, choices("apps")),
62
+ ...group("group:packages", `Packages (${choices("packages").length})`, 1, choices("packages")),
63
+ ];
64
+ return [
65
+ ...group("group:sources-root", "Sources", 0, sourceChildren),
66
+ ...group("group:replicas", `Installed replicas (${choices("replicas").length})`, 0, choices("replicas")),
67
+ ...group("group:excluded", `Export notes (${choices("excluded").length})`, 0, choices("excluded")),
68
+ ];
69
+ }
70
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
71
+ function wrapListText(value, width) {
72
+ const lines = [];
73
+ let line = "";
74
+ let lineWidth = 0;
75
+ for (const { segment } of graphemes.segment(value)) {
76
+ const segmentWidth = stringWidth(segment);
77
+ if (line && lineWidth + segmentWidth > width) {
78
+ lines.push(line);
79
+ line = "";
80
+ lineWidth = 0;
81
+ }
82
+ line += segment;
83
+ lineWidth += segmentWidth;
84
+ }
85
+ return [...lines, line];
86
+ }
87
+ function fitListLines(lines, maxRows, width) {
88
+ if (maxRows <= 0)
89
+ return [];
90
+ if (lines.length <= maxRows)
91
+ return lines;
92
+ const visible = lines.slice(0, maxRows);
93
+ let last = "";
94
+ let used = 0;
95
+ for (const { segment } of graphemes.segment(visible[maxRows - 1])) {
96
+ const segmentWidth = stringWidth(segment);
97
+ if (used + segmentWidth > width - 1)
98
+ break;
99
+ last += segment;
100
+ used += segmentWidth;
101
+ }
102
+ visible[maxRows - 1] = `${last}…`;
103
+ return visible;
104
+ }
105
+ function physicalListWindow(rows, rowHeights, selected, height) {
106
+ if (!rows.length)
107
+ return { start: 0, end: 0, indicator: undefined, stickyGroup: false };
108
+ const cursor = Math.min(Math.max(selected, 0), rows.length - 1);
109
+ const build = (budget) => {
110
+ let start = cursor;
111
+ let end = cursor + 1;
112
+ let used = rowHeights[cursor] ?? 1;
113
+ while (start > 0 || end < rows.length) {
114
+ const previous = start > 0 ? rowHeights[start - 1] : Infinity;
115
+ const next = end < rows.length ? rowHeights[end] : Infinity;
116
+ if (used + Math.min(previous, next) > budget)
117
+ break;
118
+ if (previous <= next) {
119
+ start--;
120
+ used += previous;
121
+ }
122
+ else {
123
+ used += next;
124
+ end++;
125
+ }
126
+ }
127
+ return { start, end };
128
+ };
129
+ let range = build(height);
130
+ const needsStickyGroup = (start) => start > 0 && !("kind" in rows[start]) &&
131
+ (rowHeights[start] ?? 1) < height && rows.slice(0, start).some(row => "kind" in row);
132
+ if (needsStickyGroup(range.start))
133
+ range = build(Math.max(height - 1, 1));
134
+ const stickyGroup = needsStickyGroup(range.start);
135
+ const hidden = range.start > 0 || range.end < rows.length;
136
+ return { ...range, indicator: hidden ? `${range.start + 1}–${range.end} of ${rows.length}` : undefined, stickyGroup };
137
+ }
36
138
  /** The four import blocks in one indexed list, so the checklist and the confirmation share positions. */
37
139
  function planEntries(plan) {
38
140
  return [...plan.items, ...plan.packages];
39
141
  }
40
142
  function rowsFromPlan(plan) {
41
143
  return planEntries(plan).map((item, index) => ({
42
- block: item.block, index,
144
+ id: `${item.block}:${index}`, block: item.block, index, ordinal: index + 1,
43
145
  label: `${item.status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`,
44
146
  selectable: item.status === "add",
45
147
  }));
46
148
  }
149
+ function hasChosenResource(profile) {
150
+ return profile.sources.length + profile.skills.length + profile.apps.length + profile.packages.length > 0;
151
+ }
47
152
  function selectedPlanEntries(plan, selected) {
48
153
  return planEntries(plan).filter((item, index) => item.status !== "add" || selected.some(row => row.index === index));
49
154
  }
155
+ function selectedImportProfile(profile, entries) {
156
+ const sources = profile.sources.filter(value => entries.some(item => item.block === "sources" && item.value.url === value.url));
157
+ const skills = profile.skills.filter(value => entries.some(item => item.block === "skills" &&
158
+ item.value.path === value.path && JSON.stringify(item.value.source) === JSON.stringify(value.source)));
159
+ const apps = profile.apps.filter(value => entries.some(item => item.block === "apps" &&
160
+ item.value.name === value.name && item.value.platform === value.platform));
161
+ const packages = profile.packages.filter(value => entries.some(item => item.block === "packages" &&
162
+ selectionKey(item.value) === selectionKey(value)));
163
+ const replicas = profile.replicas.filter(replica => {
164
+ if (replica.kind === "skill")
165
+ return skills.some(skill => skill.path === replica.path &&
166
+ JSON.stringify(skill.source) === JSON.stringify(replica.source));
167
+ if (replica.kind === "app")
168
+ return apps.some(app => app.name === replica.name && app.platform === replica.platform);
169
+ return packages.some(selection => selectionKey(selection) === selectionKey(replica.selection));
170
+ });
171
+ return parseProfile({ ...profile, format: replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT,
172
+ sources, skills, apps, packages, replicas });
173
+ }
50
174
  /** A plan whose items are the chosen subset, split back into the block the writer consumes. */
51
175
  function chosenPlan(plan, selected) {
52
176
  const entries = selectedPlanEntries(plan, selected);
@@ -75,6 +199,12 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
75
199
  setModeState(next);
76
200
  };
77
201
  const [rows, setRows] = useState([]);
202
+ const [expandedGroups, setExpandedGroupsState] = useState(new Set());
203
+ const expandedGroupsRef = useRef(new Set());
204
+ const setExpandedGroups = (next) => {
205
+ expandedGroupsRef.current = next;
206
+ setExpandedGroupsState(next);
207
+ };
78
208
  const [selected, setSelectedState] = useState([]);
79
209
  const selectedRef = useRef([]);
80
210
  const select = (next) => {
@@ -90,12 +220,20 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
90
220
  const [text, setText] = useState("");
91
221
  const typed = useRef("");
92
222
  const [lines, setLines] = useState([]);
223
+ const detailReturnId = useRef(undefined);
93
224
  const [profile, setProfile] = useState();
94
225
  const [plan, setPlan] = useState();
95
226
  const diagnostics = useRef([]);
96
227
  const action = useRef("export");
97
- const profileVersion = useRef("");
98
- const height = useListHeight(listHeight, 9);
228
+ const { columns: terminalColumns } = useWindowSize();
229
+ const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
230
+ const listingHint = mode === "export-list"
231
+ ? "j/k · Enter group/preview · ←/→ group · space · a all · Esc"
232
+ : "j/k move · space toggle · a all · Enter preview · Esc";
233
+ const renderedListingHint = mode === "export-list" ? `${listingHint} · preview shows full text` : listingHint;
234
+ const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
235
+ const hintRows = Math.max(Math.ceil(stringWidth(renderedListingHint) / panelWidth), 1);
236
+ const height = useListHeight(listHeight, 4 + titleRows + hintRows);
99
237
  const showLines = (next, nextMode) => {
100
238
  setLines(next);
101
239
  cursorTo(0);
@@ -120,7 +258,12 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
120
258
  const loadRows = (next, nextMode) => {
121
259
  setRows(next);
122
260
  select(next.filter(row => row.selectable));
123
- cursorTo(0);
261
+ const open = new Set();
262
+ setExpandedGroups(open);
263
+ const firstChoice = nextMode === "export-list"
264
+ ? exportDisplayRows(next, open).findIndex(row => !("kind" in row) && row.selectable)
265
+ : 0;
266
+ cursorTo(Math.max(firstChoice, 0));
124
267
  setMode(nextMode);
125
268
  };
126
269
  const chosenProfile = () => {
@@ -132,6 +275,15 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
132
275
  skills: profile.skills.filter((_, index) => isChosen("skills", index)),
133
276
  apps: profile.apps.filter((_, index) => isChosen("apps", index)),
134
277
  packages: profile.packages.filter((_, index) => isChosen("packages", index)),
278
+ replicas: profile.replicas.filter((replica, index) => {
279
+ if (!isChosen("replicas", index))
280
+ return false;
281
+ if (replica.kind === "skill")
282
+ return profile.skills.some((skill, skillIndex) => isChosen("skills", skillIndex) && skill.path === replica.path && JSON.stringify(skill.source) === JSON.stringify(replica.source));
283
+ if (replica.kind === "app")
284
+ return profile.apps.some((app, appIndex) => isChosen("apps", appIndex) && app.name === replica.name && app.platform === replica.platform);
285
+ return profile.packages.some((selection, packageIndex) => isChosen("packages", packageIndex) && JSON.stringify(selection) === JSON.stringify(replica.selection));
286
+ }),
135
287
  };
136
288
  };
137
289
  useKeys((input, key) => {
@@ -176,11 +328,11 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
176
328
  }
177
329
  else
178
330
  void run(async () => {
179
- const raw = JSON.parse(await readFile(typed.current, "utf8"));
180
- const imported = await buildProfileImport(raw, operations, apps, {}, packages);
181
- profileVersion.current = raw.agentDepotVersion;
331
+ const importedProfile = parseProfile(JSON.parse(await readFile(typed.current, "utf8")));
332
+ const imported = await buildProfileImport(importedProfile, operations, apps, {}, packages);
182
333
  if (!mounted.current)
183
334
  return;
335
+ setProfile(importedProfile);
184
336
  setPlan(imported);
185
337
  loadRows(rowsFromPlan(imported), "import-list");
186
338
  });
@@ -195,6 +347,20 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
195
347
  }
196
348
  return;
197
349
  }
350
+ if (current === "detail") {
351
+ if (key.escape) {
352
+ const returnIndex = exportDisplayRows(rows, expandedGroupsRef.current)
353
+ .findIndex(row => !("kind" in row) && row.id === detailReturnId.current);
354
+ cursorTo(Math.max(returnIndex, 0));
355
+ detailReturnId.current = undefined;
356
+ setMode("export-list");
357
+ return;
358
+ }
359
+ const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
360
+ if (next !== undefined)
361
+ cursorTo(Math.max(0, next));
362
+ return;
363
+ }
198
364
  if (current === "confirm") {
199
365
  answerYesNo(input, key, () => {
200
366
  void run(async () => {
@@ -215,7 +381,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
215
381
  }, () => setMode("browse"));
216
382
  if (input === "y" || input === "n" || key.escape)
217
383
  return;
218
- const next = moveCursor(cursorRef.current, lines.length, input, key);
384
+ const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
219
385
  if (next !== undefined)
220
386
  cursorTo(Math.max(0, next));
221
387
  return;
@@ -224,20 +390,42 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
224
390
  setMode("browse");
225
391
  return;
226
392
  }
227
- // Export diagnostics follow the checklist, but never own its cursor.
228
- // Import same/conflict rows still draw a cursor so their differences can be reviewed.
229
- const checklistLength = rows.filter(row => row.block !== "excluded").length;
230
- const next = moveCursor(cursorRef.current, checklistLength, input, key);
393
+ const visibleRows = current === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows;
394
+ const currentRow = visibleRows[cursorRef.current];
395
+ const next = moveCursor(cursorRef.current, visibleRows.length, input, key);
231
396
  if (next !== undefined)
232
397
  cursorTo(Math.max(0, next));
233
- else if (input === " " && rows[cursorRef.current]?.selectable) {
234
- select(toggleChoice(rows, selectedRef.current, rows[cursorRef.current]));
398
+ else if (current === "export-list" && currentRow && "kind" in currentRow && currentRow.kind === "group" &&
399
+ (key.return || key.rightArrow || key.leftArrow)) {
400
+ if (key.return && !hasChosenResource(chosenProfile())) {
401
+ showLines(["Nothing selected; nothing written."], "results");
402
+ return;
403
+ }
404
+ const open = new Set(expandedGroupsRef.current);
405
+ if (key.return) {
406
+ if (open.has(currentRow.id))
407
+ open.delete(currentRow.id);
408
+ else
409
+ open.add(currentRow.id);
410
+ }
411
+ else if (key.rightArrow)
412
+ open.add(currentRow.id);
413
+ else
414
+ open.delete(currentRow.id);
415
+ setExpandedGroups(open);
416
+ }
417
+ else if (input === " " && currentRow && !("kind" in currentRow) && currentRow.selectable) {
418
+ select(toggleChoice(rows, selectedRef.current, currentRow));
235
419
  }
236
420
  else if (input === "a")
237
421
  select(selectedRef.current.length ? [] : rows.filter(row => row.selectable));
422
+ else if (current === "export-list" && key.return && currentRow && !("kind" in currentRow) && currentRow.block === "excluded") {
423
+ detailReturnId.current = currentRow.id;
424
+ showLines(wrapListText(currentRow.label, Math.max((terminalColumns || 80) - 8, 1)), "detail");
425
+ }
238
426
  else if (key.return) {
239
427
  if (current === "export-list") {
240
- if (!selectedRef.current.length) {
428
+ if (!hasChosenResource(chosenProfile())) {
241
429
  showLines(["Nothing selected; nothing written."], "results");
242
430
  }
243
431
  else
@@ -247,25 +435,56 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
247
435
  void run(async () => {
248
436
  // Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
249
437
  const entries = selectedPlanEntries(plan, selectedRef.current);
250
- const preview = await buildProfileImport({
251
- format: PROFILE_FORMAT, agentDepotVersion: profileVersion.current,
252
- sources: entries.filter(item => item.block === "sources").map(item => item.value),
253
- skills: entries.filter(item => item.block === "skills").map(item => item.value),
254
- apps: entries.filter(item => item.block === "apps").map(item => item.value),
255
- packages: entries.filter(item => item.block === "packages").map(item => item.value),
256
- }, operations, apps, {}, packages);
438
+ const subset = selectedImportProfile(profile, entries);
439
+ const preview = await buildProfileImport(subset, operations, apps, {}, packages);
257
440
  if (mounted.current)
258
441
  showLines(preview.preview, "confirm");
259
442
  });
260
443
  }
261
444
  });
262
445
  const listing = mode === "export-list" || mode === "import-list";
263
- const window = computeWindow(listing ? rows.length : lines.length, cursor, height);
264
- return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Portable user-global Profile \u00B7 e export \u00B7 i import" }), mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, listing ? ["sources", "skills", "apps", "packages", "excluded"].map(block => {
265
- const choices = rows.slice(window.start, window.end).filter(row => row.block === block);
266
- if (!choices.length)
267
- return null;
268
- return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: block === "excluded" ? "Excluded" : block[0].toUpperCase() + block.slice(1) }), block === "excluded" ? _jsx(PreviewLines, { lines: choices.map(row => `${row.label} (not selectable)`) }) :
269
- _jsx(ChecklistLines, { choices: choices, cursor: choices.indexOf(rows[cursor]), isSelected: row => selected.includes(row), keyOf: row => `${row.block}:${row.index}`, label: row => `${row.label}${row.selectable ? "" : " (not selectable)"}` })] }, block);
270
- }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines.slice(window.start, window.end), question: "Apply Profile? (y/n) \u00B7 j/k scroll" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && (listing || mode === "confirm" || mode === "results") ? _jsx(Text, { children: window.indicator }) : null, listing ? _jsx(Text, { children: "j/k move \u00B7 space toggle \u00B7 a all \u00B7 Enter preview \u00B7 Esc cancel" }) : null] });
446
+ const displayRows = mode === "export-list" ? exportDisplayRows(rows, expandedGroups) : rows;
447
+ const rowHeights = displayRows.map(row => {
448
+ const prefixWidth = "kind" in row ? stringWidth(`> ${" ".repeat(row.depth * 2)}▸ `) : row.block === "excluded" ? 2
449
+ : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
450
+ const label = !("kind" in row) && row.block !== "excluded" && !row.selectable
451
+ ? `${row.label} (not selectable)` : row.label;
452
+ return wrapListText(label, Math.max(panelWidth - prefixWidth, 1)).length;
453
+ });
454
+ const listWindow = physicalListWindow(displayRows, rowHeights, cursor, height);
455
+ const window = listing ? listWindow : computeWindow(lines.length, cursor, height);
456
+ const visibleRows = displayRows.slice(window.start, window.end);
457
+ const precedingGroup = displayRows.slice(0, window.start).filter((row) => "kind" in row).at(-1);
458
+ const indexedRows = visibleRows.map((row, index) => ({ row, index: window.start + index }));
459
+ const renderedRows = listWindow.stickyGroup && precedingGroup
460
+ ? [{ row: precedingGroup, index: undefined }, ...indexedRows]
461
+ : indexedRows;
462
+ let remainingRows = height;
463
+ const listContent = renderedRows.map(({ row, index }) => {
464
+ const marker = index === cursor ? "> " : " ";
465
+ if ("kind" in row) {
466
+ const open = expandedGroups.has(row.id);
467
+ const prefix = `${marker}${" ".repeat(row.depth * 2)}${open ? "▾" : "▸"} `;
468
+ const width = Math.max(panelWidth - stringWidth(prefix), 1);
469
+ const label = fitListLines(wrapListText(row.label, width), remainingRows, width)
470
+ .join(`\n${" ".repeat(stringWidth(prefix))}`);
471
+ remainingRows -= label.split("\n").length;
472
+ return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, label] }, row.id);
473
+ }
474
+ if (row.block === "excluded") {
475
+ const width = Math.max(panelWidth - stringWidth(marker), 1);
476
+ const label = fitListLines(wrapListText(row.label, width), remainingRows, width)
477
+ .join(`\n${" ".repeat(stringWidth(marker))}`);
478
+ remainingRows -= label.split("\n").length;
479
+ return _jsxs(Text, { color: theme.value, children: [marker, label] }, row.id);
480
+ }
481
+ const prefix = `${marker}[${selected.some(choice => choice.id === row.id) ? "x" : " "}] ${row.ordinal ?? row.index + 1} `;
482
+ const value = `${row.label}${row.selectable ? "" : " (not selectable)"}`;
483
+ const width = Math.max(panelWidth - stringWidth(prefix), 1);
484
+ const label = fitListLines(wrapListText(value, width), remainingRows, width)
485
+ .join(`\n${" ".repeat(stringWidth(prefix))}`);
486
+ remainingRows -= label.split("\n").length;
487
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: prefix }), _jsx(Text, { color: theme.value, children: label })] }, row.id);
488
+ });
489
+ return _jsxs(Box, { flexDirection: "column", children: [listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: renderedListingHint }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode !== "confirm" && mode !== "detail" && !listing ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, question: "Apply Profile? (y/n) \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
271
490
  }
@@ -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
  }