@treeseed/cli 0.4.8 → 0.4.10
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/dist/cli/handlers/config-ui.d.ts +72 -0
- package/dist/cli/handlers/config-ui.js +785 -0
- package/dist/cli/handlers/config.js +105 -35
- package/dist/cli/handlers/export.d.ts +2 -0
- package/dist/cli/handlers/export.js +28 -0
- package/dist/cli/help-ui.d.ts +3 -0
- package/dist/cli/help-ui.js +490 -0
- package/dist/cli/help.d.ts +26 -0
- package/dist/cli/help.js +332 -91
- package/dist/cli/operations-registry.js +678 -28
- package/dist/cli/operations-types.d.ts +37 -2
- package/dist/cli/registry.d.ts +1 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/runtime.js +22 -9
- package/dist/cli/ui/framework.d.ts +159 -0
- package/dist/cli/ui/framework.js +296 -0
- package/dist/cli/ui/mouse.d.ts +11 -0
- package/dist/cli/ui/mouse.js +72 -0
- package/package.json +7 -4
|
@@ -1,54 +1,124 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
applyTreeseedSafeRepairs,
|
|
3
|
+
collectTreeseedConfigContext,
|
|
4
|
+
findNearestTreeseedRoot
|
|
5
|
+
} from "@treeseed/sdk/workflow-support";
|
|
6
|
+
import { fail, guidedResult } from "./utils.js";
|
|
7
|
+
import { buildCliConfigPages, runCliConfigEditor } from "./config-ui.js";
|
|
4
8
|
import { createWorkflowSdk, renderWorkflowNextSteps, workflowErrorResult } from "./workflow.js";
|
|
9
|
+
function normalizeConfigScopes(value) {
|
|
10
|
+
const requested = Array.isArray(value) ? value.map(String) : typeof value === "string" ? [value] : ["all"];
|
|
11
|
+
if (requested.includes("all")) {
|
|
12
|
+
return ["local", "staging", "prod"];
|
|
13
|
+
}
|
|
14
|
+
return ["local", "staging", "prod"].filter((scope) => requested.includes(scope));
|
|
15
|
+
}
|
|
16
|
+
function formatPrintEnvReports(payload) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
for (const report of payload.reports ?? []) {
|
|
19
|
+
lines.push(`Resolved environment values for ${report.scope}`);
|
|
20
|
+
lines.push(payload.secretsRevealed ? "Secrets are shown." : "Secret values are masked.");
|
|
21
|
+
for (const entry of report.environment?.entries ?? []) {
|
|
22
|
+
lines.push(`${entry.id}=${entry.displayValue} (${entry.source})`);
|
|
23
|
+
}
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push(`Provider connection checks for ${report.scope}`);
|
|
26
|
+
for (const check of report.provider?.checks ?? []) {
|
|
27
|
+
const status = check.ready ? "ready" : check.skipped ? "skipped" : "failed";
|
|
28
|
+
lines.push(`${check.provider}: ${status} - ${check.detail}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push("");
|
|
31
|
+
}
|
|
32
|
+
return lines.filter((line, index, all) => !(line === "" && all[index - 1] === ""));
|
|
33
|
+
}
|
|
34
|
+
function renderConfigResult(commandName, result) {
|
|
35
|
+
const payload = result.payload;
|
|
36
|
+
const toolHealth = payload.toolHealth;
|
|
37
|
+
const summary = payload.mode === "print-env-only" ? "Treeseed config environment report completed." : payload.mode === "rotate-machine-key" ? "Treeseed machine key rotated successfully." : "Treeseed config completed successfully.";
|
|
38
|
+
return guidedResult({
|
|
39
|
+
command: commandName,
|
|
40
|
+
summary,
|
|
41
|
+
facts: [
|
|
42
|
+
{ label: "Mode", value: payload.mode },
|
|
43
|
+
{ label: "Scopes", value: Array.isArray(payload.scopes) ? payload.scopes.join(", ") : "(none)" },
|
|
44
|
+
{ label: "Sync", value: payload.sync ?? "all" },
|
|
45
|
+
{ label: "Safe repairs", value: Array.isArray(payload.repairs) ? payload.repairs.length : 0 },
|
|
46
|
+
{ label: "Machine config", value: payload.configPath },
|
|
47
|
+
{ label: "Machine key", value: payload.keyPath },
|
|
48
|
+
{ label: "GitHub CLI", value: toolHealth?.githubCli?.available ? "ready" : "missing" },
|
|
49
|
+
{ label: "gh act", value: toolHealth?.ghActExtension?.available ? "ready" : "missing" },
|
|
50
|
+
{ label: "Docker", value: toolHealth?.dockerDaemon?.available ? "ready" : "missing" },
|
|
51
|
+
{ label: "ACT verify", value: toolHealth?.actVerificationReady ? "ready" : "not ready" }
|
|
52
|
+
],
|
|
53
|
+
nextSteps: renderWorkflowNextSteps(result),
|
|
54
|
+
report: payload
|
|
55
|
+
});
|
|
56
|
+
}
|
|
5
57
|
const handleConfig = async (invocation, context) => {
|
|
6
|
-
const rl = readline.createInterface({ input, output });
|
|
7
58
|
try {
|
|
8
59
|
const workflow = createWorkflowSdk(context, {
|
|
9
60
|
write: context.outputFormat === "json" ? (() => {
|
|
10
|
-
}) : context.write
|
|
11
|
-
prompt: async (message) => {
|
|
12
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
13
|
-
return "";
|
|
14
|
-
}
|
|
15
|
-
return rl.question(message);
|
|
16
|
-
}
|
|
61
|
+
}) : context.write
|
|
17
62
|
});
|
|
63
|
+
const scopes = normalizeConfigScopes(invocation.args.environment);
|
|
64
|
+
const sync = invocation.args.sync;
|
|
65
|
+
const interactive = context.outputFormat !== "json" && process.stdin.isTTY && process.stdout.isTTY;
|
|
66
|
+
if (interactive && invocation.args.printEnvOnly !== true && invocation.args.rotateMachineKey !== true) {
|
|
67
|
+
const tenantRoot = findNearestTreeseedRoot(context.cwd) ?? context.cwd;
|
|
68
|
+
if (!tenantRoot) {
|
|
69
|
+
return fail("Treeseed config requires a Treeseed project. Run the command from inside a tenant or initialize one first.");
|
|
70
|
+
}
|
|
71
|
+
applyTreeseedSafeRepairs(tenantRoot);
|
|
72
|
+
const configContext = collectTreeseedConfigContext({
|
|
73
|
+
tenantRoot,
|
|
74
|
+
scopes,
|
|
75
|
+
env: context.env
|
|
76
|
+
});
|
|
77
|
+
const editorResult = await runCliConfigEditor(configContext, {
|
|
78
|
+
initialViewMode: invocation.args.full === true ? "full" : "startup"
|
|
79
|
+
});
|
|
80
|
+
if (editorResult === null) {
|
|
81
|
+
return fail("Treeseed config canceled.");
|
|
82
|
+
}
|
|
83
|
+
const updates = buildCliConfigPages(configContext, "all", editorResult.overrides, "full").map((page) => ({
|
|
84
|
+
scope: page.scope,
|
|
85
|
+
entryId: page.entry.id,
|
|
86
|
+
value: page.finalValue,
|
|
87
|
+
reused: !(page.key in editorResult.overrides)
|
|
88
|
+
}));
|
|
89
|
+
const result2 = await workflow.config({
|
|
90
|
+
environment: scopes,
|
|
91
|
+
sync,
|
|
92
|
+
printEnv: invocation.args.printEnv === true,
|
|
93
|
+
showSecrets: invocation.args.showSecrets === true,
|
|
94
|
+
nonInteractive: true,
|
|
95
|
+
updates
|
|
96
|
+
});
|
|
97
|
+
return renderConfigResult(invocation.commandName || "config", result2);
|
|
98
|
+
}
|
|
18
99
|
const result = await workflow.config({
|
|
19
100
|
environment: invocation.args.environment,
|
|
20
|
-
sync
|
|
101
|
+
sync,
|
|
21
102
|
printEnv: invocation.args.printEnv === true,
|
|
22
103
|
printEnvOnly: invocation.args.printEnvOnly === true,
|
|
23
104
|
showSecrets: invocation.args.showSecrets === true,
|
|
24
105
|
rotateMachineKey: invocation.args.rotateMachineKey === true,
|
|
25
106
|
nonInteractive: context.outputFormat === "json"
|
|
26
107
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{ label: "Machine key", value: payload.keyPath },
|
|
40
|
-
{ label: "GitHub CLI", value: toolHealth?.githubCli?.available ? "ready" : "missing" },
|
|
41
|
-
{ label: "gh act", value: toolHealth?.ghActExtension?.available ? "ready" : "missing" },
|
|
42
|
-
{ label: "Docker", value: toolHealth?.dockerDaemon?.available ? "ready" : "missing" },
|
|
43
|
-
{ label: "ACT verify", value: toolHealth?.actVerificationReady ? "ready" : "not ready" }
|
|
44
|
-
],
|
|
45
|
-
nextSteps: renderWorkflowNextSteps(result),
|
|
46
|
-
report: payload
|
|
47
|
-
});
|
|
108
|
+
if (context.outputFormat !== "json" && result.payload.mode === "print-env-only") {
|
|
109
|
+
return {
|
|
110
|
+
exitCode: 0,
|
|
111
|
+
stdout: formatPrintEnvReports(result.payload),
|
|
112
|
+
report: {
|
|
113
|
+
command: invocation.commandName || "config",
|
|
114
|
+
ok: true,
|
|
115
|
+
...result.payload
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return renderConfigResult(invocation.commandName || "config", result);
|
|
48
120
|
} catch (error) {
|
|
49
121
|
return workflowErrorResult(error);
|
|
50
|
-
} finally {
|
|
51
|
-
rl.close();
|
|
52
122
|
}
|
|
53
123
|
};
|
|
54
124
|
export {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { guidedResult } from "./utils.js";
|
|
2
|
+
import { createWorkflowSdk, workflowErrorResult } from "./workflow.js";
|
|
3
|
+
const handleExport = async (invocation, context) => {
|
|
4
|
+
try {
|
|
5
|
+
const directory = typeof invocation.positionals[0] === "string" && invocation.positionals[0].trim().length > 0 ? invocation.positionals[0] : void 0;
|
|
6
|
+
const result = await createWorkflowSdk(context).export({ directory });
|
|
7
|
+
const exported = result.payload;
|
|
8
|
+
return guidedResult({
|
|
9
|
+
command: "export",
|
|
10
|
+
summary: "Treeseed export completed successfully.",
|
|
11
|
+
facts: [
|
|
12
|
+
{ label: "Directory", value: exported.directory },
|
|
13
|
+
{ label: "Output", value: exported.outputPath },
|
|
14
|
+
{ label: "Branch", value: exported.branch },
|
|
15
|
+
{ label: "Timestamp", value: exported.timestamp },
|
|
16
|
+
{ label: "Files", value: exported.summary?.totalFiles },
|
|
17
|
+
{ label: "Tokens", value: exported.summary?.totalTokens },
|
|
18
|
+
{ label: "Bundled paths", value: Array.isArray(exported.includedBundlePaths) ? exported.includedBundlePaths.length : 0 }
|
|
19
|
+
],
|
|
20
|
+
report: exported
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return workflowErrorResult(error);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export {
|
|
27
|
+
handleExport
|
|
28
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { TreeseedCommandContext } from './operations-types.js';
|
|
2
|
+
export declare function renderTreeseedHelpInk(commandName: string | null | undefined, context?: Pick<TreeseedCommandContext, 'outputFormat' | 'interactiveUi'>): Promise<number | null>;
|
|
3
|
+
export declare function shouldUseInkHelp(context: Pick<TreeseedCommandContext, 'outputFormat' | 'interactiveUi'>): boolean;
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { Box, render, Text, useApp, useInput, useWindowSize } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import {
|
|
4
|
+
AppFrame,
|
|
5
|
+
clampOffset,
|
|
6
|
+
computeViewportLayout,
|
|
7
|
+
ensureVisible,
|
|
8
|
+
findClickableRegion,
|
|
9
|
+
popNavigationEntry,
|
|
10
|
+
pushNavigationEntry,
|
|
11
|
+
routeWheelDeltaToScrollRegion,
|
|
12
|
+
scrollOffsetByDelta,
|
|
13
|
+
scrollOffsetByPage,
|
|
14
|
+
SecondaryButton,
|
|
15
|
+
SidebarList,
|
|
16
|
+
StatusBar,
|
|
17
|
+
truncateLine,
|
|
18
|
+
wrapText
|
|
19
|
+
} from "./ui/framework.js";
|
|
20
|
+
import { useTerminalMouse } from "./ui/mouse.js";
|
|
21
|
+
import { buildTreeseedHelpView } from "./help.js";
|
|
22
|
+
function sidebarTopIndicatorNeeded(totalSize, viewportSize, offset) {
|
|
23
|
+
return totalSize > 0 && offset > 0;
|
|
24
|
+
}
|
|
25
|
+
function sidebarItemRect(layout, offset, index, totalSections) {
|
|
26
|
+
const itemTop = layout.topBarHeight + 1 + 1 + (sidebarTopIndicatorNeeded(totalSections, Math.max(1, layout.bodyHeight - 4), offset) ? 1 : 0);
|
|
27
|
+
return {
|
|
28
|
+
x: 1,
|
|
29
|
+
y: itemTop + index,
|
|
30
|
+
width: layout.sidebarWidth - 2,
|
|
31
|
+
height: 1
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function detailRowRect(layout, rowIndex) {
|
|
35
|
+
return {
|
|
36
|
+
x: layout.sidebarWidth + 2,
|
|
37
|
+
y: layout.topBarHeight + 1 + rowIndex,
|
|
38
|
+
width: layout.contentWidth - 2,
|
|
39
|
+
height: 1
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function toneForEntry(entry) {
|
|
43
|
+
switch (entry.accent) {
|
|
44
|
+
case "flag":
|
|
45
|
+
return { color: "magenta", bold: true };
|
|
46
|
+
case "argument":
|
|
47
|
+
return { color: entry.required ? "yellow" : "cyan", bold: true };
|
|
48
|
+
case "example":
|
|
49
|
+
return { color: "green", bold: true };
|
|
50
|
+
case "alias":
|
|
51
|
+
case "related":
|
|
52
|
+
return { color: "blue", bold: true };
|
|
53
|
+
case "command":
|
|
54
|
+
default:
|
|
55
|
+
return { color: "cyan", bold: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function styledWrap(text, width, style = {}, targetCommand) {
|
|
59
|
+
const wrapped = wrapText(text, width);
|
|
60
|
+
return wrapped.map((line, index) => ({
|
|
61
|
+
text: line,
|
|
62
|
+
...style,
|
|
63
|
+
targetCommand: index === 0 ? targetCommand : void 0
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
function buildSectionRows(section, width) {
|
|
67
|
+
const rows = [];
|
|
68
|
+
for (const entry of section.entries ?? []) {
|
|
69
|
+
rows.push(...styledWrap(entry.label, width, toneForEntry(entry), entry.targetCommand));
|
|
70
|
+
if (entry.summary) {
|
|
71
|
+
rows.push(...styledWrap(` ${entry.summary}`, width, { color: "gray" }));
|
|
72
|
+
}
|
|
73
|
+
rows.push({ text: "", color: "gray" });
|
|
74
|
+
}
|
|
75
|
+
for (const line of section.lines ?? []) {
|
|
76
|
+
rows.push(...styledWrap(line, width, { color: "white" }));
|
|
77
|
+
}
|
|
78
|
+
while (rows.length > 0 && !rows.at(-1)?.text) {
|
|
79
|
+
rows.pop();
|
|
80
|
+
}
|
|
81
|
+
return rows.length > 0 ? rows : [{ text: "(empty)", color: "gray" }];
|
|
82
|
+
}
|
|
83
|
+
function computeHelpViewportLayout(rows, columns) {
|
|
84
|
+
const layout = computeViewportLayout(rows, columns, { topBarHeight: 4, footerHeight: 2 });
|
|
85
|
+
const sidebarWidth = Math.max(22, Math.min(30, Math.floor(layout.columns * 0.27)));
|
|
86
|
+
const contentWidth = Math.max(38, layout.columns - sidebarWidth - 1);
|
|
87
|
+
return {
|
|
88
|
+
...layout,
|
|
89
|
+
sidebarWidth,
|
|
90
|
+
contentWidth
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function detailViewport(rows, height, offset) {
|
|
94
|
+
const viewportSize = Math.max(1, height - 3);
|
|
95
|
+
const safeOffset = clampOffset(offset, rows.length, viewportSize);
|
|
96
|
+
return {
|
|
97
|
+
rows: rows.slice(safeOffset, safeOffset + viewportSize),
|
|
98
|
+
offset: safeOffset,
|
|
99
|
+
viewportSize,
|
|
100
|
+
totalSize: rows.length
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function buttonLabel(label) {
|
|
104
|
+
return `[ ${label} ]`;
|
|
105
|
+
}
|
|
106
|
+
function buttonRect(label, x, y) {
|
|
107
|
+
return { x, y, width: buttonLabel(label).length, height: 1 };
|
|
108
|
+
}
|
|
109
|
+
function navigableRowIndices(rows) {
|
|
110
|
+
return rows.flatMap((row, index) => row.targetCommand ? [index] : []);
|
|
111
|
+
}
|
|
112
|
+
function nearestNavigableRow(rows, fromIndex = 0) {
|
|
113
|
+
const indices = navigableRowIndices(rows);
|
|
114
|
+
if (indices.length === 0) {
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
const match = indices.find((index) => index >= fromIndex);
|
|
118
|
+
return match ?? indices[0] ?? -1;
|
|
119
|
+
}
|
|
120
|
+
function nextNavigableRow(rows, currentIndex, direction) {
|
|
121
|
+
const indices = navigableRowIndices(rows);
|
|
122
|
+
if (indices.length === 0) {
|
|
123
|
+
return -1;
|
|
124
|
+
}
|
|
125
|
+
if (currentIndex < 0) {
|
|
126
|
+
return direction > 0 ? indices[0] ?? -1 : indices.at(-1) ?? -1;
|
|
127
|
+
}
|
|
128
|
+
if (direction > 0) {
|
|
129
|
+
const next2 = indices.find((index) => index > currentIndex);
|
|
130
|
+
return next2 ?? currentIndex;
|
|
131
|
+
}
|
|
132
|
+
const reversed = [...indices].reverse();
|
|
133
|
+
const next = reversed.find((index) => index < currentIndex);
|
|
134
|
+
return next ?? currentIndex;
|
|
135
|
+
}
|
|
136
|
+
function HelpDetailPanel(props) {
|
|
137
|
+
const contentRows = Math.max(1, props.height - 3);
|
|
138
|
+
return React.createElement(
|
|
139
|
+
Box,
|
|
140
|
+
{ flexDirection: "column", width: props.width, height: props.height, borderStyle: "round", borderColor: props.focused ? "cyan" : "gray", overflow: "hidden" },
|
|
141
|
+
React.createElement(Text, { color: "yellow", bold: true }, truncateLine(props.title, props.width - 2)),
|
|
142
|
+
...Array.from({ length: contentRows }, (_, index) => {
|
|
143
|
+
const row = props.rows[index] ?? { text: "" };
|
|
144
|
+
const selected = index === props.selectedRowIndex && Boolean(row.targetCommand);
|
|
145
|
+
return React.createElement(
|
|
146
|
+
Text,
|
|
147
|
+
{
|
|
148
|
+
key: `detail-${index}`,
|
|
149
|
+
color: selected ? "black" : row.color ?? "white",
|
|
150
|
+
backgroundColor: selected ? "cyan" : void 0,
|
|
151
|
+
bold: row.bold
|
|
152
|
+
},
|
|
153
|
+
truncateLine(row.text, props.width - 2)
|
|
154
|
+
);
|
|
155
|
+
}),
|
|
156
|
+
React.createElement(
|
|
157
|
+
Text,
|
|
158
|
+
{ color: "gray" },
|
|
159
|
+
truncateLine(
|
|
160
|
+
`${props.scrollState.offset > 0 ? "\u2191" : " "} ${props.scrollState.offset + props.scrollState.viewportSize < props.scrollState.totalSize ? "\u2193" : " "} lines ${props.scrollState.totalSize === 0 ? "0-0" : `${Math.min(props.scrollState.totalSize, props.scrollState.offset + 1)}-${Math.min(props.scrollState.totalSize, props.scrollState.offset + props.scrollState.viewportSize)}`} of ${props.scrollState.totalSize}`,
|
|
161
|
+
props.width - 2
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
async function renderTreeseedHelpInk(commandName, context = {}) {
|
|
167
|
+
if (!canRenderInkHelp({ outputFormat: context.outputFormat ?? "human", interactiveUi: context.interactiveUi })) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return await new Promise((resolveSession) => {
|
|
171
|
+
let finished = false;
|
|
172
|
+
let instance;
|
|
173
|
+
const finish = (exitCode) => {
|
|
174
|
+
if (finished) return;
|
|
175
|
+
finished = true;
|
|
176
|
+
instance?.unmount();
|
|
177
|
+
resolveSession(exitCode);
|
|
178
|
+
};
|
|
179
|
+
function App() {
|
|
180
|
+
const { exit } = useApp();
|
|
181
|
+
const windowSize = useWindowSize();
|
|
182
|
+
const layout = computeHelpViewportLayout(windowSize?.rows ?? 24, windowSize?.columns ?? 100);
|
|
183
|
+
const [focusArea, setFocusArea] = React.useState("sidebar");
|
|
184
|
+
const [backHistory, setBackHistory] = React.useState([]);
|
|
185
|
+
const [forwardHistory, setForwardHistory] = React.useState([]);
|
|
186
|
+
const [currentCommand, setCurrentCommand] = React.useState(commandName ?? null);
|
|
187
|
+
const [sectionIndex, setSectionIndex] = React.useState(0);
|
|
188
|
+
const [sidebarOffset, setSidebarOffset] = React.useState(0);
|
|
189
|
+
const [detailOffset, setDetailOffset] = React.useState(0);
|
|
190
|
+
const [contentRowIndex, setContentRowIndex] = React.useState(-1);
|
|
191
|
+
const view = React.useMemo(() => buildTreeseedHelpView(currentCommand), [currentCommand]);
|
|
192
|
+
const safeSectionIndex = view.sections.length === 0 ? 0 : Math.min(sectionIndex, view.sections.length - 1);
|
|
193
|
+
const sidebarViewportSize = Math.max(1, layout.bodyHeight - 4);
|
|
194
|
+
const safeSidebarOffset = clampOffset(ensureVisible(safeSectionIndex, sidebarOffset, sidebarViewportSize), view.sections.length, sidebarViewportSize);
|
|
195
|
+
const visibleSections = view.sections.slice(safeSidebarOffset, safeSidebarOffset + sidebarViewportSize);
|
|
196
|
+
const selectedSection = view.sections[safeSectionIndex] ?? { id: "empty", title: "Help", lines: ["No help content is available."] };
|
|
197
|
+
const detailRows = buildSectionRows(selectedSection, layout.contentWidth - 2);
|
|
198
|
+
const safeContentRowIndex = contentRowIndex >= 0 && contentRowIndex < detailRows.length ? contentRowIndex : nearestNavigableRow(detailRows);
|
|
199
|
+
const detailView = detailViewport(detailRows, layout.bodyHeight, detailOffset);
|
|
200
|
+
const visibleSelectedRowIndex = safeContentRowIndex >= detailView.offset && safeContentRowIndex < detailView.offset + detailView.viewportSize ? safeContentRowIndex - detailView.offset : -1;
|
|
201
|
+
React.useEffect(() => {
|
|
202
|
+
if (safeSidebarOffset !== sidebarOffset) {
|
|
203
|
+
setSidebarOffset(safeSidebarOffset);
|
|
204
|
+
}
|
|
205
|
+
}, [safeSidebarOffset, sidebarOffset]);
|
|
206
|
+
React.useEffect(() => {
|
|
207
|
+
if (detailView.offset !== detailOffset) {
|
|
208
|
+
setDetailOffset(detailView.offset);
|
|
209
|
+
}
|
|
210
|
+
}, [detailView.offset, detailOffset]);
|
|
211
|
+
React.useEffect(() => {
|
|
212
|
+
setSectionIndex(0);
|
|
213
|
+
setSidebarOffset(0);
|
|
214
|
+
setDetailOffset(0);
|
|
215
|
+
setContentRowIndex(-1);
|
|
216
|
+
setFocusArea("sidebar");
|
|
217
|
+
}, [currentCommand]);
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
setDetailOffset(0);
|
|
220
|
+
setContentRowIndex(nearestNavigableRow(detailRows));
|
|
221
|
+
}, [selectedSection.id]);
|
|
222
|
+
const navigateToCommand = React.useCallback((targetCommand) => {
|
|
223
|
+
if (!targetCommand) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
setBackHistory((current) => pushNavigationEntry(current, currentCommand));
|
|
227
|
+
setForwardHistory([]);
|
|
228
|
+
setCurrentCommand(targetCommand);
|
|
229
|
+
}, [currentCommand]);
|
|
230
|
+
const goBack = React.useCallback(() => {
|
|
231
|
+
if (backHistory.length > 0) {
|
|
232
|
+
const { nextStack, popped } = popNavigationEntry(backHistory);
|
|
233
|
+
setBackHistory(nextStack);
|
|
234
|
+
setForwardHistory((current) => pushNavigationEntry(current, currentCommand));
|
|
235
|
+
setCurrentCommand(popped);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (currentCommand !== null) {
|
|
239
|
+
setForwardHistory((current) => pushNavigationEntry(current, currentCommand));
|
|
240
|
+
setCurrentCommand(null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
exit();
|
|
244
|
+
finish(view.exitCode);
|
|
245
|
+
}, [backHistory, currentCommand, exit, view.exitCode]);
|
|
246
|
+
const goForward = React.useCallback(() => {
|
|
247
|
+
if (forwardHistory.length === 0) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const { nextStack, popped } = popNavigationEntry(forwardHistory);
|
|
251
|
+
setForwardHistory(nextStack);
|
|
252
|
+
setBackHistory((current) => pushNavigationEntry(current, currentCommand));
|
|
253
|
+
setCurrentCommand(popped);
|
|
254
|
+
}, [currentCommand, forwardHistory]);
|
|
255
|
+
const backLabel = currentCommand !== null ? "Back to Help" : backHistory.length > 0 ? "Back" : "Exit Help";
|
|
256
|
+
const backWidth = buttonLabel(backLabel).length;
|
|
257
|
+
const backX = Math.max(0, layout.columns - backWidth);
|
|
258
|
+
const topActionY = 3;
|
|
259
|
+
const backButtonRect = buttonRect(backLabel, backX, topActionY);
|
|
260
|
+
const sidebarRect = { x: 0, y: layout.topBarHeight, width: layout.sidebarWidth, height: layout.bodyHeight };
|
|
261
|
+
const detailRect = { x: layout.sidebarWidth + 1, y: layout.topBarHeight, width: layout.contentWidth, height: layout.bodyHeight };
|
|
262
|
+
const clickRegions = [
|
|
263
|
+
{
|
|
264
|
+
id: "top-action-back",
|
|
265
|
+
rect: backButtonRect,
|
|
266
|
+
onClick: goBack
|
|
267
|
+
},
|
|
268
|
+
...visibleSections.map((section, index) => ({
|
|
269
|
+
id: `section:${section.id}`,
|
|
270
|
+
rect: sidebarItemRect(layout, safeSidebarOffset, index, view.sections.length),
|
|
271
|
+
onClick: () => {
|
|
272
|
+
setSectionIndex(safeSidebarOffset + index);
|
|
273
|
+
setFocusArea("sidebar");
|
|
274
|
+
}
|
|
275
|
+
})),
|
|
276
|
+
...detailView.rows.flatMap((row, index) => row.targetCommand ? [{
|
|
277
|
+
id: `detail:${row.targetCommand}:${index}`,
|
|
278
|
+
rect: detailRowRect(layout, index),
|
|
279
|
+
onClick: () => {
|
|
280
|
+
setFocusArea("content");
|
|
281
|
+
setContentRowIndex(detailView.offset + index);
|
|
282
|
+
navigateToCommand(row.targetCommand);
|
|
283
|
+
}
|
|
284
|
+
}] : [])
|
|
285
|
+
];
|
|
286
|
+
const scrollRegions = [
|
|
287
|
+
{
|
|
288
|
+
id: "help-sidebar",
|
|
289
|
+
rect: sidebarRect,
|
|
290
|
+
state: {
|
|
291
|
+
offset: safeSidebarOffset,
|
|
292
|
+
viewportSize: sidebarViewportSize,
|
|
293
|
+
totalSize: view.sections.length
|
|
294
|
+
},
|
|
295
|
+
onScroll: (offset) => {
|
|
296
|
+
setSidebarOffset(offset);
|
|
297
|
+
setSectionIndex(offset);
|
|
298
|
+
},
|
|
299
|
+
onFocus: () => setFocusArea("sidebar")
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: "help-detail",
|
|
303
|
+
rect: detailRect,
|
|
304
|
+
state: {
|
|
305
|
+
offset: detailView.offset,
|
|
306
|
+
viewportSize: detailView.viewportSize,
|
|
307
|
+
totalSize: detailView.totalSize
|
|
308
|
+
},
|
|
309
|
+
onScroll: (offset) => setDetailOffset(offset),
|
|
310
|
+
onFocus: () => setFocusArea("content")
|
|
311
|
+
}
|
|
312
|
+
];
|
|
313
|
+
useTerminalMouse((event) => {
|
|
314
|
+
if (event.button === "scroll-up" || event.button === "scroll-down") {
|
|
315
|
+
const delta = event.button === "scroll-up" ? -1 : 1;
|
|
316
|
+
routeWheelDeltaToScrollRegion(scrollRegions, event.x, event.y, delta);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (event.action !== "release" || event.button !== "left") {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
findClickableRegion(clickRegions, event.x, event.y)?.onClick();
|
|
323
|
+
});
|
|
324
|
+
useInput((input, key) => {
|
|
325
|
+
if (key.ctrl && input === "c") {
|
|
326
|
+
exit();
|
|
327
|
+
finish(view.exitCode);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (key.escape || input === "q") {
|
|
331
|
+
exit();
|
|
332
|
+
finish(view.exitCode);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (input === "b" || input === "[" || key.backspace) {
|
|
336
|
+
goBack();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (input === "f" || input === "]") {
|
|
340
|
+
goForward();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (key.tab) {
|
|
344
|
+
setFocusArea((current) => current === "sidebar" ? "content" : "sidebar");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (focusArea === "sidebar") {
|
|
348
|
+
if (key.upArrow || input === "k") {
|
|
349
|
+
setSectionIndex((current) => Math.max(0, current - 1));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (key.downArrow || input === "j") {
|
|
353
|
+
setSectionIndex((current) => Math.min(Math.max(0, view.sections.length - 1), current + 1));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (key.pageUp) {
|
|
357
|
+
setSidebarOffset((current) => scrollOffsetByPage({ offset: current, viewportSize: sidebarViewportSize, totalSize: view.sections.length }, -1));
|
|
358
|
+
setSectionIndex((current) => Math.max(0, current - sidebarViewportSize));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (key.pageDown) {
|
|
362
|
+
setSidebarOffset((current) => scrollOffsetByPage({ offset: current, viewportSize: sidebarViewportSize, totalSize: view.sections.length }, 1));
|
|
363
|
+
setSectionIndex((current) => Math.min(Math.max(0, view.sections.length - 1), current + sidebarViewportSize));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (focusArea === "content") {
|
|
368
|
+
if (key.upArrow) {
|
|
369
|
+
const next = nextNavigableRow(detailRows, safeContentRowIndex, -1);
|
|
370
|
+
if (next >= 0) {
|
|
371
|
+
setContentRowIndex(next);
|
|
372
|
+
setDetailOffset((current) => ensureVisible(next, current, detailView.viewportSize));
|
|
373
|
+
} else {
|
|
374
|
+
setDetailOffset((current) => scrollOffsetByDelta({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, -1));
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (key.downArrow) {
|
|
379
|
+
const next = nextNavigableRow(detailRows, safeContentRowIndex, 1);
|
|
380
|
+
if (next >= 0) {
|
|
381
|
+
setContentRowIndex(next);
|
|
382
|
+
setDetailOffset((current) => ensureVisible(next, current, detailView.viewportSize));
|
|
383
|
+
} else {
|
|
384
|
+
setDetailOffset((current) => scrollOffsetByDelta({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, 1));
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (input === "k") {
|
|
389
|
+
setDetailOffset((current) => scrollOffsetByDelta({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, -1));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (input === "j") {
|
|
393
|
+
setDetailOffset((current) => scrollOffsetByDelta({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, 1));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (key.pageUp) {
|
|
397
|
+
setDetailOffset((current) => scrollOffsetByPage({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, -1));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (key.pageDown) {
|
|
401
|
+
setDetailOffset((current) => scrollOffsetByPage({ offset: current, viewportSize: detailView.viewportSize, totalSize: detailView.totalSize }, 1));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (key.return && safeContentRowIndex >= 0) {
|
|
405
|
+
const targetCommand = detailRows[safeContentRowIndex]?.targetCommand;
|
|
406
|
+
if (targetCommand) {
|
|
407
|
+
navigateToCommand(targetCommand);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
const topBar = React.createElement(
|
|
414
|
+
Box,
|
|
415
|
+
{ flexDirection: "column", width: layout.columns, overflow: "hidden" },
|
|
416
|
+
React.createElement(Text, { backgroundColor: "cyan", color: "black", bold: true }, truncateLine(` ${view.title} `, layout.columns)),
|
|
417
|
+
React.createElement(Text, { color: "white" }, truncateLine(view.subtitle ?? "", layout.columns)),
|
|
418
|
+
React.createElement(Text, { color: "gray" }, truncateLine(view.badge ?? "", layout.columns)),
|
|
419
|
+
React.createElement(
|
|
420
|
+
Box,
|
|
421
|
+
{ width: layout.columns, justifyContent: "space-between" },
|
|
422
|
+
React.createElement(Text, { color: "gray" }, truncateLine(currentCommand === null ? "Main Help" : `Viewing ${currentCommand}`, Math.max(1, layout.columns - backButtonRect.width - 2))),
|
|
423
|
+
React.createElement(SecondaryButton, {
|
|
424
|
+
label: backLabel,
|
|
425
|
+
focused: false,
|
|
426
|
+
width: backButtonRect.width
|
|
427
|
+
})
|
|
428
|
+
)
|
|
429
|
+
);
|
|
430
|
+
const body = React.createElement(
|
|
431
|
+
Box,
|
|
432
|
+
{ width: layout.columns, height: layout.bodyHeight, overflow: "hidden" },
|
|
433
|
+
React.createElement(SidebarList, {
|
|
434
|
+
width: layout.sidebarWidth,
|
|
435
|
+
height: layout.bodyHeight,
|
|
436
|
+
title: `${view.sidebarTitle}${focusArea === "sidebar" ? " \u2022 active" : ""}`,
|
|
437
|
+
focused: focusArea === "sidebar",
|
|
438
|
+
scrollState: {
|
|
439
|
+
offset: safeSidebarOffset,
|
|
440
|
+
viewportSize: sidebarViewportSize,
|
|
441
|
+
totalSize: view.sections.length
|
|
442
|
+
},
|
|
443
|
+
items: visibleSections.map((section, index) => ({
|
|
444
|
+
id: section.id,
|
|
445
|
+
label: section.title,
|
|
446
|
+
active: safeSidebarOffset + index === safeSectionIndex,
|
|
447
|
+
tone: "normal"
|
|
448
|
+
}))
|
|
449
|
+
}),
|
|
450
|
+
React.createElement(Text, null, " "),
|
|
451
|
+
React.createElement(HelpDetailPanel, {
|
|
452
|
+
width: layout.contentWidth,
|
|
453
|
+
height: layout.bodyHeight,
|
|
454
|
+
title: `${selectedSection.title}${focusArea === "content" ? " \u2022 active" : ""}`,
|
|
455
|
+
focused: focusArea === "content",
|
|
456
|
+
rows: detailView.rows,
|
|
457
|
+
selectedRowIndex: visibleSelectedRowIndex,
|
|
458
|
+
scrollState: {
|
|
459
|
+
offset: detailView.offset,
|
|
460
|
+
viewportSize: detailView.viewportSize,
|
|
461
|
+
totalSize: detailView.totalSize
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
const footer = React.createElement(StatusBar, {
|
|
466
|
+
width: layout.columns,
|
|
467
|
+
accent: focusArea === "content",
|
|
468
|
+
primary: "Wheel or PgUp/PgDn scroll the hovered or focused panel. Enter opens the selected command. b/[ goes back. f/] goes forward. q exits.",
|
|
469
|
+
secondary: `${view.statusSecondary} Focus: ${focusArea}.`
|
|
470
|
+
});
|
|
471
|
+
return React.createElement(AppFrame, { layout, topBar, body, footer });
|
|
472
|
+
}
|
|
473
|
+
instance = render(React.createElement(App), { exitOnCtrlC: false });
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
function shouldUseInkHelp(context) {
|
|
477
|
+
return Boolean(context.interactiveUi !== false && canRenderInkHelp({ outputFormat: context.outputFormat ?? "human" }));
|
|
478
|
+
}
|
|
479
|
+
function canRenderInkHelp(context) {
|
|
480
|
+
return Boolean(
|
|
481
|
+
context.interactiveUi !== false && context.outputFormat !== "json" && !isNonHumanInteractiveEnvironment() && process.stdin.isTTY && process.stdout.isTTY
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
function isNonHumanInteractiveEnvironment() {
|
|
485
|
+
return process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" || process.env.ACT === "true" || process.env.TREESEED_VERIFY_DRIVER === "act";
|
|
486
|
+
}
|
|
487
|
+
export {
|
|
488
|
+
renderTreeseedHelpInk,
|
|
489
|
+
shouldUseInkHelp
|
|
490
|
+
};
|