@rse/ase 2.0.3 → 2.0.5
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/dst/ase-hook.js +13 -4
- package/dst/ase-lib-version.js +14 -5
- package/dst/ase-man-catalog.js +229 -0
- package/dst/ase-man-render.js +278 -0
- package/dst/ase-man-tui.js +636 -0
- package/dst/ase-man.js +33 -27
- package/dst/ase-setup.js +2 -2
- package/dst/ase-spec.js +103 -20
- package/dst/ase-task-board-filter.js +5 -4
- package/dst/ase-task-board-web-client.js +26 -26
- package/dst/man/ase-agent-adhoc.md +1165 -0
- package/dst/man/ase-agent-condenser.md +383 -0
- package/dst/man/ase-agent-editor.md +441 -0
- package/dst/man/ase-agent-estimator.md +477 -0
- package/dst/man/ase-agent-fleet.md +358 -0
- package/dst/man/ase-agent-implementer.md +521 -0
- package/dst/man/ase-agent-integrator.md +449 -0
- package/dst/man/ase-agent-qualifier.md +426 -0
- package/dst/man/ase-agent-rebooter.md +384 -0
- package/dst/man/ase-agent-relay.md +262 -0
- package/dst/man/ase-agent-researcher.md +478 -0
- package/dst/man/ase-agent.md +628 -0
- package/dst/man/ase-artifact-list.md +57 -0
- package/dst/man/ase-artifact-name.md +56 -0
- package/dst/man/ase-artifact.md +92 -0
- package/dst/man/ase-config-delete.md +74 -0
- package/dst/man/ase-config-edit.md +83 -0
- package/dst/man/ase-config-get.md +70 -0
- package/dst/man/ase-config-init.md +110 -0
- package/dst/man/ase-config-list.md +68 -0
- package/dst/man/ase-config-set.md +92 -0
- package/dst/man/ase-config.md +426 -0
- package/dst/man/ase-hook-permission-request.md +61 -0
- package/dst/man/ase-hook-pre-tool-use.md +96 -0
- package/dst/man/ase-hook-session-end.md +71 -0
- package/dst/man/ase-hook-session-persona.md +65 -0
- package/dst/man/ase-hook-session-start.md +184 -0
- package/dst/man/ase-hook-stop.md +52 -0
- package/dst/man/ase-hook-user-prompt-submit.md +54 -0
- package/dst/man/ase-hook.md +93 -0
- package/dst/man/ase-man.md +195 -0
- package/dst/man/ase-mcp.md +102 -0
- package/dst/man/ase-service-log.md +59 -0
- package/dst/man/ase-service-send.md +60 -0
- package/dst/man/ase-service-start.md +93 -0
- package/dst/man/ase-service-status.md +58 -0
- package/dst/man/ase-service-stop.md +52 -0
- package/dst/man/ase-service.md +124 -0
- package/dst/man/ase-setup-disable.md +67 -0
- package/dst/man/ase-setup-enable.md +66 -0
- package/dst/man/ase-setup-install.md +116 -0
- package/dst/man/ase-setup-mcp.md +213 -0
- package/dst/man/ase-setup-status.md +101 -0
- package/dst/man/ase-setup-statusline.md +162 -0
- package/dst/man/ase-setup-telemetry.md +166 -0
- package/dst/man/ase-setup-uninstall.md +85 -0
- package/dst/man/ase-setup-update.md +117 -0
- package/dst/man/ase-setup.md +156 -0
- package/dst/man/ase-spec-export.md +99 -0
- package/dst/man/ase-spec-init.md +57 -0
- package/dst/man/ase-spec-lint.md +74 -0
- package/dst/man/ase-spec-preview.md +57 -0
- package/dst/man/ase-spec.md +85 -0
- package/dst/man/ase-statusline.md +330 -0
- package/dst/man/ase-task-assign.md +138 -0
- package/dst/man/ase-task-board.md +651 -0
- package/dst/man/ase-task-delete.md +46 -0
- package/dst/man/ase-task-edit.md +73 -0
- package/dst/man/ase-task-idscheme.md +92 -0
- package/dst/man/ase-task-lifecycle.md +92 -0
- package/dst/man/ase-task-list.md +86 -0
- package/dst/man/ase-task-load.md +48 -0
- package/dst/man/ase-task-newid.md +100 -0
- package/dst/man/ase-task-purge.md +49 -0
- package/dst/man/ase-task-rename.md +69 -0
- package/dst/man/ase-task-request.md +108 -0
- package/dst/man/ase-task-save.md +78 -0
- package/dst/man/ase-task-status.md +79 -0
- package/dst/man/ase-task-store.md +377 -0
- package/dst/man/ase-task-view.md +50 -0
- package/dst/man/ase-task.md +335 -0
- package/dst/man/ase-util-compat.md +38 -0
- package/dst/man/ase-util-diagram.md +140 -0
- package/dst/man/ase-util-meta.md +68 -0
- package/dst/man/ase-util-metric.md +74 -0
- package/dst/man/ase-util-mint.md +85 -0
- package/dst/man/ase-util-worktree.md +109 -0
- package/dst/man/ase-util.md +81 -0
- package/dst/man/ase.md +228 -0
- package/package.json +16 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-code-lint.md +3 -3
- package/plugin/agents/ase-docs-proofread.md +3 -3
- package/plugin/agents/ase-docs-refine.md +3 -3
- package/plugin/agents/ase-spec-lint.md +338 -0
- package/plugin/mods/ase-mods-common.ts +444 -0
- package/plugin/mods/ase-mods-grill.ts +312 -0
- package/plugin/mods/ase-mods-help-browse.ts +405 -0
- package/plugin/mods/ase-mods-help-compose.ts +561 -0
- package/plugin/mods/ase-mods-help-find.ts +426 -0
- package/plugin/mods/ase-mods-help-hint.ts +134 -0
- package/plugin/mods/ase-mods-help-show.ts +142 -0
- package/plugin/mods/ase-mods.ts +214 -381
- package/plugin/mods/claude-code.d.ts +26 -3
- package/plugin/skills/ase-code-lint/SKILL.md +15 -15
- package/plugin/skills/ase-code-lint/help.md +21 -8
- package/plugin/skills/ase-help-intent/SKILL.md +2 -1
- package/plugin/skills/ase-help-intent/data.md +214 -9
- package/plugin/skills/ase-help-skill/catalog.md +1 -0
- package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
- package/plugin/skills/ase-repo-commit/SKILL.md +1 -0
- package/plugin/skills/ase-repo-merge/SKILL.md +1 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +3 -3
- package/plugin/skills/ase-spec-edit/help.md +1 -1
- package/plugin/skills/ase-spec-lint/SKILL.md +695 -0
- package/plugin/skills/ase-spec-lint/help.md +184 -0
- package/plugin/skills/ase-task-implement/SKILL.md +24 -6
- package/plugin/skills/ase-task-implement/help.md +11 -1
- package/dst/man/ase-agent-adhoc.1 +0 -515
- package/dst/man/ase-agent-condenser.1 +0 -193
- package/dst/man/ase-agent-editor.1 +0 -212
- package/dst/man/ase-agent-estimator.1 +0 -214
- package/dst/man/ase-agent-fleet.1 +0 -185
- package/dst/man/ase-agent-implementer.1 +0 -233
- package/dst/man/ase-agent-integrator.1 +0 -213
- package/dst/man/ase-agent-qualifier.1 +0 -214
- package/dst/man/ase-agent-rebooter.1 +0 -193
- package/dst/man/ase-agent-relay.1 +0 -128
- package/dst/man/ase-agent-researcher.1 +0 -217
- package/dst/man/ase-agent.1 +0 -264
- package/dst/man/ase-artifact-list.1 +0 -38
- package/dst/man/ase-artifact-name.1 +0 -39
- package/dst/man/ase-artifact.1 +0 -56
- package/dst/man/ase-config-delete.1 +0 -45
- package/dst/man/ase-config-edit.1 +0 -48
- package/dst/man/ase-config-get.1 +0 -46
- package/dst/man/ase-config-init.1 +0 -60
- package/dst/man/ase-config-list.1 +0 -42
- package/dst/man/ase-config-set.1 +0 -52
- package/dst/man/ase-config.1 +0 -231
- package/dst/man/ase-hook-permission-request.1 +0 -36
- package/dst/man/ase-hook-pre-tool-use.1 +0 -59
- package/dst/man/ase-hook-session-end.1 +0 -47
- package/dst/man/ase-hook-session-persona.1 +0 -37
- package/dst/man/ase-hook-session-start.1 +0 -81
- package/dst/man/ase-hook-stop.1 +0 -34
- package/dst/man/ase-hook-user-prompt-submit.1 +0 -34
- package/dst/man/ase-hook.1 +0 -51
- package/dst/man/ase-man.1 +0 -67
- package/dst/man/ase-mcp.1 +0 -61
- package/dst/man/ase-service-log.1 +0 -43
- package/dst/man/ase-service-send.1 +0 -47
- package/dst/man/ase-service-start.1 +0 -54
- package/dst/man/ase-service-status.1 +0 -46
- package/dst/man/ase-service-stop.1 +0 -37
- package/dst/man/ase-service.1 +0 -85
- package/dst/man/ase-setup-disable.1 +0 -44
- package/dst/man/ase-setup-enable.1 +0 -45
- package/dst/man/ase-setup-install.1 +0 -73
- package/dst/man/ase-setup-mcp.1 +0 -124
- package/dst/man/ase-setup-status.1 +0 -60
- package/dst/man/ase-setup-statusline.1 +0 -92
- package/dst/man/ase-setup-telemetry.1 +0 -92
- package/dst/man/ase-setup-uninstall.1 +0 -59
- package/dst/man/ase-setup-update.1 +0 -64
- package/dst/man/ase-setup.1 +0 -93
- package/dst/man/ase-spec-export.1 +0 -62
- package/dst/man/ase-spec-lint.1 +0 -40
- package/dst/man/ase-spec-preview.1 +0 -40
- package/dst/man/ase-spec.1 +0 -46
- package/dst/man/ase-statusline.1 +0 -189
- package/dst/man/ase-task-assign.1 +0 -71
- package/dst/man/ase-task-board.1 +0 -384
- package/dst/man/ase-task-delete.1 +0 -35
- package/dst/man/ase-task-edit.1 +0 -46
- package/dst/man/ase-task-idscheme.1 +0 -65
- package/dst/man/ase-task-lifecycle.1 +0 -57
- package/dst/man/ase-task-list.1 +0 -47
- package/dst/man/ase-task-load.1 +0 -36
- package/dst/man/ase-task-newid.1 +0 -61
- package/dst/man/ase-task-purge.1 +0 -37
- package/dst/man/ase-task-rename.1 +0 -44
- package/dst/man/ase-task-request.1 +0 -61
- package/dst/man/ase-task-save.1 +0 -48
- package/dst/man/ase-task-status.1 +0 -55
- package/dst/man/ase-task-store.1 +0 -194
- package/dst/man/ase-task-view.1 +0 -40
- package/dst/man/ase-task.1 +0 -160
- package/dst/man/ase-util-compat.1 +0 -25
- package/dst/man/ase-util-diagram.1 +0 -76
- package/dst/man/ase-util-meta.1 +0 -45
- package/dst/man/ase-util-metric.1 +0 -54
- package/dst/man/ase-util-mint.1 +0 -59
- package/dst/man/ase-util-worktree.1 +0 -72
- package/dst/man/ase-util.1 +0 -50
- package/dst/man/ase.1 +0 -124
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { render, Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
8
|
+
import pkg from "../package.json" with { type: "json" };
|
|
9
|
+
import { loadCatalog, searchPages, findPage } from "./ase-man-catalog.js";
|
|
10
|
+
import { renderPage, markMatches, codeLoad } from "./ase-man-render.js";
|
|
11
|
+
import { useMouseReports } from "./ase-task-board-tui.js";
|
|
12
|
+
import { mouseReporting } from "./ase-task-board-tui-model.js";
|
|
13
|
+
import { h } from "./ase-task-board-tui-view.js";
|
|
14
|
+
import { palette, loadPalette, cx } from "./ase-task-board-tui-style.js";
|
|
15
|
+
import { sanitize } from "./ase-task-board-tui-popup.js";
|
|
16
|
+
/* layout constants: the screen rows above the content (header, rule, top of
|
|
17
|
+
the panel, the three rows of the search field, and a blank line) and below
|
|
18
|
+
it (bottom of the panel, two key hint lines, rule, and status line), the
|
|
19
|
+
screen columns left of the content (margin, side, and padding of the panel),
|
|
20
|
+
the left and right padding of the rendered page within the detail, the
|
|
21
|
+
minimum width, the maximum count, and the gap of the catalog columns, and
|
|
22
|
+
the minimum window size */
|
|
23
|
+
const TOP = 7;
|
|
24
|
+
const BOTTOM = 5;
|
|
25
|
+
const LEFT = 3;
|
|
26
|
+
const PAD = 2;
|
|
27
|
+
const COL_MIN = 24;
|
|
28
|
+
const COL_MAX = 5;
|
|
29
|
+
const GAP = 2;
|
|
30
|
+
const MIN_W = 60;
|
|
31
|
+
const MIN_H = 20;
|
|
32
|
+
/* the text properties of the tones of the rendered manual pages */
|
|
33
|
+
const toneProps = (tone) => ({
|
|
34
|
+
text: { color: palette.normal },
|
|
35
|
+
bold: { color: palette.normal, bold: true },
|
|
36
|
+
italic: { color: palette.normal, italic: true },
|
|
37
|
+
code: { color: palette.accent },
|
|
38
|
+
link: { color: palette.accent, underline: true },
|
|
39
|
+
title: { color: palette.accent, bold: true },
|
|
40
|
+
head: { color: palette.accent, bold: true },
|
|
41
|
+
sub: { color: palette.normal, bold: true },
|
|
42
|
+
dim: { color: palette.dim },
|
|
43
|
+
bullet: { color: palette.accent },
|
|
44
|
+
cmt: { color: palette.dim, italic: true },
|
|
45
|
+
str: { color: palette.accent },
|
|
46
|
+
num: { color: palette.signal },
|
|
47
|
+
opt: { color: palette.signal },
|
|
48
|
+
var: { color: palette.signal },
|
|
49
|
+
key: { color: palette.accent, bold: true },
|
|
50
|
+
cmd: { color: palette.normal, bold: true },
|
|
51
|
+
kw: { color: palette.normal, bold: true },
|
|
52
|
+
punct: { color: palette.dim }
|
|
53
|
+
}[tone]);
|
|
54
|
+
/* reduce groups to the matching pages of a search (if any), dropping the empty groups */
|
|
55
|
+
const filterGroups = (groups, hits) => hits === null ? groups : groups
|
|
56
|
+
.map((group) => ({ ...group, pages: group.pages.filter((page) => hits.has(page.id)) }))
|
|
57
|
+
.filter((group) => group.pages.length > 0);
|
|
58
|
+
/* lay out the catalog grid onto a width (with 1-5 columns, like the
|
|
59
|
+
catalog of the website), reduced to the matching pages of a search (if any),
|
|
60
|
+
where the groups of each section are distributed in their order onto the
|
|
61
|
+
columns, each filled up to the balanced height */
|
|
62
|
+
const layoutGrid = (catalog, hits, width) => {
|
|
63
|
+
const n = Math.max(1, Math.min(COL_MAX, Math.floor((width + GAP) / (COL_MIN + GAP))));
|
|
64
|
+
const colW = Math.floor((width - (n - 1) * GAP) / n);
|
|
65
|
+
const lines = [];
|
|
66
|
+
const empty = () => new Array(n).fill(null);
|
|
67
|
+
const section = (title, groups) => {
|
|
68
|
+
if (groups.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
if (lines.length > 0)
|
|
71
|
+
lines.push(empty());
|
|
72
|
+
lines.push([{ kind: "section", text: title }, ...empty().slice(1)], empty());
|
|
73
|
+
const total = groups.reduce((sum, group) => sum + group.pages.length + 2, -1);
|
|
74
|
+
const target = Math.ceil(total / n);
|
|
75
|
+
const cols = Array.from({ length: n }, () => []);
|
|
76
|
+
let c = 0;
|
|
77
|
+
for (const group of groups) {
|
|
78
|
+
if (cols[c].length > 0 && cols[c].length + group.pages.length + 2 > target && c < n - 1)
|
|
79
|
+
c++;
|
|
80
|
+
if (cols[c].length > 0)
|
|
81
|
+
cols[c].push(null);
|
|
82
|
+
cols[c].push({ kind: "group", text: group.label }, ...group.pages.map((page) => ({ kind: "page", page })));
|
|
83
|
+
}
|
|
84
|
+
const height = Math.max(...cols.map((col) => col.length));
|
|
85
|
+
for (let l = 0; l < height; l++)
|
|
86
|
+
lines.push(cols.map((col) => col[l] ?? null));
|
|
87
|
+
};
|
|
88
|
+
section("AI SKILLS", filterGroups(catalog.skills, hits));
|
|
89
|
+
section("CLI COMMANDS", filterGroups(catalog.commands, hits));
|
|
90
|
+
const pos = new Map();
|
|
91
|
+
lines.forEach((cells, l) => {
|
|
92
|
+
cells.forEach((cell, c) => {
|
|
93
|
+
if (cell !== null && cell.kind === "page")
|
|
94
|
+
pos.set(cell.page.id, { c, l });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return { n, colW, lines, pos };
|
|
98
|
+
};
|
|
99
|
+
/* move the selection within the catalog grid: vertically onto the nearest page
|
|
100
|
+
of the same column (or, at the end of the column, onto the nearest page further
|
|
101
|
+
in this direction of any other column, so the entire grid stays reachable),
|
|
102
|
+
resp. horizontally onto the nearest page of the neighbor column */
|
|
103
|
+
const moveGrid = (grid, id, dc, dl) => {
|
|
104
|
+
const cur = grid.pos.get(id);
|
|
105
|
+
if (cur === undefined)
|
|
106
|
+
return id;
|
|
107
|
+
let best = id;
|
|
108
|
+
let dist = Infinity;
|
|
109
|
+
for (const [other, p] of grid.pos) {
|
|
110
|
+
let d = Infinity;
|
|
111
|
+
if (dl !== 0 && p.c === cur.c && Math.sign(p.l - cur.l) === dl)
|
|
112
|
+
d = Math.abs(p.l - cur.l);
|
|
113
|
+
else if (dc !== 0 && p.c === cur.c + dc)
|
|
114
|
+
d = Math.abs(p.l - cur.l) * 2 + (p.l < cur.l ? 1 : 0);
|
|
115
|
+
if (d < dist) {
|
|
116
|
+
best = other;
|
|
117
|
+
dist = d;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (dist === Infinity && dl !== 0) {
|
|
121
|
+
for (const [other, p] of grid.pos) {
|
|
122
|
+
const d = Math.sign(p.l - cur.l) === dl ? Math.abs(p.l - cur.l) * grid.n + Math.abs(p.c - cur.c) : Infinity;
|
|
123
|
+
if (d < dist) {
|
|
124
|
+
best = other;
|
|
125
|
+
dist = d;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
};
|
|
131
|
+
/* the entries of the master: the sections and groups with their pages,
|
|
132
|
+
reduced to the matching pages of a search (if any) */
|
|
133
|
+
const masterItems = (catalog, hits) => {
|
|
134
|
+
const items = [];
|
|
135
|
+
for (const [title, groups] of [["AI SKILLS", catalog.skills], ["CLI COMMANDS", catalog.commands]]) {
|
|
136
|
+
let head = false;
|
|
137
|
+
for (const group of filterGroups(groups, hits)) {
|
|
138
|
+
if (items.length > 0)
|
|
139
|
+
items.push(null);
|
|
140
|
+
if (!head)
|
|
141
|
+
items.push({ kind: "section", text: title }, null);
|
|
142
|
+
head = true;
|
|
143
|
+
items.push({ kind: "group", text: group.label }, ...group.pages.map((page) => ({ kind: "page", page })));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return items;
|
|
147
|
+
};
|
|
148
|
+
/* keep an index visible within a viewport of a height, by minimally moving its
|
|
149
|
+
offset, where scrolling up reveals the entries from a top index on */
|
|
150
|
+
const follow = (off, top, idx, height, total) => Math.max(0, Math.min(Math.max(0, total - height), top < off ? top : idx >= off + height ? idx - height + 1 : off));
|
|
151
|
+
/* the index of the topmost entry to reveal together with an entry of a list:
|
|
152
|
+
the heading of its group (and the heading of its section, if the group is
|
|
153
|
+
the first one of the section), as long as they fit into a viewport of a height */
|
|
154
|
+
const headOf = (list, idx, height) => {
|
|
155
|
+
let k = idx;
|
|
156
|
+
while (k > 0 && list[k - 1] !== null)
|
|
157
|
+
k--;
|
|
158
|
+
if (k >= 2 && list[k - 2]?.kind === "section")
|
|
159
|
+
k -= 2;
|
|
160
|
+
return idx - k < height ? k : idx;
|
|
161
|
+
};
|
|
162
|
+
/* render a cell onto a width: the label of a page with its last part
|
|
163
|
+
accented in bold (or, in the same style, inversely in signal color over the
|
|
164
|
+
entire width if selected), resp. a heading */
|
|
165
|
+
const renderCell = (cell, width, selected) => {
|
|
166
|
+
if (cell === null)
|
|
167
|
+
return h(Text, {}, " ");
|
|
168
|
+
if (cell.kind !== "page")
|
|
169
|
+
return h(Text, { ...(cell.kind === "group" ? cx("accent", "bold") : cx("bold")), wrap: "truncate" }, cell.text);
|
|
170
|
+
const label = cell.page.label.length > width ? cell.page.label.slice(0, width - 1) + "…" : cell.page.label;
|
|
171
|
+
const idx = cell.page.kind === "skill" ? label.lastIndexOf("-") : label.lastIndexOf(" ");
|
|
172
|
+
const base = selected ? { color: palette.signal, inverse: true } : cx("normal");
|
|
173
|
+
const last = selected ? { color: palette.signal, bold: true, inverse: true } : cx("accent", "bold");
|
|
174
|
+
return h(Text, { wrap: "truncate" }, h(Text, base, label.slice(0, idx + 1)), h(Text, last, label.slice(idx + 1)), ...(selected ? [h(Text, base, " ".repeat(Math.max(0, width - label.length)))] : []));
|
|
175
|
+
};
|
|
176
|
+
/* render a vertical scroll bar (like the one of the read dialog of the task board)
|
|
177
|
+
over the rows of a viewport of a height (at an offset into a total count of
|
|
178
|
+
lines) below some leading blank rows, shown only if the lines do not fit */
|
|
179
|
+
const scrollBar = (lead, height, total, off) => {
|
|
180
|
+
const max = Math.max(0, total - height);
|
|
181
|
+
const thumbH = Math.max(1, Math.round(height * Math.min(1, height / Math.max(1, total))));
|
|
182
|
+
const thumbY = max === 0 ? 0 : Math.round((height - thumbH) * Math.min(off, max) / max);
|
|
183
|
+
return h(Box, { flexDirection: "column", width: 1 }, ...Array.from({ length: lead + height }, (_, i) => h(Text, { key: `bar-${i}`, ...cx("signal") }, i < lead || total <= height ? " " : i - lead >= thumbY && i - lead < thumbY + thumbH ? "█" : "░")));
|
|
184
|
+
};
|
|
185
|
+
/* render a line of a page, with its search matches and its selected reference highlighted */
|
|
186
|
+
const renderLine = (line, refSel) => h(Text, { wrap: "truncate" }, line.length === 0 ? " " : line.map((span, i) => h(Text, {
|
|
187
|
+
key: i,
|
|
188
|
+
...toneProps(span.tone),
|
|
189
|
+
...(span.ref !== undefined ? { underline: true } : {}),
|
|
190
|
+
...(span.match === true ? { color: palette.signal, inverse: true } : {}),
|
|
191
|
+
...(span.ref !== undefined && span.ref === refSel ? { color: palette.signal, inverse: true } : {})
|
|
192
|
+
}, sanitize(span.text))));
|
|
193
|
+
/* the root component of the manual page browser, starting on the start page,
|
|
194
|
+
resp. on the browser page with an initially selected page or search */
|
|
195
|
+
const App = ({ catalog, initial }) => {
|
|
196
|
+
const { exit } = useApp();
|
|
197
|
+
const { columns, rows } = useWindowSize();
|
|
198
|
+
const first = initial?.id ?? [...catalog.pages.keys()][0] ?? "";
|
|
199
|
+
/* the state: the shown page (the start page with the catalog, or the
|
|
200
|
+
browser page with master and detail), the search query (with its
|
|
201
|
+
cursor position, and whether it is being typed), the selected page of
|
|
202
|
+
the catalog and of the master, the scroll offset of the detail, the
|
|
203
|
+
selected reference of the detail, and whether the syntax highlighter of
|
|
204
|
+
the code blocks is loaded */
|
|
205
|
+
const [page, setPage] = React.useState(initial !== null ? "browse" : "start");
|
|
206
|
+
const [query, setQuery] = React.useState(initial?.query ?? "");
|
|
207
|
+
const [cursor, setCursor] = React.useState(initial?.query.length ?? 0);
|
|
208
|
+
const [typing, setTyping] = React.useState(false);
|
|
209
|
+
const [startSel, setStartSel] = React.useState(first);
|
|
210
|
+
const [sel, setSel] = React.useState(first);
|
|
211
|
+
const [scroll, setScroll] = React.useState(0);
|
|
212
|
+
const [ref, setRef] = React.useState(-1);
|
|
213
|
+
const [coded, setCoded] = React.useState(false);
|
|
214
|
+
const gridOff = React.useRef(0);
|
|
215
|
+
const masterOff = React.useRef(0);
|
|
216
|
+
/* the cursor position within the search query, clamped as the query
|
|
217
|
+
can be cleared without moving the cursor */
|
|
218
|
+
const cur = Math.min(cursor, query.length);
|
|
219
|
+
/* load the syntax highlighter of the code blocks in the background */
|
|
220
|
+
React.useEffect(() => {
|
|
221
|
+
codeLoad().then(() => {
|
|
222
|
+
setCoded(true);
|
|
223
|
+
}).catch(() => {
|
|
224
|
+
/* keep the code blocks without syntax highlighting */
|
|
225
|
+
});
|
|
226
|
+
}, []);
|
|
227
|
+
/* report mouse presses while the browser runs */
|
|
228
|
+
React.useEffect(() => {
|
|
229
|
+
mouseReporting(true);
|
|
230
|
+
return () => {
|
|
231
|
+
mouseReporting(false);
|
|
232
|
+
};
|
|
233
|
+
}, []);
|
|
234
|
+
/* the geometry: the content width and height (without and with the
|
|
235
|
+
search field and its blank line within the panel), the catalog grid,
|
|
236
|
+
the master width (of its longest label plus its padding, capped at a
|
|
237
|
+
third of the content width), the detail width (besides the adjacent
|
|
238
|
+
borders and paddings of the master and detail panes, where the scroll bar
|
|
239
|
+
of the detail takes the right padding column of its pane), and the
|
|
240
|
+
search field width (on the start page, resp. of the master) */
|
|
241
|
+
const W = columns - 2 * LEFT;
|
|
242
|
+
const H = Math.max(1, rows - TOP - BOTTOM);
|
|
243
|
+
const HC = H + 4;
|
|
244
|
+
const MW = React.useMemo(() => Math.min(Math.floor(W / 3), Math.max(16, ...[...catalog.pages.values()].map((p) => p.label.length + 1)) + 2 * PAD), [catalog, W]);
|
|
245
|
+
const DW = Math.max(10, W - MW - 4);
|
|
246
|
+
const FW = page === "start" ? Math.min(44, W) : MW;
|
|
247
|
+
/* the search hits (live while typing), the catalog grid and the master
|
|
248
|
+
entries (both reduced to the matching pages), and the selected page of the
|
|
249
|
+
catalog and of the master, which fall back to the first matching page if
|
|
250
|
+
the selected one does not match the search */
|
|
251
|
+
const hits = React.useMemo(() => searchPages(catalog, query), [catalog, query]);
|
|
252
|
+
const grid = React.useMemo(() => layoutGrid(catalog, hits, W - 2 * PAD), [catalog, hits, W]);
|
|
253
|
+
const items = React.useMemo(() => masterItems(catalog, hits), [catalog, hits]);
|
|
254
|
+
const pageIds = items.flatMap((item) => item !== null && item.kind === "page" ? [item.page.id] : []);
|
|
255
|
+
const startId = hits === null || hits.has(startSel) ? startSel : pageIds[0] ?? startSel;
|
|
256
|
+
const selId = hits === null || hits.has(sel) ? sel : pageIds[0] ?? sel;
|
|
257
|
+
const current = hits === null || hits.has(selId) ? catalog.pages.get(selId) : undefined;
|
|
258
|
+
React.useEffect(() => {
|
|
259
|
+
if (startId !== startSel)
|
|
260
|
+
setStartSel(startId);
|
|
261
|
+
if (selId !== sel)
|
|
262
|
+
setSel(selId);
|
|
263
|
+
}, [startId, startSel, selId, sel]);
|
|
264
|
+
/* the rendered selected page (wrapped within the left and right padding of the detail),
|
|
265
|
+
whose detail starts at its top without any selected reference whenever
|
|
266
|
+
another page is selected */
|
|
267
|
+
const rendered = React.useMemo(() => current !== undefined ?
|
|
268
|
+
renderPage(current.md, DW - 2 * PAD, current.id, (id) => catalog.pages.has(id)) : { lines: [], refs: [] }, [catalog, current, DW, coded]);
|
|
269
|
+
React.useEffect(() => {
|
|
270
|
+
setScroll(0);
|
|
271
|
+
setRef(-1);
|
|
272
|
+
}, [selId]);
|
|
273
|
+
const maxScroll = Math.max(0, rendered.lines.length - H);
|
|
274
|
+
const top = Math.min(scroll, maxScroll);
|
|
275
|
+
/* open a page on the browser page, leaving a search not matching it */
|
|
276
|
+
const open = (id) => {
|
|
277
|
+
if (hits !== null && !hits.has(id))
|
|
278
|
+
setQuery("");
|
|
279
|
+
setTyping(false);
|
|
280
|
+
setSel(id);
|
|
281
|
+
setPage("browse");
|
|
282
|
+
};
|
|
283
|
+
/* step the selection of the master, scroll the detail, and select the
|
|
284
|
+
next/previous reference of the detail, starting at the shown part of
|
|
285
|
+
the detail (and scrolling to the reference if it is not shown) */
|
|
286
|
+
const selectStep = (step) => {
|
|
287
|
+
if (pageIds.length > 0)
|
|
288
|
+
setSel(pageIds[Math.max(0, Math.min(pageIds.length - 1, pageIds.indexOf(selId) + step))]);
|
|
289
|
+
};
|
|
290
|
+
const scrollBy = (delta) => {
|
|
291
|
+
setScroll(Math.max(0, Math.min(maxScroll, top + delta)));
|
|
292
|
+
};
|
|
293
|
+
const cycleRef = (step) => {
|
|
294
|
+
const refs = rendered.refs.map((r, i) => ({ ...r, i })).filter((r) => r.line >= 0);
|
|
295
|
+
if (refs.length === 0)
|
|
296
|
+
return;
|
|
297
|
+
const shown = (r) => r.line >= top && r.line < top + H;
|
|
298
|
+
const k = refs.findIndex((r) => r.i === ref);
|
|
299
|
+
const next = k >= 0 && shown(refs[k]) ? refs[(k + step + refs.length) % refs.length] :
|
|
300
|
+
step > 0 ? refs.find((r) => r.line >= top) ?? refs[0] : refs.findLast((r) => r.line < top + H) ?? refs[refs.length - 1];
|
|
301
|
+
setRef(next.i);
|
|
302
|
+
if (!shown(next))
|
|
303
|
+
setScroll(Math.max(0, Math.min(maxScroll, next.line - Math.floor(H / 3))));
|
|
304
|
+
};
|
|
305
|
+
/* edit the search query by a typed key at the cursor position (like the input
|
|
306
|
+
boxes of the task board): the left/right arrows move the cursor by one
|
|
307
|
+
character, HOME/END move it to the start/end, BACKSPACE removes the character
|
|
308
|
+
before the cursor, DELETE removes the character at the cursor, and all other
|
|
309
|
+
input is inserted at the cursor */
|
|
310
|
+
const edit = (input, key) => {
|
|
311
|
+
if (key.leftArrow || key.rightArrow) {
|
|
312
|
+
setCursor(Math.max(0, Math.min(query.length, cur + (key.leftArrow ? -1 : 1))));
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (key.home || key.end) {
|
|
316
|
+
setCursor(key.home ? 0 : query.length);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
if (key.backspace) {
|
|
320
|
+
if (cur > 0) {
|
|
321
|
+
setQuery(query.slice(0, cur - 1) + query.slice(cur));
|
|
322
|
+
setCursor(cur - 1);
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (key.delete) {
|
|
327
|
+
if (cur < query.length)
|
|
328
|
+
setQuery(query.slice(0, cur) + query.slice(cur + 1));
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
if (input !== "" && !key.ctrl && !key.meta && !key.tab && !key.return && !key.escape) {
|
|
332
|
+
const text = sanitize(input.replace(/[\r\n]+/g, " "));
|
|
333
|
+
setQuery(query.slice(0, cur) + text + query.slice(cur));
|
|
334
|
+
setCursor(cur + text.length);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
};
|
|
339
|
+
/* handle the mouse: a click onto the search field starts typing into it
|
|
340
|
+
(clearing the search on its clear symbol), a click onto a page of the
|
|
341
|
+
catalog or the master opens it, a click onto a reference of the detail
|
|
342
|
+
follows it, and the wheel (64/65: up/down) steps the selection of the
|
|
343
|
+
catalog and the master, resp. scrolls the detail */
|
|
344
|
+
useMouseReports((btn, mx, my) => {
|
|
345
|
+
const wheel = btn === 64 ? -1 : btn === 65 ? 1 : 0;
|
|
346
|
+
if (btn !== 0 && wheel === 0)
|
|
347
|
+
return;
|
|
348
|
+
if (btn === 0) {
|
|
349
|
+
const inField = my >= TOP - 4 && my < TOP - 1 && mx < LEFT + FW;
|
|
350
|
+
setTyping(inField);
|
|
351
|
+
if (inField && my === TOP - 3 && mx >= LEFT + FW - 4 && mx <= LEFT + FW - 2)
|
|
352
|
+
setQuery("");
|
|
353
|
+
if (inField) {
|
|
354
|
+
setRef(-1);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (page === "start") {
|
|
359
|
+
if (wheel !== 0) {
|
|
360
|
+
setStartSel(moveGrid(grid, startId, 0, wheel));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const gx = mx - LEFT - PAD;
|
|
364
|
+
const c = Math.floor(gx / (grid.colW + GAP));
|
|
365
|
+
const cell = my >= TOP && my < TOP + H && gx >= 0 && c < grid.n && gx - c * (grid.colW + GAP) < grid.colW ?
|
|
366
|
+
grid.lines[gridOff.current + my - TOP]?.[c] : undefined;
|
|
367
|
+
if (cell !== undefined && cell !== null && cell.kind === "page") {
|
|
368
|
+
setStartSel(cell.page.id);
|
|
369
|
+
setSel(cell.page.id);
|
|
370
|
+
setPage("browse");
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const inMaster = mx <= LEFT + MW + 1;
|
|
375
|
+
if (wheel !== 0) {
|
|
376
|
+
if (inMaster)
|
|
377
|
+
selectStep(wheel);
|
|
378
|
+
else
|
|
379
|
+
scrollBy(3 * wheel);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (my < TOP || my >= TOP + H)
|
|
383
|
+
return;
|
|
384
|
+
if (inMaster) {
|
|
385
|
+
const item = items[masterOff.current + my - TOP];
|
|
386
|
+
if (item !== undefined && item !== null && item.kind === "page") {
|
|
387
|
+
setSel(item.page.id);
|
|
388
|
+
setRef(-1);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const line = rendered.lines[top + my - TOP];
|
|
393
|
+
let x = LEFT + MW + 4 + PAD;
|
|
394
|
+
for (const span of line ?? []) {
|
|
395
|
+
if (mx >= x && mx < x + span.text.length) {
|
|
396
|
+
if (span.ref !== undefined)
|
|
397
|
+
open(rendered.refs[span.ref].id);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
x += span.text.length;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
/* handle the keyboard */
|
|
404
|
+
useInput((input, key) => {
|
|
405
|
+
/* ignore key releases and mouse reports (handled by "useMouseReports" above) */
|
|
406
|
+
if (key.eventType === "release" || /^\[<\d+;\d+;\d+[Mm]$/.test(input))
|
|
407
|
+
return;
|
|
408
|
+
/* on the start page, typing into the search field (filtering the catalog
|
|
409
|
+
live) and submitting the search (opening the browser page with the
|
|
410
|
+
selected, i.e. initially the first, matching page) or leaving the field,
|
|
411
|
+
clearing the search, and selecting and opening a page of the catalog */
|
|
412
|
+
if (page === "start") {
|
|
413
|
+
if (typing) {
|
|
414
|
+
if (key.escape)
|
|
415
|
+
setTyping(false);
|
|
416
|
+
else if (key.return) {
|
|
417
|
+
setTyping(false);
|
|
418
|
+
if (hits !== null) {
|
|
419
|
+
if (grid.pos.size > 0)
|
|
420
|
+
setSel(startId);
|
|
421
|
+
setPage("browse");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (key.escape || key.return || edit(input, key))
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
else if (input === "/") {
|
|
428
|
+
setTyping(true);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
else if (input === "q") {
|
|
432
|
+
exit();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
else if (key.escape) {
|
|
436
|
+
setQuery("");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
else if (key.return) {
|
|
440
|
+
if (grid.pos.size > 0) {
|
|
441
|
+
setSel(startId);
|
|
442
|
+
setPage("browse");
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (key.upArrow || key.downArrow)
|
|
447
|
+
setStartSel(moveGrid(grid, startId, 0, key.upArrow ? -1 : 1));
|
|
448
|
+
else if (key.leftArrow || key.rightArrow)
|
|
449
|
+
setStartSel(moveGrid(grid, startId, key.leftArrow ? -1 : 1, 0));
|
|
450
|
+
else if (key.home || key.end) {
|
|
451
|
+
const ids = [...grid.pos.keys()];
|
|
452
|
+
setStartSel(ids[key.home ? 0 : ids.length - 1] ?? startId);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
/* on the browser page, ESCAPE first clears the search query, then leaves
|
|
457
|
+
the search field, and finally returns to the start page */
|
|
458
|
+
if (key.escape) {
|
|
459
|
+
if (query !== "")
|
|
460
|
+
setQuery("");
|
|
461
|
+
else if (typing)
|
|
462
|
+
setTyping(false);
|
|
463
|
+
else {
|
|
464
|
+
setStartSel(selId);
|
|
465
|
+
setPage("start");
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
/* typing into the search field (filtering the master live), resp.
|
|
470
|
+
starting it, quitting, selecting and following a reference, and
|
|
471
|
+
entering resp. leaving the references of the detail */
|
|
472
|
+
if (typing) {
|
|
473
|
+
if (key.return) {
|
|
474
|
+
setTyping(false);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (edit(input, key))
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
else if (input === "/") {
|
|
481
|
+
setTyping(true);
|
|
482
|
+
setRef(-1);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
else if (input === "q") {
|
|
486
|
+
exit();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
else if (key.tab) {
|
|
490
|
+
cycleRef(key.shift ? -1 : 1);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
else if (key.rightArrow) {
|
|
494
|
+
if (ref < 0)
|
|
495
|
+
cycleRef(1);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
else if (key.leftArrow) {
|
|
499
|
+
setRef(-1);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
else if (key.return) {
|
|
503
|
+
const target = rendered.refs[ref];
|
|
504
|
+
if (target !== undefined)
|
|
505
|
+
open(target.id);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
else if (input === " ") {
|
|
509
|
+
scrollBy(H - 1);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
/* selecting in the master (resp. the references of the detail, while
|
|
513
|
+
one is selected) and scrolling the detail */
|
|
514
|
+
if ((key.upArrow || key.downArrow) && ref >= 0)
|
|
515
|
+
cycleRef(key.upArrow ? -1 : 1);
|
|
516
|
+
else if (key.upArrow || key.downArrow)
|
|
517
|
+
selectStep(key.upArrow ? -1 : 1);
|
|
518
|
+
else if (key.pageUp || key.pageDown)
|
|
519
|
+
scrollBy((key.pageUp ? -1 : 1) * (H - 1));
|
|
520
|
+
else if (key.home || key.end)
|
|
521
|
+
setScroll(key.home ? 0 : maxScroll);
|
|
522
|
+
});
|
|
523
|
+
/* refuse to draw into a too small window */
|
|
524
|
+
if (columns < MIN_W || rows < MIN_H)
|
|
525
|
+
return h(Box, { width: columns, height: rows, justifyContent: "center", alignItems: "center" }, h(Text, cx("signal"), `window too small (${columns}×${rows}) — needs at least ${MIN_W}×${MIN_H}`));
|
|
526
|
+
/* the header, enclosed on its left and right side by the upward lines of the rule below it,
|
|
527
|
+
with the page counts (as matching/total counts during a search, in signal color without any match) */
|
|
528
|
+
const count = (groups) => {
|
|
529
|
+
const pages = groups.flatMap((group) => group.pages);
|
|
530
|
+
return hits === null ? String(pages.length) : `${pages.filter((p) => hits.has(p.id)).length}/${pages.length}`;
|
|
531
|
+
};
|
|
532
|
+
const counts = cx("bold", hits !== null && hits.size === 0 && "signal");
|
|
533
|
+
const header = h(Box, { paddingX: 1 }, h(Box, cx("fixed"), h(Text, cx("dim"), "│")), h(Box, cx("grow", "bar"), h(Text, cx("normal", "truncate"), "⧉ ASE: ", h(Text, cx("bold"), "Manpage Browser"), " · skills: ", h(Text, counts, count(catalog.skills)), " · commands: ", h(Text, counts, count(catalog.commands)))), h(Box, cx("fixed"), h(Text, cx("dim"), "│")));
|
|
534
|
+
/* the search field in the frame of an input box (like the input boxes of the
|
|
535
|
+
task board, in accent color, resp. in signal color while typing): the part
|
|
536
|
+
of the query around the cursor (with an inverse cursor cell at the cursor,
|
|
537
|
+
resp. behind its end) while typing, else its tail, or a placeholder,
|
|
538
|
+
followed by a clickable clear symbol for a non-empty query */
|
|
539
|
+
const fieldW = FW;
|
|
540
|
+
const textW = Math.max(1, fieldW - 8);
|
|
541
|
+
const tone = typing ? "signal" : "accent";
|
|
542
|
+
const shown = sanitize(query);
|
|
543
|
+
const hint = "search manpages… (/)".slice(0, textW);
|
|
544
|
+
const cells = shown.length + (typing && cur >= shown.length ? 1 : 0);
|
|
545
|
+
const fieldX = Math.max(0, typing ? cur + 1 - textW : shown.length - textW);
|
|
546
|
+
const used = typing || query !== "" ? Math.min(cells, fieldX + textW) - fieldX : hint.length;
|
|
547
|
+
const text = typing ? [
|
|
548
|
+
h(Text, { key: "pre", ...cx(tone, "bold") }, shown.slice(fieldX, cur)),
|
|
549
|
+
h(Text, { key: "cur", ...cx(tone, "bold", "inverse") }, shown[cur] ?? " "),
|
|
550
|
+
h(Text, { key: "post", ...cx(tone, "bold") }, shown.slice(cur + 1, fieldX + textW))
|
|
551
|
+
] : query !== "" ?
|
|
552
|
+
[h(Text, { key: "text", ...cx(tone, "bold") }, shown.slice(fieldX))] :
|
|
553
|
+
[h(Text, { key: "hint", ...cx("dim") }, hint)];
|
|
554
|
+
const field = h(Box, { flexDirection: "column", width: fieldW }, h(Text, cx(tone), `╭${"─".repeat(fieldW - 2)}╮`), h(Text, {}, h(Text, cx(tone), "│ "), h(Text, cx(typing ? "signal" : "dim"), "⌕ "), ...text, " ".repeat(Math.max(0, textW - used)), h(Text, cx(tone, "bold"), query !== "" ? " ✕" : " "), h(Text, cx(tone), " │")), h(Text, cx(tone), `╰${"─".repeat(fieldW - 2)}╯`));
|
|
555
|
+
/* the content of the start page: the search field, a blank line, and the
|
|
556
|
+
catalog grid (reduced to the matching pages of a search, and laid out
|
|
557
|
+
within a left and right padding, also towards its scroll bar) */
|
|
558
|
+
let content;
|
|
559
|
+
if (page === "start") {
|
|
560
|
+
/* the column of the selected page (with the section headings of the first column) */
|
|
561
|
+
const at = grid.pos.get(startId) ?? { c: 0, l: 0 };
|
|
562
|
+
const col = grid.lines.map((cells) => cells[at.c] ?? (cells[0]?.kind === "section" ? cells[0] : null));
|
|
563
|
+
gridOff.current = follow(gridOff.current, headOf(col, at.l, H), at.l, H, grid.lines.length);
|
|
564
|
+
content = h(Box, { height: HC }, h(Box, { flexDirection: "column", width: W }, field, h(Text, {}, " "), ...(grid.lines.length === 0 ? [h(Text, { key: "none", ...cx("dim") }, " ".repeat(PAD) + "(no matching manpages)")] :
|
|
565
|
+
grid.lines.slice(gridOff.current, gridOff.current + H).map((cells, l) => h(Box, { key: `line-${gridOff.current + l}`, paddingX: PAD }, ...cells.map((cell, c) => h(Box, { key: `cell-${c}`, width: grid.colW, marginRight: c < grid.n - 1 ? GAP : 0 }, renderCell(cell, grid.colW, cell !== null && cell.kind === "page" && cell.page.id === startId))))))), scrollBar(HC - H, H, grid.lines.length, gridOff.current));
|
|
566
|
+
}
|
|
567
|
+
/* the content of the browser page, in two adjacent panes (with the border
|
|
568
|
+
of the active one in signal color, i.e. of the detail while one of its
|
|
569
|
+
references is selected, else of the master): the master with the search
|
|
570
|
+
field, a blank line, and the entries, and the detail with the information
|
|
571
|
+
line in the frame of a box (aligned with the search field), a blank line,
|
|
572
|
+
and the rendered selected page (aligned with the entries) */
|
|
573
|
+
else {
|
|
574
|
+
const selIdx = Math.max(0, items.findIndex((item) => item !== null && item.kind === "page" && item.page.id === selId));
|
|
575
|
+
masterOff.current = follow(masterOff.current, headOf(items, selIdx, H), selIdx, H, items.length);
|
|
576
|
+
const re = hits?.get(selId);
|
|
577
|
+
const master = h(Box, { flexDirection: "column", width: MW }, field, h(Text, {}, " "), ...items.slice(masterOff.current, masterOff.current + H).map((item, i) => h(Box, { key: `item-${masterOff.current + i}`, width: MW, paddingX: PAD }, renderCell(item, MW - 2 * PAD, item !== null && item.kind === "page" && item.page.id === selId))));
|
|
578
|
+
const where = rendered.lines.length <= H ? "all" :
|
|
579
|
+
`${top + 1}-${Math.min(rendered.lines.length, top + H)}/${rendered.lines.length}`;
|
|
580
|
+
const bar = scrollBar(HC - H, H, rendered.lines.length, top);
|
|
581
|
+
const detail = h(Box, { flexDirection: "column", width: DW }, h(Text, cx("dim"), `╭${"─".repeat(DW - 2)}╮`), h(Box, { width: DW }, h(Box, cx("fixed"), h(Text, cx("dim"), "│ ")), h(Box, { flexGrow: 1, flexShrink: 1, minWidth: 0 }, current === undefined ? h(Text, cx("dim"), " ") :
|
|
582
|
+
h(Text, cx("normal", "truncate"), `${current.kind}: `, h(Text, cx("bold"), current.label), ` — ${sanitize(current.title)}`)), h(Box, cx("fixed"), h(Text, cx("dim"), ` lines: ${where} │`))), h(Text, cx("dim"), `╰${"─".repeat(DW - 2)}╯`), h(Text, {}, " "), ...(current === undefined ? [h(Text, { key: "none", ...cx("dim") }, " ".repeat(PAD) + "(no matching manpages)")] :
|
|
583
|
+
rendered.lines.slice(top, top + H).map((line, i) => h(Box, { key: `line-${top + i}`, width: DW, paddingX: PAD }, renderLine(re !== undefined ? markMatches(line, re) : line, ref)))));
|
|
584
|
+
const pane = (active) => ({ borderStyle: "round", borderColor: active ? palette.signal : palette.dim, height: HC + 2, paddingLeft: LEFT - 2 });
|
|
585
|
+
content = h(Box, { marginX: 1 }, h(Box, { ...pane(ref < 0), paddingRight: 1 }, master), h(Box, pane(ref >= 0), detail, bar));
|
|
586
|
+
}
|
|
587
|
+
/* the key hints and the status line in the last line of the screen, enclosed
|
|
588
|
+
on its left and right side by the downward lines of the rule above it */
|
|
589
|
+
const hints = page === "start" ? [
|
|
590
|
+
"↑/↓/←/→/Wheel: select manpage · ⏎/Left-Click: open manpage · ⇤/⇥: first/last manpage",
|
|
591
|
+
typing ? "⏎: search manpages · ESC: leave search field · ←/→/⇤/⇥: move cursor · ⌫/DEL: delete character" :
|
|
592
|
+
`/: search manpages${query !== "" ? " · ESC: clear search" : ""} · q: quit`
|
|
593
|
+
] : [
|
|
594
|
+
ref >= 0 ?
|
|
595
|
+
"↑/↓/TAB/⇧TAB: select previous/next reference · ←: select side-bar · ⇈/⇊/SPACE/⇤/⇥/Wheel: scroll manpage" :
|
|
596
|
+
"↑/↓/Wheel: select manpage · →/TAB/⇧TAB: select first reference · ⇈/⇊/SPACE/⇤/⇥/Wheel: scroll manpage",
|
|
597
|
+
typing ? "ESC: clear search, leave search field · ⏎: leave search field · ←/→/⇤/⇥: move cursor · ⌫/DEL: delete character" :
|
|
598
|
+
`⏎/Left-Click: follow reference · /: search manpages · ESC: ${query !== "" ? "clear search" : "back to catalog"} · q: quit`
|
|
599
|
+
];
|
|
600
|
+
const report = typing ? "searching all manpages for all keywords (with fuzzy matching as fallback)" : null;
|
|
601
|
+
/* the screen: the header, the content (of the start page in a panel, always
|
|
602
|
+
active and hence with its border in signal color, aligned with the rules
|
|
603
|
+
of the header and the status line, and without a right padding, as the
|
|
604
|
+
content covers it with its scroll bar), the key hints, and the status line */
|
|
605
|
+
const panel = { borderStyle: "round", borderColor: palette.signal, marginX: 1, paddingLeft: LEFT - 2 };
|
|
606
|
+
return h(Box, { width: columns, height: rows, flexDirection: "column" }, header, h(Text, cx("dim"), " ╰" + "─".repeat(Math.max(0, columns - 4)) + "╯ "), page === "start" ? h(Box, { flexDirection: "column", ...panel }, content) : content, ...hints.map((hint, i) => h(Box, { key: `keys-${i}`, ...cx("bar") }, h(Text, cx("hint"), hint))), h(Text, cx("dim"), " ╭" + "─".repeat(Math.max(0, columns - 4)) + "╮ "), h(Box, { paddingX: 1 }, h(Box, cx("fixed"), h(Text, cx("dim"), "│")), h(Box, cx("grow", "bar"), h(Text, cx("hint", report !== null && "signal"), report ?? ["⧉ ASE: ", h(Text, { key: "app", ...cx("bold") }, "Manpage Browser"),
|
|
607
|
+
" · version: ", h(Text, { key: "version", ...cx("bold") }, `ASE ${pkg.version}`)])), h(Box, cx("fixed"), h(Text, cx("dim"), "│"))));
|
|
608
|
+
};
|
|
609
|
+
/* run the manual page browser until the user quits, where the words of a
|
|
610
|
+
requested page select the page fitting exactly onto them, else are searched */
|
|
611
|
+
export const runManTUI = async (log, words = []) => {
|
|
612
|
+
const catalog = loadCatalog();
|
|
613
|
+
const query = words.join(" ").trim();
|
|
614
|
+
const found = query !== "" ? findPage(catalog, words) : undefined;
|
|
615
|
+
const initial = query === "" ? null : { id: found?.id ?? null, query: found !== undefined ? "" : sanitize(query) };
|
|
616
|
+
loadPalette(log);
|
|
617
|
+
/* defer stderr log output while Ink draws, as it bypasses patchConsole */
|
|
618
|
+
log.defer(true);
|
|
619
|
+
/* ensure the terminal never stays in mouse reporting mode,
|
|
620
|
+
even on a premature process exit (e.g. an uncaught exception) */
|
|
621
|
+
const reset = () => {
|
|
622
|
+
mouseReporting(false);
|
|
623
|
+
};
|
|
624
|
+
process.once("exit", reset);
|
|
625
|
+
try {
|
|
626
|
+
const app = render(h(App, { catalog, initial }), {
|
|
627
|
+
alternateScreen: true, exitOnCtrlC: true, patchConsole: true
|
|
628
|
+
});
|
|
629
|
+
await app.waitUntilExit();
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
process.off("exit", reset);
|
|
633
|
+
mouseReporting(false);
|
|
634
|
+
log.defer(false);
|
|
635
|
+
}
|
|
636
|
+
};
|