@treeseed/cli 0.4.7 → 0.4.9

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.
@@ -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
+ };
@@ -1,4 +1,30 @@
1
1
  import type { TreeseedOperationSpec } from './operations-types.ts';
2
+ export type TreeseedHelpEntryAccent = 'command' | 'flag' | 'argument' | 'example' | 'alias' | 'related';
3
+ export type TreeseedHelpEntry = {
4
+ label: string;
5
+ summary?: string;
6
+ accent?: TreeseedHelpEntryAccent;
7
+ required?: boolean;
8
+ targetCommand?: string;
9
+ };
10
+ export type TreeseedHelpSection = {
11
+ id: string;
12
+ title: string;
13
+ entries?: TreeseedHelpEntry[];
14
+ lines?: string[];
15
+ };
16
+ export type TreeseedHelpView = {
17
+ kind: 'top' | 'command' | 'unknown';
18
+ title: string;
19
+ subtitle?: string;
20
+ badge?: string;
21
+ sidebarTitle: string;
22
+ sections: TreeseedHelpSection[];
23
+ statusPrimary: string;
24
+ statusSecondary: string;
25
+ exitCode: number;
26
+ };
2
27
  export declare function renderUsage(spec: TreeseedOperationSpec): string;
3
28
  export declare function suggestTreeseedCommands(input: string): string[];
29
+ export declare function buildTreeseedHelpView(commandName?: string | null): TreeseedHelpView;
4
30
  export declare function renderTreeseedHelp(commandName?: string | null): string;