@no-k/hermes 0.1.0 → 0.2.0

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,184 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isSourcePath, isUtilityScope } from "./catalog.js";
4
+ import { readDestination, validateFilePlans, writePlannedFile } from "./files.js";
5
+ import { PROJECT_CONFIG_FILENAME, mergeProjectConfig, planProjectConfig, resolveInstallDirectory, } from "./project-config.js";
6
+ import { planInstalledVersions, sourceHash } from "./versions.js";
7
+ function itemNames(names) {
8
+ const [first, ...rest] = names;
9
+ if (!first)
10
+ throw new Error("Specify at least one item.");
11
+ return [first, ...rest];
12
+ }
13
+ function sourceContent(packageDirectory, relative) {
14
+ if (!isSourcePath(relative))
15
+ throw new Error("Invalid source template path: " + relative);
16
+ const directory = realpathSync(path.join(packageDirectory, "templates"));
17
+ const file = realpathSync(path.join(directory, relative));
18
+ const fromRoot = path.relative(directory, file);
19
+ if (path.isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(".." + path.sep)) {
20
+ throw new Error("Source template escapes its directory: " + relative);
21
+ }
22
+ return readFileSync(file, "utf8");
23
+ }
24
+ export function planUtilityFiles(targets, packageDirectory, decisions = []) {
25
+ if (!targets.length || targets.some((target) => !target.items.length))
26
+ throw new Error("Specify at least one item.");
27
+ const choices = new Map();
28
+ for (const decision of decisions) {
29
+ const destination = decision.conflict.destination;
30
+ if (!path.isAbsolute(destination) || choices.has(destination) || !["keep", "replace"].includes(decision.action)) {
31
+ throw new Error("Invalid or duplicate file decision: " + destination);
32
+ }
33
+ choices.set(destination, decision);
34
+ }
35
+ const sources = new Map();
36
+ const outputs = new Map();
37
+ const items = new Map();
38
+ const license = readFileSync(path.join(packageDirectory, "LICENSE"), "utf8");
39
+ for (const target of targets) {
40
+ if (!path.isAbsolute(target.directory))
41
+ throw new Error("Expected an absolute destination: " + target.directory);
42
+ for (const item of target.items) {
43
+ items.set(item.name, item);
44
+ for (const file of [...item.files, "LICENSE"]) {
45
+ let content = sources.get(file);
46
+ if (content === undefined) {
47
+ content = file === "LICENSE" ? license : sourceContent(packageDirectory, file);
48
+ sources.set(file, content);
49
+ }
50
+ if (file !== "LICENSE") {
51
+ const expected = item.modules
52
+ .flatMap((module) => Object.entries(module.files))
53
+ .find(([source]) => source === file)?.[1];
54
+ if (!expected || sourceHash(content) !== expected)
55
+ throw new Error("Template does not match module version: " + file);
56
+ }
57
+ const destination = path.join(target.directory, file);
58
+ const previous = outputs.get(destination);
59
+ if (previous && previous.content !== content)
60
+ throw new Error("Selected sources conflict at: " + destination);
61
+ if (previous)
62
+ previous.names.add(item.name);
63
+ else
64
+ outputs.set(destination, { file, content, names: new Set([item.name]) });
65
+ }
66
+ }
67
+ }
68
+ const conflicts = [];
69
+ const files = [...outputs]
70
+ .sort(([a], [b]) => a.localeCompare(b))
71
+ .map(([destination, source]) => {
72
+ const existingContent = readDestination(destination);
73
+ let action = existingContent === null ? "create" : "keep";
74
+ if (existingContent !== null && existingContent !== source.content) {
75
+ const conflict = {
76
+ file: source.file,
77
+ content: source.content,
78
+ destination,
79
+ existingContent,
80
+ names: itemNames([...source.names]),
81
+ };
82
+ conflicts.push(conflict);
83
+ const decision = choices.get(destination);
84
+ if (decision?.action === "replace" &&
85
+ decision.conflict.existingContent === existingContent &&
86
+ decision.conflict.content === source.content)
87
+ action = "replace";
88
+ }
89
+ return { file: source.file, content: source.content, destination, existingContent, action };
90
+ });
91
+ validateFilePlans(files);
92
+ const selected = [...items.values()];
93
+ return {
94
+ files,
95
+ conflicts,
96
+ dependencies: [...new Set(selected.flatMap((item) => item.dependencies))].sort(),
97
+ devDependencies: [...new Set(selected.flatMap((item) => item.devDependencies ?? []))].sort(),
98
+ runtimeRequirements: selected.flatMap((item) => (item.runtime ? [{ name: item.name, runtime: item.runtime }] : [])),
99
+ };
100
+ }
101
+ export function createInstallPlan(request, catalog, packageDirectory) {
102
+ if (!path.isAbsolute(request.config.destination) ||
103
+ path.basename(request.config.destination) !== PROJECT_CONFIG_FILENAME) {
104
+ throw new Error("Expected an absolute no-k.config.ts destination.");
105
+ }
106
+ const cwd = path.dirname(request.config.destination);
107
+ const seenScopes = new Set();
108
+ const seenNames = new Set();
109
+ const targets = request.targets.map((target) => {
110
+ if (!isUtilityScope(target.scope) || seenScopes.has(target.scope)) {
111
+ throw new Error("Invalid or duplicate installation scope: " + target.scope);
112
+ }
113
+ seenScopes.add(target.scope);
114
+ const items = target.names.map((name) => {
115
+ const item = catalog.items.find((candidate) => candidate.name === name);
116
+ if (!item || seenNames.has(name))
117
+ throw new Error("Unknown or duplicate installation item: " + name);
118
+ if (item.scope !== target.scope) {
119
+ throw new Error("Installation scope mismatch for " + name + ": expected " + item.scope + ".");
120
+ }
121
+ seenNames.add(name);
122
+ return item;
123
+ });
124
+ return { items, directory: resolveInstallDirectory(target.directory, cwd) };
125
+ });
126
+ const utilities = planUtilityFiles(targets, packageDirectory, request.fileDecisions);
127
+ const { config, dependencies, compatibilityErrors, compatibilityGuards } = planInstalledVersions(mergeProjectConfig(request.config, request.targets), targets, utilities.files, catalog, cwd);
128
+ const configFile = planProjectConfig(request.config, config);
129
+ validateFilePlans([...utilities.files, configFile]);
130
+ return {
131
+ ...request,
132
+ ...utilities,
133
+ dependencies,
134
+ configFile,
135
+ nextConfig: config,
136
+ compatibilityErrors,
137
+ compatibilityGuards,
138
+ };
139
+ }
140
+ export class InstallExecutionError extends Error {
141
+ phase;
142
+ completedFiles;
143
+ constructor(phase, completedFiles, cause) {
144
+ super((phase === "config"
145
+ ? "Utilities were processed, but no-k.config.ts could not be saved: "
146
+ : "Installation stopped before saving no-k.config.ts: ") +
147
+ (cause instanceof Error ? cause.message : String(cause)), { cause });
148
+ this.phase = phase;
149
+ this.completedFiles = completedFiles;
150
+ this.name = "InstallExecutionError";
151
+ }
152
+ }
153
+ export function executeInstallPlan(plan, onFile) {
154
+ if (plan.compatibilityErrors.length)
155
+ throw new Error("Incompatible installation; no files were written:\n" + plan.compatibilityErrors.join("\n"));
156
+ validateFilePlans(plan.compatibilityGuards);
157
+ validateFilePlans([...plan.files, plan.configFile]);
158
+ if (plan.dryRun)
159
+ return { status: "dry-run", files: plan.files, configFile: plan.configFile };
160
+ const completed = [];
161
+ try {
162
+ for (const file of plan.files) {
163
+ writePlannedFile(file);
164
+ completed.push(file);
165
+ onFile?.(file);
166
+ }
167
+ // 생성·교체·유지한 파일을 다시 검사한 다음에만 설치 기록을 저장한다.
168
+ for (const file of plan.files) {
169
+ const expected = file.action === "keep" ? file.existingContent : file.content;
170
+ if (readDestination(file.destination) !== expected)
171
+ throw new Error("Installed file changed: " + file.destination);
172
+ }
173
+ }
174
+ catch (error) {
175
+ throw new InstallExecutionError("files", completed, error);
176
+ }
177
+ try {
178
+ writePlannedFile(plan.configFile);
179
+ }
180
+ catch (error) {
181
+ throw new InstallExecutionError("config", completed, error);
182
+ }
183
+ return { status: "completed", files: completed, configFile: plan.configFile };
184
+ }
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import { readDestination } from "./files.js";
3
+ import { sourceHash } from "./versions.js";
4
+ const labels = {
5
+ present: "이미 설치됨",
6
+ modified: "설치됨 · 로컬 수정",
7
+ unverified: "설치됨 · 버전 기록 없음",
8
+ partial: "설치 기록 있음 · 일부 파일 없음",
9
+ missing: "설치 기록만 있음 · 파일 없음",
10
+ unreadable: "설치 위치 확인 불가",
11
+ };
12
+ export function describeInstallation(value) {
13
+ return `${labels[value.status]}${value.version ? " @" + value.version : ""} — ${value.directory}`;
14
+ }
15
+ // 설치 기록과 실제 파일을 대조한다. UI 표시용이며 설치 시점의 검증을 대체하지 않는다.
16
+ export function inspectInstallations(catalog, config) {
17
+ const result = new Map();
18
+ if (config.status === "missing")
19
+ return new Map();
20
+ const cwd = path.dirname(config.destination);
21
+ const known = new Map(catalog.items.map((item) => [item.name, item]));
22
+ for (const record of config.value.installed) {
23
+ const directory = path.resolve(cwd, record.directory);
24
+ const item = known.get(record.name);
25
+ const candidates = record.source?.modules.map((module) => ({ name: module.name, version: module.version, files: module.files })) ??
26
+ [];
27
+ if (item) {
28
+ candidates.push({
29
+ name: item.name,
30
+ version: record.source?.modules.find((module) => module.name === item.name)?.version ?? "",
31
+ files: record.source
32
+ ? Object.fromEntries(record.source.modules.flatMap((module) => Object.entries(module.files)))
33
+ : Object.fromEntries(item.files.map((file) => [file, ""])),
34
+ });
35
+ }
36
+ for (const candidate of candidates) {
37
+ if (known.has(candidate.name)) {
38
+ const locations = result.get(candidate.name) ?? new Map();
39
+ const previous = locations.get(directory);
40
+ if (!previous || previous.via !== candidate.name || record.name === candidate.name) {
41
+ let count = 0;
42
+ let changed = false;
43
+ let unreadable = false;
44
+ for (const [file, hash] of Object.entries(candidate.files)) {
45
+ try {
46
+ const content = readDestination(path.join(directory, file));
47
+ if (content !== null) {
48
+ count++;
49
+ if (hash && sourceHash(content) !== hash)
50
+ changed = true;
51
+ }
52
+ }
53
+ catch {
54
+ unreadable = true;
55
+ }
56
+ }
57
+ const status = unreadable
58
+ ? "unreadable"
59
+ : !count
60
+ ? "missing"
61
+ : count < Object.keys(candidate.files).length
62
+ ? "partial"
63
+ : changed
64
+ ? "modified"
65
+ : candidate.version
66
+ ? "present"
67
+ : "unverified";
68
+ locations.set(directory, {
69
+ directory,
70
+ status,
71
+ ...(candidate.version ? { version: candidate.version } : {}),
72
+ via: record.name,
73
+ });
74
+ result.set(candidate.name, locations);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return new Map([...result].map(([name, locations]) => [name, [...locations.values()]]));
80
+ }
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ export function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ export function readJson(file) {
6
+ return JSON.parse(readFileSync(file, "utf8"));
7
+ }
8
+ export function readJsonObject(file) {
9
+ const value = readJson(file);
10
+ if (!isRecord(value))
11
+ throw new Error("Expected a JSON object: " + file);
12
+ return value;
13
+ }
14
+ export function readPackageVersion(file) {
15
+ const manifest = readJsonObject(file);
16
+ if (typeof manifest.version !== "string")
17
+ throw new Error("Invalid package version: " + file);
18
+ return manifest.version;
19
+ }
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from "ink";
3
+ import { useTerminalInput } from "./terminal-input.js";
4
+ import { Line, theme } from "./terminal-style.js";
5
+ import { fitTerminalLine, terminalText, wrapTerminalLines } from "./terminal-text.js";
6
+ export function PickerApp({ picker, onKey, onPaste, }) {
7
+ useTerminalInput(onKey, onPaste);
8
+ const { columns, rows } = useWindowSize();
9
+ return _jsx(PickerView, { picker: picker, columns: columns || 80, rows: rows || 24 });
10
+ }
11
+ function detailColor(line) {
12
+ if (line.startsWith("+") && !line.startsWith("+++"))
13
+ return theme.accent;
14
+ if (line.startsWith("-") && !line.startsWith("---"))
15
+ return "#ff8b9d";
16
+ if (/^(함께 생성|실행 환경|npm 의존성|peer 호환|@@)/u.test(line))
17
+ return theme.purple;
18
+ return undefined;
19
+ }
20
+ export function PickerView({ picker, columns, rows }) {
21
+ const width = Math.max(1, columns - 1);
22
+ const height = Math.max(1, rows - 1);
23
+ const usable = width >= 20 && height >= 7;
24
+ const compact = width < 64 || height < 20;
25
+ const headerRows = compact ? 3 : 9;
26
+ const footerRows = compact ? 2 : 3;
27
+ const bodyRows = Math.max(1, height - headerRows - footerRows);
28
+ const listRows = compact ? bodyRows : bodyRows - 2;
29
+ const rowHeight = compact ? 1 : 2;
30
+ const pageSize = Math.max(1, Math.floor(listRows / rowHeight));
31
+ picker.viewport(pageSize, usable);
32
+ if (!usable)
33
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "\uD130\uBBF8\uB110\uC744 \uB113\uD600 \uC8FC\uC138\uC694." }), _jsx(Text, { children: "\uCD5C\uC18C 21\uC5F4 \u00D7 8\uD589" }), _jsx(Text, { dimColor: true, children: "q \uCDE8\uC18C \u00B7 Ctrl+C \uC911\uB2E8" })] }));
34
+ const { choices, index, choice } = picker.focus();
35
+ const selected = picker.values();
36
+ const hidden = selected.filter((id) => !choices.some((entry) => entry.id === id)).length;
37
+ const start = Math.max(0, Math.min(index - Math.floor(pageSize / 2), choices.length - pageSize));
38
+ const split = !compact && width >= 100 && !picker.detail;
39
+ const leftWidth = split ? Math.floor(width * 0.55) : width;
40
+ const contentWidth = leftWidth - (compact ? 0 : 4);
41
+ const stage = picker.request.stage ?? "utility";
42
+ const stageIndex = ["scope", "category", "utility"].indexOf(stage);
43
+ const heading = picker.request.title.replace(" · 대분류 선택", "");
44
+ const summary = picker.notice || picker.request.summary(selected);
45
+ const search = picker.searching
46
+ ? ` / ${picker.state.query}▏`
47
+ : picker.state.query
48
+ ? ` / ${picker.state.query}`
49
+ : " / 이름이나 설명으로 검색";
50
+ const detail = picker.detail ? picker.detailLines(width - (compact ? 0 : 4), Math.max(1, listRows)) : undefined;
51
+ const shortcuts = picker.detail
52
+ ? [
53
+ ["↑↓", "스크롤"],
54
+ ["Enter", "닫기"],
55
+ ["Esc", "목록"],
56
+ ]
57
+ : picker.searching
58
+ ? [
59
+ ["Enter", "검색 완료"],
60
+ ["Esc", "초기화"],
61
+ ["Ctrl+C", "취소"],
62
+ ]
63
+ : [
64
+ ["↑↓", "이동"],
65
+ ["Space", "선택"],
66
+ ["Enter", "다음"],
67
+ ];
68
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", children: [compact ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["\u25C8 HERMES ", _jsxs(Text, { color: theme.muted, children: ["\u00B7 ", picker.detail ? "상세" : "선택"] })] }), _jsx(Line, { text: heading, width: width, bold: true }), _jsx(Line, { text: picker.searching || picker.state.query ? search : summary, width: width, color: picker.notice ? theme.warning : theme.muted })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { height: 2, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.accent, bold: true, children: [" ", "\u25C8 H E R M E S ", _jsx(Text, { color: theme.purple, children: " / " }), _jsx(Text, { color: theme.muted, children: "CATALOG" })] }), _jsxs(Text, { color: theme.accent, children: [selected.length.toString().padStart(2, "0"), " CHECKED "] })] }), _jsx(Box, { height: 2, gap: 2, children: stage === "conflict" ? (_jsxs(Text, { color: theme.warning, children: ["\u25C6 \uD30C\uC77C \uAC80\uD1A0 ", _jsx(Text, { dimColor: true, children: "\uCCB4\uD06C\uD55C \uD30C\uC77C\uB9CC \uAD50\uCCB4\uD569\uB2C8\uB2E4" })] })) : (["대분류", "하위 분류", "유틸 선택"].map((label, step) => (_jsxs(Text, { color: step === stageIndex ? theme.accent : theme.muted, bold: step === stageIndex, children: [step < stageIndex ? "✓" : `0${step + 1}`, " ", label, step < 2 ? " ──" : ""] }, label)))) }), _jsx(Line, { text: heading, width: width, bold: true }), _jsx(Line, { text: picker.request.breadcrumb?.join(" / ") || "필요한 도구를 골라 내 프로젝트에 추가하세요.", width: width, color: theme.muted }), _jsx(Box, { height: 3, width: width, borderStyle: "round", borderColor: picker.searching ? theme.accent : theme.border, paddingX: 1, children: _jsx(Line, { text: search, width: width - 4, color: picker.searching ? theme.accent : theme.muted }) })] })), _jsxs(Box, { height: bodyRows, flexDirection: "row", gap: split ? 1 : 0, children: [_jsx(Box, { width: leftWidth, height: bodyRows, flexDirection: "column", borderStyle: compact ? undefined : "round", borderColor: picker.detail ? theme.purple : theme.border, paddingX: compact ? 0 : 1, overflow: "hidden", children: detail ? (detail.lines.map((line, i) => (_jsx(Line, { text: line, width: contentWidth, color: detailColor(line) }, detail.offset + i)))) : choices.length ? (choices.slice(start, start + pageSize).map((entry) => {
69
+ const focused = entry.id === choice?.id;
70
+ const checked = picker.selected.has(entry.id);
71
+ return (_jsxs(Box, { height: rowHeight, flexDirection: "column", flexShrink: 0, backgroundColor: focused ? theme.focus : undefined, children: [_jsx(Text, { color: focused ? theme.bright : checked ? theme.accent : undefined, bold: focused, wrap: "truncate-end", children: fitTerminalLine(`${focused ? "❯" : " "} ${checked ? "◉" : "○"} ${entry.label}`, contentWidth) }), !compact && (_jsx(Line, { text: " " + entry.summary, width: contentWidth, color: focused ? theme.bright : theme.muted }))] }, entry.id));
72
+ })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "\uAC80\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC5B4\uC694." }), _jsx(Text, { dimColor: true, children: "/ \uAC80\uC0C9 \uC218\uC815 \u00B7 Ctrl+L \uCD08\uAE30\uD654" })] })) }), split && (_jsxs(Box, { width: width - leftWidth - 1, height: bodyRows, borderStyle: "round", borderColor: theme.purple, paddingX: 1, flexDirection: "column", overflow: "hidden", children: [_jsxs(Text, { bold: true, color: theme.purple, children: ["INSPECT ", _jsx(Text, { dimColor: true, children: "\u00B7 Tab \uC804\uCCB4 \uBCF4\uAE30" })] }), _jsx(Box, { height: 1 }), wrapTerminalLines(choice?.details() ?? ["항목을 고르면 정보가 표시됩니다."], width - leftWidth - 5)
73
+ .slice(0, bodyRows - 4)
74
+ .map((line, i) => (_jsx(Line, { text: line, width: width - leftWidth - 5, color: detailColor(line) }, i)))] }))] }), _jsxs(Box, { height: footerRows, flexDirection: "column", children: [_jsx(Line, { text: detail
75
+ ? `상세 ${detail.offset + 1}–${Math.min(detail.total, detail.offset + detail.lines.length)}/${detail.total} · ↑↓ 스크롤 · Esc 닫기`
76
+ : `${summary} · ${choices.length ? index + 1 : 0}/${choices.length}${hidden ? ` · 검색 밖 ${hidden}개` : ""}`, width: width, color: picker.notice ? theme.warning : theme.accent }), _jsx(Text, { wrap: "truncate-end", children: shortcuts.map(([key, description], index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === 2 ? theme.accent : theme.purple, bold: true, children: key }), _jsx(Text, { dimColor: true, children: ` ${description}${index < 2 ? " " : ""}` })] }, key))) }), !compact && (_jsx(Text, { dimColor: true, children: terminalText(picker.detail
77
+ ? "PgUp/PgDn 페이지 · Home/End 처음/끝 · Esc 목록 · q 취소"
78
+ : picker.searching
79
+ ? "입력 중에는 Space도 검색어입니다. · Ctrl+L 검색 초기화"
80
+ : "/ 검색 · a 검색 결과 전체 선택 · Tab 상세 · Esc 이전 · q 취소 · j/k 이동") }))] })] }));
81
+ }
@@ -0,0 +1,135 @@
1
+ import { removeLastGrapheme, wrapTerminalLines } from "./terminal-text.js";
2
+ export class Picker {
3
+ request;
4
+ selected;
5
+ state;
6
+ searching = false;
7
+ detail = false;
8
+ notice;
9
+ detailOffset = 0;
10
+ detailLength = 0;
11
+ pageSize = 1;
12
+ tooSmall = false;
13
+ constructor(request) {
14
+ this.request = request;
15
+ this.selected = new Set(request.selected.filter((id) => request.choices.some((choice) => choice.id === id)));
16
+ this.state = { query: "", ...request.state };
17
+ this.notice = request.notice ?? "";
18
+ }
19
+ values() {
20
+ return this.request.choices.filter((choice) => this.selected.has(choice.id)).map((choice) => choice.id);
21
+ }
22
+ visible() {
23
+ return this.request.choices.filter((choice) => !this.state.query.trim() || choice.matches(this.state.query));
24
+ }
25
+ focus() {
26
+ const choices = this.visible();
27
+ const index = Math.max(0, choices.findIndex((choice) => choice.id === this.state.focusedId));
28
+ const choice = choices[index];
29
+ this.state.focusedId = choice?.id;
30
+ return { choices, index, ...(choice ? { choice } : {}) };
31
+ }
32
+ viewport(pageSize, usable) {
33
+ this.pageSize = Math.max(1, pageSize);
34
+ this.tooSmall = !usable;
35
+ }
36
+ detailLines(columns, rows) {
37
+ const choice = this.focus().choice;
38
+ const lines = wrapTerminalLines(choice?.details() ?? [], columns);
39
+ this.detailLength = lines.length;
40
+ this.pageSize = Math.max(1, rows);
41
+ this.detailOffset = Math.min(this.detailOffset, Math.max(0, lines.length - this.pageSize));
42
+ return {
43
+ lines: lines.slice(this.detailOffset, this.detailOffset + this.pageSize),
44
+ offset: this.detailOffset,
45
+ total: lines.length,
46
+ };
47
+ }
48
+ key(text, key) {
49
+ if (key.ctrl && key.name === "c")
50
+ return { status: "cancelled", reason: "interrupt" };
51
+ if (key.ctrl && key.name === "d")
52
+ return { status: "cancelled", reason: "eof" };
53
+ if (this.tooSmall)
54
+ return text === "q" ? { status: "cancelled", reason: "cancel" } : undefined;
55
+ const { choices, index, choice } = this.focus();
56
+ const movement = key.name === "up" || (!this.searching && text === "k") || (key.ctrl && key.name === "p")
57
+ ? -1
58
+ : key.name === "down" || (!this.searching && text === "j") || (key.ctrl && key.name === "n")
59
+ ? 1
60
+ : key.name === "pageup"
61
+ ? -this.pageSize
62
+ : key.name === "pagedown"
63
+ ? this.pageSize
64
+ : 0;
65
+ if (this.detail) {
66
+ if (key.name === "escape" || key.name === "return" || key.name === "tab" || text === "?")
67
+ this.detail = false;
68
+ else if (text === "q")
69
+ return { status: "cancelled", reason: "cancel" };
70
+ else {
71
+ const offset = key.name === "home" ? 0 : key.name === "end" ? this.detailLength : this.detailOffset + movement;
72
+ this.detailOffset = Math.max(0, Math.min(Math.max(0, this.detailLength - this.pageSize), offset));
73
+ }
74
+ return;
75
+ }
76
+ if (movement || key.name === "home" || key.name === "end") {
77
+ const next = key.name === "home" ? 0 : key.name === "end" ? choices.length - 1 : index + movement;
78
+ this.state.focusedId = choices[Math.max(0, Math.min(choices.length - 1, next))]?.id;
79
+ return;
80
+ }
81
+ if (key.ctrl && key.name === "l") {
82
+ this.state.query = "";
83
+ return;
84
+ }
85
+ if (this.searching) {
86
+ if (key.name === "return")
87
+ this.searching = false;
88
+ else if (key.name === "escape") {
89
+ this.state.query = "";
90
+ this.searching = false;
91
+ }
92
+ else if (key.name === "backspace")
93
+ this.state.query = removeLastGrapheme(this.state.query);
94
+ else if (key.ctrl && key.name === "u")
95
+ this.state.query = "";
96
+ else if (!key.ctrl && !key.meta && text && !/[\p{Cc}\p{Cs}]/u.test(text))
97
+ this.state.query += text;
98
+ this.notice = "";
99
+ return;
100
+ }
101
+ if (text === "/")
102
+ this.searching = true;
103
+ else if (text === "q")
104
+ return { status: "cancelled", reason: "cancel" };
105
+ else if (key.name === "escape")
106
+ return { status: "back", selected: this.values(), state: { ...this.state } };
107
+ else if ((key.name === "tab" || text === "?") && choice) {
108
+ this.detail = true;
109
+ this.detailOffset = 0;
110
+ }
111
+ else if (key.name === "space" && choice) {
112
+ if (this.selected.has(choice.id))
113
+ this.selected.delete(choice.id);
114
+ else
115
+ this.selected.add(choice.id);
116
+ this.notice = "";
117
+ }
118
+ else if (text === "a") {
119
+ const clear = choices.every((entry) => this.selected.has(entry.id));
120
+ for (const entry of choices) {
121
+ if (clear)
122
+ this.selected.delete(entry.id);
123
+ else
124
+ this.selected.add(entry.id);
125
+ }
126
+ this.notice = "";
127
+ }
128
+ else if (key.name === "return") {
129
+ const selected = this.values();
130
+ this.notice = this.request.validate?.(selected) ?? "";
131
+ if (!this.notice)
132
+ return { status: "next", selected, state: { ...this.state } };
133
+ }
134
+ }
135
+ }