@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.
- package/CHANGELOG.md +61 -0
- package/README.md +3 -1
- package/dist/src/app-flow.js +8 -1
- package/dist/src/atomic-file.js +29 -3
- package/dist/src/cli.js +2 -0
- package/dist/src/flow-progress.js +9 -0
- package/dist/src/git-source.js +257 -15
- package/dist/src/preview-model.js +37 -0
- package/dist/src/process-runner.js +1 -0
- package/dist/src/profile-cli.js +12 -4
- package/dist/src/profile-export.js +23 -3
- package/dist/src/skill-update.js +1 -0
- package/dist/src/source-refresh-session.js +139 -0
- package/dist/src/sources.js +4 -4
- package/dist/src/tui/adoption-actions.js +21 -2
- package/dist/src/tui/app-actions.js +57 -47
- package/dist/src/tui/app.js +22 -4
- package/dist/src/tui/bulk-actions.js +45 -11
- package/dist/src/tui/catalog-actions.js +12 -0
- package/dist/src/tui/catalog-flow.js +31 -11
- package/dist/src/tui/catalog-panel.js +15 -13
- package/dist/src/tui/catalog-tree.js +7 -2
- package/dist/src/tui/catalog-view.js +12 -6
- package/dist/src/tui/file-picker.js +2 -1
- package/dist/src/tui/installations-browse.js +1 -1
- package/dist/src/tui/installations-panel.js +12 -4
- package/dist/src/tui/installations-rows.js +2 -2
- package/dist/src/tui/installations-tree.js +1 -1
- package/dist/src/tui/installations-view.js +16 -7
- package/dist/src/tui/keys.js +35 -4
- package/dist/src/tui/manage-actions.js +55 -27
- package/dist/src/tui/manage-panel.js +15 -29
- package/dist/src/tui/mode-keys.js +11 -1
- package/dist/src/tui/package-actions.js +25 -18
- package/dist/src/tui/package-rows.js +9 -4
- package/dist/src/tui/panel-parts.js +54 -37
- package/dist/src/tui/preview-render.js +93 -0
- package/dist/src/tui/preview-window.js +185 -20
- package/dist/src/tui/profile-view.js +54 -44
- package/dist/src/tui/render.js +13 -1
- package/dist/src/tui/safe-text.js +25 -0
- package/dist/src/tui/sources-mode.js +2 -2
- package/dist/src/tui/sources-recipes.js +53 -32
- package/dist/src/tui/sources-view.js +85 -39
- package/dist/src/tui/text-wrap.js +47 -3
- package/dist/src/tui/unmanaged-actions.js +17 -5
- package/dist/src/tui/updates-flow.js +11 -5
- package/dist/src/tui/updates-keys.js +2 -2
- package/dist/src/tui/updates-model.js +5 -2
- package/dist/src/tui/updates-panel.js +10 -9
- package/dist/src/tui/updates-preview.js +27 -44
- package/dist/src/tui/updates-view.js +15 -11
- package/dist/src/tui/updates.js +117 -33
- package/dist/src/update-flow.js +31 -15
- 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 {
|
|
7
|
-
import {
|
|
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 ${
|
|
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:
|
|
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:
|
|
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:
|
|
146
|
-
|
|
147
|
-
: `${
|
|
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
|
|
156
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
199
|
+
/** Renders a Package prompt with the shared paged preview. */
|
|
194
200
|
export function PackagePanel({ mode }) {
|
|
195
201
|
const { lines, chrome } = packagePreview(mode);
|
|
196
|
-
|
|
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}): ${
|
|
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
|
|
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 {
|
|
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: [
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
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 :
|
|
127
|
-
return _jsx(
|
|
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 :
|
|
133
|
-
return _jsx(
|
|
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(
|
|
136
|
+
return _jsx(PreEscapedText, { color: noticeColor(logicalLine ?? line) ?? theme.value, children: line }, position);
|
|
136
137
|
}
|
|
137
|
-
export function PreviewLines({ lines
|
|
138
|
-
return _jsx(_Fragment, { children: lines.map((line, position) => _jsx(PreviewLine, { line: line, position: 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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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 (
|
|
15
|
-
wrapped.push({ row, offset: rowOffset });
|
|
16
|
-
row =
|
|
17
|
-
rowWidth =
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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
|
|
205
|
+
total,
|
|
47
206
|
cursor: selected,
|
|
48
207
|
height,
|
|
49
|
-
reachedEnd
|
|
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
|
+
}
|