@iseoane/agent-depot 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -1
  3. package/dist/src/flow-progress.js +6 -0
  4. package/dist/src/git-source.js +18 -6
  5. package/dist/src/preview-model.js +34 -0
  6. package/dist/src/process-runner.js +1 -0
  7. package/dist/src/profile-cli.js +12 -4
  8. package/dist/src/profile-export.js +23 -3
  9. package/dist/src/skill-update.js +1 -0
  10. package/dist/src/source-refresh-session.js +139 -0
  11. package/dist/src/sources.js +4 -4
  12. package/dist/src/tui/adoption-actions.js +21 -2
  13. package/dist/src/tui/app-actions.js +54 -45
  14. package/dist/src/tui/app.js +22 -4
  15. package/dist/src/tui/bulk-actions.js +42 -9
  16. package/dist/src/tui/catalog-actions.js +12 -0
  17. package/dist/src/tui/catalog-flow.js +31 -11
  18. package/dist/src/tui/catalog-panel.js +15 -13
  19. package/dist/src/tui/catalog-tree.js +7 -2
  20. package/dist/src/tui/catalog-view.js +12 -6
  21. package/dist/src/tui/file-picker.js +2 -1
  22. package/dist/src/tui/installations-panel.js +12 -4
  23. package/dist/src/tui/installations-rows.js +1 -1
  24. package/dist/src/tui/installations-view.js +16 -7
  25. package/dist/src/tui/keys.js +27 -4
  26. package/dist/src/tui/manage-actions.js +55 -27
  27. package/dist/src/tui/manage-panel.js +15 -29
  28. package/dist/src/tui/mode-keys.js +11 -1
  29. package/dist/src/tui/package-actions.js +25 -18
  30. package/dist/src/tui/package-rows.js +2 -1
  31. package/dist/src/tui/panel-parts.js +45 -36
  32. package/dist/src/tui/preview-render.js +93 -0
  33. package/dist/src/tui/preview-window.js +141 -10
  34. package/dist/src/tui/profile-view.js +49 -40
  35. package/dist/src/tui/render.js +11 -1
  36. package/dist/src/tui/safe-text.js +25 -0
  37. package/dist/src/tui/sources-mode.js +2 -2
  38. package/dist/src/tui/sources-recipes.js +53 -32
  39. package/dist/src/tui/sources-view.js +86 -39
  40. package/dist/src/tui/unmanaged-actions.js +17 -5
  41. package/dist/src/tui/updates-flow.js +11 -5
  42. package/dist/src/tui/updates-keys.js +2 -2
  43. package/dist/src/tui/updates-model.js +5 -2
  44. package/dist/src/tui/updates-panel.js +10 -9
  45. package/dist/src/tui/updates-preview.js +27 -44
  46. package/dist/src/tui/updates-view.js +8 -10
  47. package/dist/src/tui/updates.js +117 -33
  48. package/dist/src/update-flow.js +31 -15
  49. package/package.json +1 -1
@@ -2,9 +2,9 @@ import { APP_KINDS } from "./app-actions.js";
2
2
  import { PACKAGE_KINDS } from "./package-actions.js";
3
3
  import { globalRecord, missingHosts, prepareHostAddition, prepareHostRemoval, prepareUninstall, runHostAddition, runHostRemoval, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
4
4
  import { errorText } from "./batch.js";
5
- import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
6
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
7
- import { handleUnmanagedKey, UNMANAGED_KINDS } from "./unmanaged-actions.js";
5
+ import { BULK_KINDS, bulkConfirmPreview, handleBulkKey } from "./bulk-actions.js";
6
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
7
+ import { handleUnmanagedKey, UNMANAGED_KINDS, unmanagedConfirmPreview } from "./unmanaged-actions.js";
8
8
  import { busy } from "./working-model.js";
9
9
  const MANAGE_KINDS = new Set([
10
10
  "remove-scope",
@@ -43,7 +43,7 @@ async function startFullUninstall(context, skill) {
43
43
  if (!context.isMounted())
44
44
  return;
45
45
  if (prepared.kind === "ready") {
46
- context.setMode({ kind: "confirm-uninstall", skill, prepared });
46
+ context.setMode({ kind: "confirm-uninstall", skill, prepared, cursor: 0 });
47
47
  return;
48
48
  }
49
49
  context.setMessage({ kind: "error", text: UNINSTALL_REFUSALS[prepared.kind] });
@@ -73,13 +73,13 @@ async function startHostChange(context, mode) {
73
73
  const prepared = await prepareHostAddition(operations, env, mode.skill, installed, mode.selected);
74
74
  if (!context.isMounted())
75
75
  return;
76
- context.setMode({ kind: "confirm-host-add", skill: mode.skill, prepared });
76
+ context.setMode({ kind: "confirm-host-add", skill: mode.skill, prepared, cursor: 0 });
77
77
  }
78
78
  else {
79
79
  const prepared = await prepareHostRemoval(operations, env, mode.skill, installed, mode.selected);
80
80
  if (!context.isMounted())
81
81
  return;
82
- context.setMode({ kind: "confirm-host-remove", skill: mode.skill, prepared });
82
+ context.setMode({ kind: "confirm-host-remove", skill: mode.skill, prepared, cursor: 0 });
83
83
  }
84
84
  }
85
85
  catch (error) {
@@ -117,35 +117,61 @@ function handleRemoveScopeKey(context, mode, input, key) {
117
117
  else if (input === "2")
118
118
  context.setMode({ kind: "hosts-remove", skill: mode.skill, choices: mode.hosts, cursor: 0, selected: [] });
119
119
  }
120
- /** Keys of the confirmation modes: `y` runs the step (or asks for exposure consent first), `n`/Esc cancels. */
121
- function handleConfirmKey(context, mode, input, key) {
122
- const { operations, env } = context;
123
- const cancelWith = (text) => () => cancel(context, text);
120
+ /** What a paged confirmation shows, so the key handler and the panel page the same rows. */
121
+ export function pagedConfirmPreview(mode) {
122
+ if (mode.kind === "confirm-unmanaged")
123
+ return unmanagedConfirmPreview(mode);
124
+ if (mode.kind === "confirm-bulk-uninstall" || mode.kind === "confirm-bulk-hosts")
125
+ return bulkConfirmPreview(mode);
126
+ const name = mode.skill.name;
124
127
  switch (mode.kind) {
125
128
  case "confirm-host-add":
126
- answerYesNo(input, key, () => context.setMode({ ...mode, kind: "confirm-host-exposure" }), cancelWith("Install cancelled"));
127
- return;
128
- case "confirm-host-exposure":
129
- answerYesNo(input, key, () => {
130
- void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostAddition(mode.prepared, operations, env));
131
- }, cancelWith("Install cancelled"));
132
- return;
129
+ return { lines: mode.prepared.preview, chrome: { title: "Add hosts", action: `Add hosts to ${name}`, consequence: `Add hosts to ${name}? y/n` } };
133
130
  case "confirm-host-remove":
134
- answerYesNo(input, key, () => {
135
- void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
136
- }, cancelWith("Uninstall cancelled"));
137
- return;
131
+ return { lines: mode.prepared.preview, chrome: { title: "Remove hosts", action: `Remove hosts from ${name}`, consequence: `Remove hosts from ${name}? y/n` } };
138
132
  case "confirm-uninstall":
139
- answerYesNo(input, key, () => {
133
+ return { lines: mode.prepared.plan.preview, chrome: { title: "Uninstall", action: `Uninstall ${name}`, consequence: `Uninstall ${name}? y/n` } };
134
+ }
135
+ }
136
+ export function isPagedConfirmMode(mode) {
137
+ return ["confirm-host-add", "confirm-host-remove", "confirm-uninstall", "confirm-unmanaged", "confirm-bulk-uninstall", "confirm-bulk-hosts"].includes(mode.kind);
138
+ }
139
+ /** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
140
+ function handlePagedConfirmKey(context, mode, input, key) {
141
+ const { operations, env } = context;
142
+ const { lines, chrome } = pagedConfirmPreview(mode);
143
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
144
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
145
+ if (cursor !== undefined) {
146
+ context.setMode({ ...mode, cursor });
147
+ return;
148
+ }
149
+ answerYesNo(input, key, () => {
150
+ if (!viewport.reachedEnd)
151
+ return;
152
+ switch (mode.kind) {
153
+ case "confirm-host-add":
154
+ context.setMode({ kind: "confirm-host-exposure", skill: mode.skill, prepared: mode.prepared });
155
+ return;
156
+ case "confirm-host-remove":
157
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
158
+ return;
159
+ case "confirm-uninstall":
140
160
  void execute(context, `Removing ${mode.skill.name}…`, async () => {
141
161
  await runUninstall(mode.prepared, operations, env);
142
162
  return mode.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
143
163
  ? `Forgot missing installation record for ${mode.skill.path}; no files were removed`
144
164
  : `Removed ${mode.skill.name} (${mode.skill.path})`;
145
165
  });
146
- }, cancelWith("Uninstall cancelled"));
147
- return;
148
- }
166
+ return;
167
+ }
168
+ }, () => cancel(context, mode.kind === "confirm-host-add" ? "Install cancelled" : "Uninstall cancelled"));
169
+ }
170
+ /** The exposure question has no preview: `y` adds the hosts, `n`/Esc cancels. */
171
+ function handleExposureKey(context, mode, input, key) {
172
+ answerYesNo(input, key, () => {
173
+ void execute(context, `Changing hosts of ${mode.skill.name}…`, () => runHostAddition(mode.prepared, context.operations, context.env));
174
+ }, () => cancel(context, "Install cancelled"));
149
175
  }
150
176
  /**
151
177
  * Handles a key in one of the manage modes. Returns false when the mode is not
@@ -167,10 +193,12 @@ export function handleManageKey(context, mode, input, key) {
167
193
  handleHostChecklistKey(context, mode, input, key);
168
194
  break;
169
195
  case "confirm-host-add":
170
- case "confirm-host-exposure":
171
196
  case "confirm-host-remove":
172
197
  case "confirm-uninstall":
173
- handleConfirmKey(context, mode, input, key);
198
+ handlePagedConfirmKey(context, mode, input, key);
199
+ break;
200
+ case "confirm-host-exposure":
201
+ handleExposureKey(context, mode, input, key);
174
202
  break;
175
203
  default:
176
204
  break;
@@ -1,29 +1,20 @@
1
1
  import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { AppPanel } from "./app-actions.js";
4
5
  import { PackagePanel } from "./package-actions.js";
5
- import { ChecklistLines, DecisionPrompt, HostChecklist, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
6
+ import { pagedConfirmPreview } from "./manage-actions.js";
7
+ import { ChecklistLines, DecisionPrompt, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
8
+ import { usePreviewGeometry } from "./preview-window.js";
6
9
  import { theme } from "./theme.js";
7
10
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
8
11
  function SkippedLines({ skipped }) {
9
12
  return (_jsx(_Fragment, { children: skipped.map(({ id, label, reason }) => _jsxs(Text, { color: theme.warning, children: ["Skipped ", label, ": ", reason] }, id)) }));
10
13
  }
11
- /** One block per planned item, the skipped ones, then the y/n question. */
12
- function PlanPreview({ entries, skipped, question }) {
13
- return (_jsxs(Box, { flexDirection: "column", children: [entries.map((entry) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.group, children: entry.label }), _jsx(PreviewLines, { lines: entry.lines, warnFirst: entry.warnFirst })] }, entry.id))), _jsx(SkippedLines, { skipped: skipped }), _jsx(DecisionPrompt, { children: question })] }));
14
- }
15
- function UninstallPreview({ plan }) {
16
- const entries = plan.items.map((item) => ({
17
- id: item.id,
18
- label: item.label,
19
- lines: item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview,
20
- warnFirst: item.kind === "unmanaged",
21
- }));
22
- return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Uninstall ${plural(plan.items.length)}?` });
23
- }
24
- function HostsPreview({ plan }) {
25
- const entries = plan.items.map((item) => ({ id: item.id, label: item.label, lines: item.prepared.preview, warnFirst: false }));
26
- return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Add hosts to ${plural(plan.items.length)}?` });
14
+ /** A Skill confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
15
+ export function PagedConfirmPanel({ mode }) {
16
+ const { lines, styled, chrome } = pagedConfirmPreview(mode);
17
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
27
18
  }
28
19
  /** Renders the prompt of a manage mode: host lists, previews and confirmations. */
29
20
  export function ManagePanel({ mode }) {
@@ -44,25 +35,20 @@ export function ManagePanel({ mode }) {
44
35
  case "hosts-remove":
45
36
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [mode.kind === "hosts-add" ? "Add hosts to" : "Remove hosts from", " ", mode.skill.name, ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
46
37
  case "confirm-host-add":
47
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Add hosts to ${mode.skill.name}? y/n` });
48
- case "confirm-host-exposure":
49
- return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.skill.name, " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
50
38
  case "confirm-host-remove":
51
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove hosts from ${mode.skill.name}? y/n` });
52
39
  case "confirm-uninstall":
53
- return _jsx(PreviewConfirm, { lines: mode.prepared.plan.preview, question: `Uninstall ${mode.skill.name}? y/n` });
40
+ case "confirm-unmanaged":
41
+ case "confirm-bulk-uninstall":
42
+ case "confirm-bulk-hosts":
43
+ return _jsx(PagedConfirmPanel, { mode: mode });
44
+ case "confirm-host-exposure":
45
+ return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.skill.name, " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
54
46
  case "unmanaged-scope":
55
47
  return _jsxs(Text, { children: ["Remove ", mode.group.name, " (", mode.group.locations.length, " locations): 1 all locations 2 choose locations (Esc cancel)"] });
56
48
  case "unmanaged-pick":
57
49
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Remove locations of ", mode.group.name, ". Location (space/1-", mode.group.locations.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(ChecklistLines, { choices: mode.group.locations, cursor: mode.cursor, isSelected: (location) => mode.selected.includes(location.path), keyOf: (location) => location.path, label: (location) => `${location.path}${location.linkTarget === undefined ? "" : ` symlink → ${location.linkTarget}`}` })] }));
58
- case "confirm-unmanaged":
59
- return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove ${mode.group.name}? y/n`, warnFirst: true });
60
50
  case "bulk-hosts":
61
51
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Add hosts to ", plural(mode.targets.length), ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected }), _jsx(SkippedLines, { skipped: mode.skipped })] }));
62
- case "confirm-bulk-uninstall":
63
- return _jsx(UninstallPreview, { plan: mode.plan });
64
- case "confirm-bulk-hosts":
65
- return _jsx(HostsPreview, { plan: mode.plan });
66
52
  case "confirm-bulk-exposure":
67
53
  return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.plan.items.map((item) => item.label).join(", "), " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
68
54
  case "busy":
@@ -1,4 +1,5 @@
1
1
  import { isValidFixedVersion } from "../skill-install.js";
2
+ import { isYes } from "./keys.js";
2
3
  /** The cursor after a down/up key (arrows or j/k), or undefined when the key does not move it. */
3
4
  export function moveCursor(cursor, length, input, key) {
4
5
  if (key.downArrow || input === "j")
@@ -51,11 +52,20 @@ export function applyChecklistOutcome(outcome, handlers) {
51
52
  }
52
53
  /** The y/n confirmation keys: `y` confirms, `n` or Esc declines, anything else is ignored. */
53
54
  export function answerYesNo(input, key, onYes, onNo) {
54
- if (input === "y")
55
+ if (isYes(input, key))
55
56
  onYes();
56
57
  else if (input === "n" || key.escape)
57
58
  onNo();
58
59
  }
60
+ /** A paged preview answers `y` only once `viewport` has been read to the end; paging keys move the cursor. */
61
+ export function answerPaged(viewport, cursor, input, key, outcome) {
62
+ const moved = movePreviewCursor(cursor, viewport.total, input, key, viewport.height);
63
+ if (moved !== undefined)
64
+ outcome.moveTo(moved);
65
+ else
66
+ answerYesNo(input, key, () => { if (viewport.reachedEnd)
67
+ outcome.yes(); }, outcome.no);
68
+ }
59
69
  /** Esc cancels, Enter submits a valid version, backspace deletes, any other plain key is typed. */
60
70
  export function versionInputKey(value, input, key) {
61
71
  if (key.escape)
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { quoteText } from "../preview-model.js";
2
3
  import { displayPackageSelection } from "../package-model.js";
3
4
  import { errorText } from "./batch.js";
5
+ import { isYes } from "./keys.js";
4
6
  import { movePreviewCursor } from "./mode-keys.js";
5
7
  import { installedEvidenceLabel, packagePlanLines } from "./package-rows.js";
6
- import { PreviewConfirm } from "./panel-parts.js";
7
- import { previewViewport } from "./preview-window.js";
8
+ import { PagedPreview } from "./panel-parts.js";
9
+ import { usePreviewGeometry } from "./preview-window.js";
8
10
  import { busy } from "./working-model.js";
9
11
  /** The per-row keys of a Package; Enter is approval, handled beside these. */
10
12
  export const PACKAGE_ACTION_KEYS = new Map([
@@ -58,7 +60,7 @@ export async function startPackageAction(context, target, input) {
58
60
  manage.setMode({ kind: "package-confirm", plan, approval, cursor: 0 });
59
61
  return;
60
62
  }
61
- await manage.finish({ kind: "error", text: `The action ${JSON.stringify(input)} is not available for Packages` });
63
+ await manage.finish({ kind: "error", text: `The action ${quoteText(input)} is not available for Packages` });
62
64
  }
63
65
  catch (error) {
64
66
  await manage.finish({ kind: "error", text: errorText(error) });
@@ -132,28 +134,32 @@ function packagePreview(mode) {
132
134
  case "package-approve":
133
135
  return {
134
136
  lines: mode.lines,
135
- chrome: reviewChrome("Package approval", `Approve this install declaration for ${mode.target.name}? y/n`),
137
+ chrome: { title: "Package approval", action: "Approve the install declaration",
138
+ consequence: `Approve this install declaration for ${mode.target.name}? y/n` },
136
139
  };
137
140
  case "package-forget":
138
141
  return {
139
142
  lines: mode.lines,
140
- chrome: reviewChrome("Forget Package selection", `Forget the selection record of ${mode.target.name}? y/n`),
143
+ chrome: { title: "Forget Package selection", action: "Forget the selection record",
144
+ consequence: `Forget the selection record of ${mode.target.name}? y/n` },
141
145
  };
142
- case "package-confirm":
146
+ case "package-confirm": {
147
+ const { action, descriptor } = mode.plan;
148
+ const needsApproval = mode.approval === "needs approval";
143
149
  return {
144
150
  lines: packagePlanLines(mode.plan, mode.approval),
145
- chrome: reviewChrome(`Package ${mode.plan.action} confirmation`, mode.approval === "needs approval"
146
- ? "Approve this declaration and continue? y/n"
147
- : `${mode.plan.action} ${mode.plan.descriptor.name}? y/n`),
151
+ chrome: {
152
+ title: `Package ${action} confirmation`,
153
+ action: needsApproval ? `Approve and ${action} ${descriptor.name}` : `${action[0].toUpperCase()}${action.slice(1)} ${descriptor.name}`,
154
+ consequence: needsApproval ? "Approve this declaration and continue? y/n" : `${action} ${descriptor.name}? y/n`,
155
+ },
148
156
  };
157
+ }
149
158
  }
150
159
  }
151
- function reviewChrome(title, question) {
152
- return { title, questions: [question], footerRows: 2 };
153
- }
154
160
  /**
155
- * Pages the declaration with the arrows, PgUp/PgDn and j/k. `y` answers from any page: the panel shows
156
- * the `Review N of M` position and the row window while more remains. `n` and Esc run nothing.
161
+ * Pages the declaration with the arrows, PgUp/PgDn and j/k. `y` answers only once the whole declaration
162
+ * has been displayed. `n` and Esc run nothing.
157
163
  */
158
164
  export function handlePackageKey(context, mode, input, key) {
159
165
  const { manage, packages } = context;
@@ -162,7 +168,7 @@ export function handlePackageKey(context, mode, input, key) {
162
168
  manage.browse();
163
169
  };
164
170
  const { lines, chrome } = packagePreview(mode);
165
- const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, chrome);
171
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
166
172
  const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
167
173
  if (cursor !== undefined) {
168
174
  manage.setMode({ ...mode, cursor });
@@ -172,7 +178,7 @@ export function handlePackageKey(context, mode, input, key) {
172
178
  cancel("Package action cancelled");
173
179
  return;
174
180
  }
175
- if (input !== "y")
181
+ if (!isYes(input, key) || !viewport.reachedEnd)
176
182
  return;
177
183
  switch (mode.kind) {
178
184
  case "package-approve":
@@ -190,8 +196,9 @@ export function handlePackageKey(context, mode, input, key) {
190
196
  return;
191
197
  }
192
198
  }
193
- /** Renders a Package prompt with the existing preview panel and y/n confirmation. */
199
+ /** Renders a Package prompt with the shared paged preview. */
194
200
  export function PackagePanel({ mode }) {
195
201
  const { lines, chrome } = packagePreview(mode);
196
- return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: chrome.questions[0], chrome: chrome });
202
+ const viewport = usePreviewGeometry()(lines, mode.cursor, chrome);
203
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, viewport: viewport });
197
204
  }
@@ -1,4 +1,5 @@
1
1
  import { classifyUpdate } from "../package-host-plans.js";
2
+ import { quoteText } from "../preview-model.js";
2
3
  import { displayPackageCoordinates, displayPackageSelection, } from "../package-model.js";
3
4
  /** Version evidence as a short label; `unknown` when the host or the bundle states none. */
4
5
  export function packageVersionLabel(evidence, commit) {
@@ -98,7 +99,7 @@ export function packagePlanLines(plan, approval) {
98
99
  ...(plan.commands.length === 0
99
100
  ? ["This action runs no host command."]
100
101
  : plan.commands.flatMap((command, position) => [
101
- `Step ${position + 1} (${command.purpose}): ${JSON.stringify([command.executable, ...command.args])}`,
102
+ `Step ${position + 1} (${command.purpose}): [${[command.executable, ...command.args].map(quoteText).join(",")}]`,
102
103
  `Executable: ${command.resolvedPath ?? command.executable}`,
103
104
  ])),
104
105
  `Blast radius: ${plan.affects.map((coordinates) => displayPackageCoordinates(selection.source, coordinates)).join(", ")}`,
@@ -1,8 +1,9 @@
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 { PreEscapedText, Text } from "./safe-text.js";
3
4
  import { useEffect, useState } from "react";
4
5
  import { theme } from "./theme.js";
5
- import { previewViewport } from "./preview-window.js";
6
+ import { escapeControlText, sliceSpans } from "./preview-window.js";
6
7
  import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./working-model.js";
7
8
  /** A titled, color-independent boundary for a focused interaction or result. */
8
9
  export function BorderPanel({ title, children }) {
@@ -40,7 +41,6 @@ export function ChecklistLines({ choices, cursor, isSelected, keyOf, label }) {
40
41
  export function HostChecklist({ choices, cursor, selected }) {
41
42
  return _jsx(ChecklistLines, { choices: choices, cursor: cursor, isSelected: (host) => selected.includes(host), keyOf: (host) => host, label: (host) => host });
42
43
  }
43
- /** Preview lines; `warnFirst` colors the first one as a warning. */
44
44
  const PREVIEW_FIELD_LABELS = [
45
45
  "File:", "Name:", "Platform:", "Status:", "Homepage:", "Owned Skills:",
46
46
  "App:", "Environment:", "Version:", "argv:", "Executable:", "Manual:",
@@ -106,51 +106,60 @@ function profilePreviewSpans(line) {
106
106
  previewSpan(` ${hosts[2]}`, theme.version)];
107
107
  return undefined;
108
108
  }
109
- function visiblePreviewSpans(spans, offset, length) {
110
- const end = offset + length;
111
- let spanStart = 0;
112
- const visible = [];
113
- for (const span of spans) {
114
- const spanEnd = spanStart + span.text.length;
115
- const start = Math.max(offset, spanStart);
116
- const stop = Math.min(end, spanEnd);
117
- if (start < stop)
118
- visible.push({ ...span, text: span.text.slice(start - spanStart, stop - spanStart) });
119
- spanStart = spanEnd;
120
- }
121
- return visible;
109
+ /** The colour of a warning, host exposure notice or refusal, which the flows indent to different depths. */
110
+ function noticeColor(line) {
111
+ const text = line.trimStart();
112
+ if (text.startsWith("WARNING:") || text.startsWith("ADDITIONAL HOST EXPOSURE:") || text.startsWith("Skipped "))
113
+ return theme.warning;
114
+ return text.startsWith("Cannot install ") ? theme.error : undefined;
122
115
  }
123
- function PreviewLine({ line, position, warnFirst, logicalLine, offset = 0 }) {
116
+ function PreviewLine({ line, position, logicalLine, offset = 0 }) {
124
117
  const profileSpans = logicalLine === undefined ? profilePreviewSpans(line) : profilePreviewSpans(logicalLine);
125
118
  if (profileSpans) {
126
- const spans = logicalLine === undefined ? profileSpans : visiblePreviewSpans(profileSpans, offset, line.length);
127
- return _jsx(Text, { children: spans.map((span, index) => _jsx(Text, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
119
+ const spans = logicalLine === undefined ? profileSpans : sliceSpans(profileSpans, offset, line.length);
120
+ return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
128
121
  }
129
122
  const field = previewField(logicalLine ?? line);
130
123
  if (field) {
131
124
  const fieldSpans = [previewSpan(field.label, theme.field), previewSpan(field.value, theme.value)];
132
- const spans = logicalLine === undefined ? fieldSpans : visiblePreviewSpans(fieldSpans, offset, line.length);
133
- return _jsx(Text, { children: spans.map((span, index) => _jsx(Text, { color: span.color, children: span.text }, index)) }, position);
125
+ const spans = logicalLine === undefined ? fieldSpans : sliceSpans(fieldSpans, offset, line.length);
126
+ return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, children: span.text }, index)) }, position);
134
127
  }
135
- return _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : theme.value, children: line }, position);
128
+ return _jsx(PreEscapedText, { color: noticeColor(logicalLine ?? line) ?? theme.value, children: line }, position);
136
129
  }
137
- export function PreviewLines({ lines, warnFirst = false }) {
138
- return _jsx(_Fragment, { children: lines.map((line, position) => _jsx(PreviewLine, { line: line, position: position, warnFirst: warnFirst }, position)) });
130
+ export function PreviewLines({ lines }) {
131
+ return _jsx(_Fragment, { children: lines.map((line, position) => _jsx(PreviewLine, { line: escapeControlText(line), position: position }, position)) });
139
132
  }
140
133
  /** Under a list: the window indicator, how many items are marked, and the last result. */
141
134
  export function ListFooter({ indicator, selectedCount, message }) {
142
135
  return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, selectedCount > 0 ? _jsxs(Text, { color: theme.muted, children: [selectedCount, " selected"] }) : null, message ? (_jsx(BorderPanel, { title: message.kind === "error" ? "Result · failed" : "Result · complete", children: _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) })) : null] }));
143
136
  }
144
- /** A preview followed by a y/n question. */
145
- export function PreviewConfirm({ title = "Confirmation", lines, question, warnFirst, cursor, chrome }) {
146
- const { rows, columns } = useWindowSize();
147
- const viewport = cursor === undefined ? undefined : previewViewport(lines, cursor, rows || 24, columns || 80, chrome ?? { title, questions: [question], footerRows: 2 });
148
- const shown = viewport?.rows ?? lines;
149
- return (_jsxs(BorderPanel, { title: title, children: [viewport ? shown.map((line, position) => {
150
- const sourceLine = viewport.sourceLines[position];
151
- return _jsx(PreviewLine, { line: line, position: position, warnFirst: Boolean(warnFirst && viewport.start === 0), logicalLine: lines[sourceLine], offset: viewport.sourceOffsets[position] }, position);
152
- }) : _jsx(PreviewLines, { lines: shown, warnFirst: warnFirst }), viewport && viewport.total > viewport.height ? _jsxs(Text, { color: theme.muted, children: [viewport.start + 1, "\u2013", viewport.end, " of ", viewport.total] }) : null, viewport ? _jsxs(Text, { color: theme.legend, children: ["Review ", viewport.cursor + 1, " of ", viewport.total, " \u00B7 arrows/Pg page"] }) : null, question.includes("y/n") ? (() => {
153
- const marker = question.indexOf("y/n");
154
- return _jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: question.slice(0, marker) }), _jsx(Text, { color: theme.success, bold: true, children: "y" }), _jsx(Text, { color: theme.legend, children: "/" }), _jsx(Text, { color: theme.error, children: "n" }), _jsx(Text, { color: theme.legend, children: question.slice(marker + 3) })] });
155
- })() : _jsx(Text, { color: theme.legend, children: question })] }));
137
+ function footerColor(role, reachedEnd) {
138
+ switch (role) {
139
+ case "read": return { color: theme.success };
140
+ case "reading": return { color: theme.warning };
141
+ case "consequence": return { color: theme.warning };
142
+ case "yes": return { color: theme.success, bold: true };
143
+ case "no": return { color: theme.error };
144
+ case "action": return reachedEnd ? { color: theme.success, bold: true } : { color: theme.muted };
145
+ case "position":
146
+ case "cancel": return { color: theme.legend };
147
+ }
148
+ }
149
+ /**
150
+ * A paged preview with the shared two-row footer: reading progress, then what `y` does. `[y]` stays
151
+ * dimmed until `viewport` reaches the end; callers ignore `y` until then using the same viewport.
152
+ */
153
+ export function PagedPreview({ chrome, lines: rawLines, styled: rawStyled, viewport }) {
154
+ // The same escape `previewViewport` applied, so rows, offsets and spans describe the same text. The rows themselves are already
155
+ // escaped, hence `PreEscapedText`; only the title goes through `BorderPanel`.
156
+ const lines = rawLines.map(escapeControlText);
157
+ const styled = rawStyled?.map((line) => line?.map((span) => ({ ...span, text: escapeControlText(span.text) })));
158
+ return (_jsxs(BorderPanel, { title: chrome.title, children: [viewport.rows.map((line, position) => {
159
+ const logical = viewport.sourceLines[position];
160
+ const custom = styled?.[logical];
161
+ return custom === undefined
162
+ ? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position] }, position)
163
+ : _jsx(PreEscapedText, { children: sliceSpans(custom, viewport.sourceOffsets[position], line.length).map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
164
+ }), viewport.footer.map((spans, row) => _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { ...footerColor(span.role, viewport.reachedEnd), children: span.text }, index)) }, `footer-${row}`))] }));
156
165
  }
@@ -0,0 +1,93 @@
1
+ import { commandLine, itemWarnings } from "../preview-model.js";
2
+ import { theme } from "./theme.js";
3
+ export const span = (text, color, bold = false) => bold ? { text, color, bold } : { text, color };
4
+ // Ink drops the height of an empty Text, so a blank separator keeps one space.
5
+ const BLANK = [span(" ", theme.value)];
6
+ export function lineText(line) {
7
+ return line.map(({ text }) => text).join("");
8
+ }
9
+ export function textLine(text) {
10
+ return [span(text, theme.value)];
11
+ }
12
+ export function fieldLine(label, value) {
13
+ return [span(label, theme.field), span(` ${value}`, theme.value)];
14
+ }
15
+ const ROW_INDENT = " ";
16
+ // The views escape control characters once, as they draw.
17
+ const TERMINAL = { terminal: true };
18
+ // A recipe's manual text can span lines; escaped inside one line, its breaks would show as `\x0a`.
19
+ const LINE_BREAK = /\r\n|\r|\n/u;
20
+ function rowLines(row) {
21
+ switch (row.kind) {
22
+ case "field": {
23
+ const [first = "", ...rest] = row.value.split(LINE_BREAK);
24
+ return [
25
+ [span(`${ROW_INDENT}${row.label}:`, theme.field), ...(first === "" ? [] : [span(` ${first}`, theme.value)])],
26
+ ...rest.map((line) => [span(`${ROW_INDENT}${line}`, theme.value)]),
27
+ ];
28
+ }
29
+ case "text":
30
+ return [[span(`${ROW_INDENT}${" ".repeat(row.indent ?? 0)}${row.text}`, theme.value)]];
31
+ case "warning":
32
+ return [[span(`${ROW_INDENT}⚠ ${row.text}`, theme.warning)]];
33
+ case "command": {
34
+ const where = [row.executable, row.cwd === undefined ? undefined : `in ${row.cwd}`].filter((part) => part !== undefined);
35
+ return [
36
+ [span(`${ROW_INDENT}$ ${commandLine(row.argv, TERMINAL)}`, theme.value)],
37
+ ...(where.length === 0 ? [] : [[span(`${ROW_INDENT}${where.join(" · ")}`, theme.legend)]]),
38
+ ];
39
+ }
40
+ }
41
+ }
42
+ function itemLines(item) {
43
+ const title = [span(` ${item.title}`, theme.value, true)];
44
+ if (item.detail !== undefined)
45
+ title.push(span(` ${item.detail}`, theme.legend));
46
+ if (item.version !== undefined)
47
+ title.push(span(` ${item.version.from} → ${item.version.to}`, theme.version));
48
+ return [title, ...item.rows.flatMap(rowLines)];
49
+ }
50
+ /** One block per step joined by a tree gutter: what the step runs or asks for, then dimmed where it runs. */
51
+ export function stepBlockLines(steps) {
52
+ return steps.flatMap((step, index) => {
53
+ const last = index === steps.length - 1;
54
+ const branch = last ? "└" : index === 0 ? "┌" : "├";
55
+ const row = (...spans) => [span(last ? " " : " │ ", theme.field), ...spans];
56
+ const where = [`in ${step.cwd}`, ...(step.environment ? [`on ${step.environment}`] : [])].join(" · ");
57
+ return [
58
+ [span(` ${branch} ${step.step}`, theme.field)],
59
+ ...(step.argv ? [row(span(`$ ${commandLine(step.argv, TERMINAL)}`, theme.value))] : []),
60
+ ...(step.manual ? step.manual.split(LINE_BREAK).map((line, position) => row(span(position === 0 ? `Manual: ${line}` : line, theme.value))) : []),
61
+ ...(step.declaration ? [row(span(step.declaration, theme.value))] : []),
62
+ ...(step.pattern ? [row(span(`Version pattern: ${step.pattern}`, theme.value))] : []),
63
+ step.argv && step.executable === undefined
64
+ ? row(span(`${commandLine(step.argv.slice(0, 1), TERMINAL)} not found in PATH`, theme.warning), span(` · ${where}`, theme.legend))
65
+ : row(span(step.executable ? `${step.executable} · ${where}` : where, theme.legend)),
66
+ ...(step.warning ? [row(span(`⚠ ${step.warning}`, theme.warning))] : []),
67
+ ];
68
+ });
69
+ }
70
+ /** The document as preview lines: action and counts, context, every warning, one section per type, then the failures. */
71
+ export function renderPreviewDocument(document) {
72
+ const warnings = [
73
+ ...document.warnings,
74
+ ...document.sections.flatMap(({ items }) => items.flatMap((item) => itemWarnings(item).map((warning) => `${item.title}: ${warning}`))),
75
+ ];
76
+ const lines = [
77
+ [span(document.action, theme.accent, true), span(` ${document.summary}`, theme.legend)],
78
+ ...document.context.map((line) => [span(` ${line}`, theme.legend)]),
79
+ ...warnings.map((warning) => [span(` ⚠ ${warning}`, theme.warning)]),
80
+ ];
81
+ for (const section of document.sections) {
82
+ lines.push(BLANK, [span(section.heading, theme.field, true)]);
83
+ section.items.forEach((item, position) => {
84
+ if (position > 0)
85
+ lines.push(BLANK);
86
+ lines.push(...itemLines(item));
87
+ });
88
+ }
89
+ if (document.failures.length > 0) {
90
+ lines.push(BLANK, [span("FAILED", theme.error, true)], ...document.failures.map((failure) => [span(` ${failure}`, theme.error)]));
91
+ }
92
+ return lines;
93
+ }