@iseoane/agent-depot 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/src/app-flow.js +8 -1
  4. package/dist/src/atomic-file.js +29 -3
  5. package/dist/src/cli.js +2 -0
  6. package/dist/src/flow-progress.js +9 -0
  7. package/dist/src/git-source.js +257 -15
  8. package/dist/src/preview-model.js +37 -0
  9. package/dist/src/process-runner.js +1 -0
  10. package/dist/src/profile-cli.js +12 -4
  11. package/dist/src/profile-export.js +23 -3
  12. package/dist/src/skill-update.js +1 -0
  13. package/dist/src/source-refresh-session.js +139 -0
  14. package/dist/src/sources.js +4 -4
  15. package/dist/src/tui/adoption-actions.js +21 -2
  16. package/dist/src/tui/app-actions.js +57 -47
  17. package/dist/src/tui/app.js +22 -4
  18. package/dist/src/tui/bulk-actions.js +45 -11
  19. package/dist/src/tui/catalog-actions.js +12 -0
  20. package/dist/src/tui/catalog-flow.js +31 -11
  21. package/dist/src/tui/catalog-panel.js +15 -13
  22. package/dist/src/tui/catalog-tree.js +7 -2
  23. package/dist/src/tui/catalog-view.js +12 -6
  24. package/dist/src/tui/file-picker.js +2 -1
  25. package/dist/src/tui/installations-browse.js +1 -1
  26. package/dist/src/tui/installations-panel.js +12 -4
  27. package/dist/src/tui/installations-rows.js +2 -2
  28. package/dist/src/tui/installations-tree.js +1 -1
  29. package/dist/src/tui/installations-view.js +16 -7
  30. package/dist/src/tui/keys.js +35 -4
  31. package/dist/src/tui/manage-actions.js +55 -27
  32. package/dist/src/tui/manage-panel.js +15 -29
  33. package/dist/src/tui/mode-keys.js +11 -1
  34. package/dist/src/tui/package-actions.js +25 -18
  35. package/dist/src/tui/package-rows.js +9 -4
  36. package/dist/src/tui/panel-parts.js +54 -37
  37. package/dist/src/tui/preview-render.js +93 -0
  38. package/dist/src/tui/preview-window.js +185 -20
  39. package/dist/src/tui/profile-view.js +54 -44
  40. package/dist/src/tui/render.js +13 -1
  41. package/dist/src/tui/safe-text.js +25 -0
  42. package/dist/src/tui/sources-mode.js +2 -2
  43. package/dist/src/tui/sources-recipes.js +53 -32
  44. package/dist/src/tui/sources-view.js +85 -39
  45. package/dist/src/tui/text-wrap.js +47 -3
  46. package/dist/src/tui/unmanaged-actions.js +17 -5
  47. package/dist/src/tui/updates-flow.js +11 -5
  48. package/dist/src/tui/updates-keys.js +2 -2
  49. package/dist/src/tui/updates-model.js +5 -2
  50. package/dist/src/tui/updates-panel.js +10 -9
  51. package/dist/src/tui/updates-preview.js +27 -44
  52. package/dist/src/tui/updates-view.js +15 -11
  53. package/dist/src/tui/updates.js +117 -33
  54. package/dist/src/update-flow.js +31 -15
  55. package/package.json +1 -1
@@ -1,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, { lines: lines, viewport: viewport });
197
204
  }
@@ -1,10 +1,11 @@
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) {
5
6
  if (evidence === undefined)
6
7
  return "unknown";
7
- return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : evidence.commit.slice(0, 7);
8
+ return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : `commit ${evidence.commit.slice(0, 7)}`;
8
9
  }
9
10
  function versionWithCommit(version, commit) {
10
11
  return `${version}${commit === undefined ? "" : ` (${commit.slice(0, 7)})`}`;
@@ -46,9 +47,13 @@ export function recordedVersionLabel(record) {
46
47
  if (record.verified !== undefined)
47
48
  return packageVersionLabel(record.verified);
48
49
  if (record.lastDelegatedCommit !== undefined)
49
- return record.lastDelegatedCommit.slice(0, 7);
50
+ return `commit ${record.lastDelegatedCommit.slice(0, 7)}`;
50
51
  return "unknown";
51
52
  }
53
+ /** The version the host reports when it states one, else what the selection record remembers. */
54
+ export function installedVersionLabel({ installed, observed }) {
55
+ return observed?.kind === "installed" && observed.version !== undefined ? observed.version : recordedVersionLabel(installed);
56
+ }
52
57
  export function packageRow(record, inspection) {
53
58
  return {
54
59
  selection: record.selection,
@@ -57,7 +62,7 @@ export function packageRow(record, inspection) {
57
62
  ...(inspection.installed.kind === "installed" && inspection.installed.version !== undefined
58
63
  ? { installedVersion: versionWithCommit(inspection.installed.version, inspection.installed.commit) }
59
64
  : inspection.installed.kind === "installed" && inspection.installed.commit !== undefined
60
- ? { installedVersion: inspection.installed.commit.slice(0, 7) }
65
+ ? { installedVersion: `commit ${inspection.installed.commit.slice(0, 7)}` }
61
66
  : {}),
62
67
  available: packageVersionLabel(inspection.available, inspection.availableCommit),
63
68
  status: packageRowStatus(record.selection, inspection),
@@ -98,7 +103,7 @@ export function packagePlanLines(plan, approval) {
98
103
  ...(plan.commands.length === 0
99
104
  ? ["This action runs no host command."]
100
105
  : plan.commands.flatMap((command, position) => [
101
- `Step ${position + 1} (${command.purpose}): ${JSON.stringify([command.executable, ...command.args])}`,
106
+ `Step ${position + 1} (${command.purpose}): [${[command.executable, ...command.args].map(quoteText).join(",")}]`,
102
107
  `Executable: ${command.resolvedPath ?? command.executable}`,
103
108
  ])),
104
109
  `Blast radius: ${plan.affects.map((coordinates) => displayPackageCoordinates(selection.source, coordinates)).join(", ")}`,
@@ -1,12 +1,15 @@
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 }) {
9
- return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.title, bold: true, children: title }), children] }));
10
+ return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [typeof title === "string"
11
+ ? _jsx(Text, { color: theme.title, bold: true, children: title })
12
+ : title.map((row, position) => _jsx(PreEscapedText, { color: theme.title, bold: true, wrap: "truncate", children: row }, position)), children] }));
10
13
  }
11
14
  export function FieldLine({ label, value }) {
12
15
  return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
@@ -40,7 +43,6 @@ export function ChecklistLines({ choices, cursor, isSelected, keyOf, label }) {
40
43
  export function HostChecklist({ choices, cursor, selected }) {
41
44
  return _jsx(ChecklistLines, { choices: choices, cursor: cursor, isSelected: (host) => selected.includes(host), keyOf: (host) => host, label: (host) => host });
42
45
  }
43
- /** Preview lines; `warnFirst` colors the first one as a warning. */
44
46
  const PREVIEW_FIELD_LABELS = [
45
47
  "File:", "Name:", "Platform:", "Status:", "Homepage:", "Owned Skills:",
46
48
  "App:", "Environment:", "Version:", "argv:", "Executable:", "Manual:",
@@ -106,51 +108,66 @@ function profilePreviewSpans(line) {
106
108
  previewSpan(` ${hosts[2]}`, theme.version)];
107
109
  return undefined;
108
110
  }
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;
111
+ /** The colour of a warning, host exposure notice or refusal, which the flows indent to different depths. */
112
+ function noticeColor(line) {
113
+ const text = line.trimStart();
114
+ if (text.startsWith("WARNING:") || text.startsWith("ADDITIONAL HOST EXPOSURE:") || text.startsWith("Skipped "))
115
+ return theme.warning;
116
+ return text.startsWith("Cannot install ") ? theme.error : undefined;
117
+ }
118
+ /** The hanging prefix of a continuation row, drawn in the gutter colour, then the part of the line's spans the row shows. */
119
+ function rowSpans(spans, prefix, offset, length) {
120
+ return [...(prefix === "" ? [] : [previewSpan(prefix, theme.field)]), ...sliceSpans(spans, offset, length)];
122
121
  }
123
- function PreviewLine({ line, position, warnFirst, logicalLine, offset = 0 }) {
122
+ function PreviewLine({ line, position, logicalLine, offset = 0, prefixLength = 0 }) {
123
+ const prefix = line.slice(0, prefixLength);
124
+ const textLength = line.length - prefixLength;
124
125
  const profileSpans = logicalLine === undefined ? profilePreviewSpans(line) : profilePreviewSpans(logicalLine);
125
126
  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);
127
+ const spans = logicalLine === undefined ? profileSpans : rowSpans(profileSpans, prefix, offset, textLength);
128
+ return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
128
129
  }
129
130
  const field = previewField(logicalLine ?? line);
130
131
  if (field) {
131
132
  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);
133
+ const spans = logicalLine === undefined ? fieldSpans : rowSpans(fieldSpans, prefix, offset, textLength);
134
+ return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, children: span.text }, index)) }, position);
134
135
  }
135
- return _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : theme.value, children: line }, position);
136
+ return _jsx(PreEscapedText, { color: noticeColor(logicalLine ?? line) ?? theme.value, children: line }, position);
136
137
  }
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)) });
138
+ export function PreviewLines({ lines }) {
139
+ return _jsx(_Fragment, { children: lines.map((line, position) => _jsx(PreviewLine, { line: escapeControlText(line), position: position }, position)) });
139
140
  }
140
141
  /** Under a list: the window indicator, how many items are marked, and the last result. */
141
142
  export function ListFooter({ indicator, selectedCount, message }) {
142
143
  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
144
  }
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 })] }));
145
+ function footerColor(role, reachedEnd) {
146
+ switch (role) {
147
+ case "read": return { color: theme.success };
148
+ case "reading": return { color: theme.warning };
149
+ case "consequence": return { color: theme.warning };
150
+ case "yes": return { color: theme.success, bold: true };
151
+ case "no": return { color: theme.error };
152
+ case "action": return reachedEnd ? { color: theme.success, bold: true } : { color: theme.muted };
153
+ case "position":
154
+ case "cancel": return { color: theme.legend };
155
+ }
156
+ }
157
+ /**
158
+ * A paged preview with the shared two-row footer: reading progress, then what `y` does. `[y]` stays
159
+ * dimmed until `viewport` reaches the end; callers ignore `y` until then using the same viewport.
160
+ */
161
+ export function PagedPreview({ lines: rawLines, styled: rawStyled, viewport }) {
162
+ // The same escape `previewViewport` applied, so rows, offsets and spans describe the same text. The rows themselves are already
163
+ // escaped, hence `PreEscapedText`; only the title goes through `BorderPanel`.
164
+ const lines = rawLines.map(escapeControlText);
165
+ const styled = rawStyled?.map((line) => line?.map((span) => ({ ...span, text: escapeControlText(span.text) })));
166
+ return (_jsxs(BorderPanel, { title: viewport.titleRows, children: [viewport.rows.map((line, position) => {
167
+ const logical = viewport.sourceLines[position];
168
+ const custom = styled?.[logical];
169
+ return custom === undefined
170
+ ? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position], prefixLength: viewport.prefixLengths[position] }, position)
171
+ : _jsx(PreEscapedText, { children: rowSpans(custom, line.slice(0, viewport.prefixLengths[position]), viewport.sourceOffsets[position], line.length - viewport.prefixLengths[position]).map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
172
+ }), 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
173
  }
@@ -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
+ }
@@ -1,51 +1,216 @@
1
+ import { useWindowSize } from "ink";
1
2
  import stringWidth from "string-width";
2
3
  import { headerTabRowCount } from "./header-layout.js";
4
+ import { ESCAPE_TEXT, namesEscapedCharacter, textUnits, UNSAFE_PATTERN } from "./text-wrap.js";
3
5
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
- function wrapLine(line, width) {
5
- const segments = [...graphemes.segment(line)];
6
+ // Text shown in the TUI comes from recipes, Sources, Skills and Hosts. Raw, an ESC or carriage return redraws the terminal over the
7
+ // lines under review and a bidi override reorders them; both also change the width the `y` gate was computed for. The zero-width joiner
8
+ // stays because emoji sequences such as a person at a laptop are made with it.
9
+ // A backslash run before text shaped like an escape we print (`\x1b`, `\u202e`, `\u{1f600}`), or before a character we escape.
10
+ const ESCAPABLE = new RegExp(`(\\\\+)(?:(${ESCAPE_TEXT})|(?=${UNSAFE_PATTERN}))|${UNSAFE_PATTERN}`, "giu");
11
+ /**
12
+ * `text` with control, format and line-separator characters replaced by visible escapes (`\x1b`, `\u202e`). A backslash run right
13
+ * before text that looks like one of those escapes is doubled, so a typed `\x1b` cannot pass for a real ESC; other backslashes,
14
+ * such as those of a Windows path, are left alone. Not idempotent: escape text once, where it enters a view.
15
+ */
16
+ export function escapeControlText(text) {
17
+ return text.replace(ESCAPABLE, (match, backslashes, typed) => {
18
+ if (backslashes !== undefined) {
19
+ return typed !== undefined && !namesEscapedCharacter(typed) ? match : backslashes.repeat(2) + (typed ?? "");
20
+ }
21
+ const code = match.codePointAt(0);
22
+ const hex = code.toString(16);
23
+ if (code <= 0xff)
24
+ return `\\x${hex.padStart(2, "0")}`;
25
+ return code <= 0xffff ? `\\u${hex.padStart(4, "0")}` : `\\u{${hex}}`;
26
+ });
27
+ }
28
+ function escapeChrome(chrome) {
29
+ return {
30
+ ...chrome,
31
+ title: escapeControlText(chrome.title),
32
+ ...(chrome.action === undefined ? {} : { action: escapeControlText(chrome.action) }),
33
+ ...(chrome.consequence === undefined ? {} : { consequence: escapeControlText(chrome.consequence) }),
34
+ ...(chrome.cancel === undefined ? {} : { cancel: escapeControlText(chrome.cancel) }),
35
+ };
36
+ }
37
+ const TREE_GUTTER = /^( *)([│┌├└])?( *)/u;
38
+ /**
39
+ * What continuation rows of a wrapped line start with: its indent and, in a step tree, the gutter that
40
+ * keeps running below its branch (`│` under `┌` and `├`, nothing under `└`). Empty when the prefix would
41
+ * leave less than half the width for text.
42
+ */
43
+ function hangingPrefix(line, width) {
44
+ const [, before = "", glyph = "", after = ""] = TREE_GUTTER.exec(line) ?? [];
45
+ const continued = glyph === "" ? "" : glyph === "└" ? " " : "│";
46
+ const prefix = `${before}${continued}${after}`;
47
+ return stringWidth(prefix) * 2 <= width ? prefix : "";
48
+ }
49
+ function wrapLine(line, width, hang = false) {
50
+ const segments = textUnits(line, width);
6
51
  if (segments.length === 0)
7
- return [{ row: "", offset: 0 }];
52
+ return [{ row: "", offset: 0, prefixLength: 0 }];
53
+ const prefix = hang ? hangingPrefix(line, width) : "";
8
54
  const wrapped = [];
9
55
  let row = "";
10
56
  let rowWidth = 0;
11
57
  let rowOffset = 0;
58
+ let prefixLength = 0;
59
+ let hasText = false;
12
60
  for (const { segment, index } of segments) {
13
61
  const segmentWidth = stringWidth(segment);
14
- if (row !== "" && rowWidth + segmentWidth > width) {
15
- wrapped.push({ row, offset: rowOffset });
16
- row = "";
17
- rowWidth = 0;
62
+ if (hasText && rowWidth + segmentWidth > width) {
63
+ wrapped.push({ row, offset: rowOffset, prefixLength });
64
+ row = prefix;
65
+ rowWidth = stringWidth(prefix);
66
+ prefixLength = prefix.length;
67
+ hasText = false;
18
68
  rowOffset = index;
19
69
  }
20
70
  row += segment;
21
71
  rowWidth += segmentWidth;
72
+ hasText = true;
22
73
  }
23
- wrapped.push({ row, offset: rowOffset });
74
+ wrapped.push({ row, offset: rowOffset, prefixLength });
24
75
  return wrapped;
25
76
  }
26
- export function previewViewport(lines, cursor, terminalRows, terminalColumns, chrome = { title: "Confirmation", questions: ["Scroll to end before y; n cancels.", "Confirm? y/n"] }) {
77
+ const MAX_BAR_WIDTH = 12;
78
+ /** The consequence with its `y/n` keys picked out, as in a one-screen confirmation. */
79
+ function consequenceSpans(consequence) {
80
+ const marker = consequence.indexOf("y/n");
81
+ if (marker < 0)
82
+ return [{ text: consequence, role: "consequence" }];
83
+ return [
84
+ { text: consequence.slice(0, marker), role: "consequence" },
85
+ { text: "y", role: "yes" },
86
+ { text: "/", role: "position" },
87
+ { text: "n", role: "no" },
88
+ ...(marker + 3 < consequence.length ? [{ text: consequence.slice(marker + 3), role: "position" }] : []),
89
+ ];
90
+ }
91
+ /** The progress line and the action line, before wrapping. */
92
+ function footerSpans(state, chrome, width) {
93
+ const range = (total, start, end) => ` · ${start}–${end} of ${total}`;
94
+ const position = (cursor, start, end) => `Review ${cursor} of ${state.total}${state.windowed ? range(state.total, start, end) : ""}`;
95
+ // Sized for the widest position text so the bar, and so the footer height, never changes while paging.
96
+ const widest = position(state.total, state.total, state.total);
97
+ const barWidth = Math.min(Math.max(width - stringWidth(`read 100% · ${widest}`), 0), MAX_BAR_WIDTH);
98
+ const read = state.total === 0 ? 1 : state.end / state.total;
99
+ const filled = Math.floor(read * barWidth);
100
+ const bar = barWidth === 0 ? "" : `${"█".repeat(filled)}${"░".repeat(barWidth - filled)} `;
101
+ const progress = state.reachedEnd
102
+ ? [{ text: "read ✓", role: "read" }]
103
+ : [{ text: `read ${bar}${Math.floor(read * 100)}%`, role: "reading" }];
104
+ if (state.total > 0)
105
+ progress.push({ text: ` · ${position(state.cursor + 1, state.start + 1, state.end)}`, role: "position" });
106
+ if (!state.reachedEnd)
107
+ progress.push({ text: " · arrows/Pg page", role: "position" });
108
+ else if (chrome.consequence !== undefined)
109
+ progress.push({ text: " · ", role: "position" }, ...consequenceSpans(chrome.consequence));
110
+ const actions = chrome.action === undefined ? [] : [{ text: `[y] ${chrome.action}`, role: "action" }, { text: " ", role: "cancel" }];
111
+ actions.push({ text: chrome.cancel ?? "[n] cancel", role: "cancel" });
112
+ return [progress, actions];
113
+ }
114
+ /** The spans covering `length` UTF-16 units of their joined text from `offset`. */
115
+ export function sliceSpans(spans, offset, length) {
116
+ const end = offset + length;
117
+ let spanStart = 0;
118
+ const visible = [];
119
+ for (const span of spans) {
120
+ const spanEnd = spanStart + span.text.length;
121
+ const start = Math.max(offset, spanStart);
122
+ const stop = Math.min(end, spanEnd);
123
+ if (start < stop)
124
+ visible.push({ ...span, text: span.text.slice(start - spanStart, stop - spanStart) });
125
+ spanStart = spanEnd;
126
+ }
127
+ return visible;
128
+ }
129
+ function wrapSpans(spans, width) {
130
+ return wrapLine(spans.map(({ text }) => text).join(""), width).map(({ row, offset }) => sliceSpans(spans, offset, row.length));
131
+ }
132
+ /** `rows` cut to `maxRows`, the last kept row ending in an ellipsis when something was cut. */
133
+ function clipRows(rows, maxRows) {
134
+ if (rows.length <= maxRows)
135
+ return rows;
136
+ const last = [...rows[maxRows - 1]];
137
+ const tail = last.pop();
138
+ const shortened = [...graphemes.segment(tail.text)].slice(0, -1).map(({ segment }) => segment).join("");
139
+ return [...rows.slice(0, maxRows - 1), [...last, ...(shortened ? [{ ...tail, text: shortened }] : []), { ...tail, text: "…" }]];
140
+ }
141
+ /**
142
+ * The footer lines wrapped to `width` within `maxRows`: the progress row, which carries the
143
+ * consequence, keeps all but one row if it needs them, the action row the rest. A cut row ends in an
144
+ * ellipsis instead of pushing the panel past the terminal.
145
+ */
146
+ function layoutFooter([progressLine, actionLine], width, maxRows) {
147
+ const progress = wrapSpans(progressLine, width);
148
+ const actions = wrapSpans(actionLine, width);
149
+ const progressRows = Math.min(progress.length, Math.max(maxRows - 1, 1));
150
+ return [...clipRows(progress, progressRows), ...clipRows(actions, Math.max(maxRows - progressRows, 1))];
151
+ }
152
+ /** Footer rows for the widest position and both reading states, so paging never changes the height. */
153
+ function reservedFooterRows(total, windowed, chrome, width, maxRows) {
154
+ const widest = { cursor: Math.max(total - 1, 0), start: Math.max(total - 1, 0), end: total, total, windowed };
155
+ return Math.max(layoutFooter(footerSpans({ ...widest, reachedEnd: false }, chrome, width), width, maxRows).length, layoutFooter(footerSpans({ ...widest, reachedEnd: true }, chrome, width), width, maxRows).length);
156
+ }
157
+ /** `title` cut, with an ellipsis, to the longest start that wraps to at most `maxRows` once escaped. */
158
+ function clipTitle(title, width, maxRows) {
159
+ const fits = (text) => wrapLine(escapeControlText(text), width).length <= maxRows;
160
+ if (fits(title))
161
+ return title;
162
+ const parts = [...graphemes.segment(title)].map(({ segment }) => segment);
163
+ let low = 0;
164
+ let high = parts.length;
165
+ while (low < high) {
166
+ const middle = Math.ceil((low + high) / 2);
167
+ if (fits(`${parts.slice(0, middle).join("")}…`))
168
+ low = middle;
169
+ else
170
+ high = middle - 1;
171
+ }
172
+ return `${parts.slice(0, low).join("")}…`;
173
+ }
174
+ export function previewViewport(rawLines, cursor, terminalRows, terminalColumns, rawChrome) {
175
+ const lines = rawLines.map(escapeControlText);
176
+ const chrome = escapeChrome(rawChrome);
27
177
  const contentWidth = Math.max(terminalColumns - 8, 1);
28
- const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map(({ row, offset }) => ({ row, sourceLine, offset })));
178
+ const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth, true).map((wrappedRow) => ({ ...wrappedRow, sourceLine })));
29
179
  const previewRows = wrapped.map(({ row }) => row);
180
+ const total = previewRows.length;
30
181
  const headerRows = headerTabRowCount(contentWidth) + 3;
31
- const titleRows = wrapLine(chrome.title, contentWidth).length;
32
- const questionRows = Math.max(...chrome.questions.map(question => wrapLine(question, contentWidth).length));
33
- const panelChromeRows = 2 + titleRows + questionRows + (chrome.footerRows ?? 1);
34
182
  const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
35
- const availableRows = Math.max(terminalRows - headerRows - 1 - panelChromeRows - reservedRows, 1);
36
- const height = previewRows.length > availableRows ? Math.max(availableRows - 1, 1) : availableRows;
37
- const selected = Math.min(Math.max(cursor, 0), Math.max(previewRows.length - 1, 0));
38
- const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(previewRows.length - height, 0));
39
- const end = Math.min(start + height, previewRows.length);
183
+ // The footer, with its question and its action, is reserved first; the title takes what remains after a row of content.
184
+ const footerRows = Math.max(reservedFooterRows(total, false, chrome, contentWidth, Infinity), reservedFooterRows(total, true, chrome, contentWidth, Infinity));
185
+ const titleBudget = Math.max(terminalRows - headerRows - 1 - 2 - reservedRows - 1 - footerRows, 1);
186
+ const titleRows = wrapLine(escapeControlText(clipTitle(rawChrome.title, contentWidth, titleBudget)), contentWidth).map(({ row }) => row);
187
+ const fixedRows = headerRows + 1 + 2 + titleRows.length + reservedRows;
188
+ // At least one preview row stays visible; the footer is cut to what remains.
189
+ const maxFooterRows = Math.max(terminalRows - fixedRows - 1, 2);
190
+ const rowsWithFooter = (windowed) => Math.max(terminalRows - fixedRows - reservedFooterRows(total, windowed, chrome, contentWidth, maxFooterRows), 1);
191
+ const windowed = total > rowsWithFooter(false);
192
+ const height = rowsWithFooter(windowed);
193
+ const selected = Math.min(Math.max(cursor, 0), Math.max(total - 1, 0));
194
+ const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(total - height, 0));
195
+ const end = Math.min(start + height, total);
196
+ const reachedEnd = end === total;
40
197
  return {
198
+ titleRows,
41
199
  rows: previewRows.slice(start, end),
42
200
  sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
43
201
  sourceOffsets: wrapped.slice(start, end).map(({ offset }) => offset),
202
+ prefixLengths: wrapped.slice(start, end).map(({ prefixLength }) => prefixLength),
44
203
  start,
45
204
  end,
46
- total: previewRows.length,
205
+ total,
47
206
  cursor: selected,
48
207
  height,
49
- reachedEnd: end === previewRows.length,
208
+ reachedEnd,
209
+ footer: layoutFooter(footerSpans({ reachedEnd, cursor: selected, start, end, total, windowed }, chrome, contentWidth), contentWidth, maxFooterRows),
50
210
  };
51
211
  }
212
+ /** The viewport of a preview at the current terminal size; key handling and rendering share it. */
213
+ export function usePreviewGeometry() {
214
+ const { rows, columns } = useWindowSize();
215
+ return (lines, cursor, chrome) => previewViewport(lines, cursor, rows || 24, columns || 80, chrome);
216
+ }