@xfe-repo/cli 2.0.7 → 2.0.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.
- package/CHANGELOG.md +18 -0
- package/dist/app.js +2 -2
- package/dist/backend/http-backend.d.ts +45 -0
- package/dist/backend/http-backend.js +88 -0
- package/dist/backend/server-task-commands.d.ts +9 -0
- package/dist/backend/server-task-commands.js +254 -0
- package/dist/bin.js +63 -109
- package/dist/command-catalog.d.ts +19 -0
- package/dist/command-catalog.js +67 -0
- package/dist/command.d.ts +45 -0
- package/dist/command.js +88 -0
- package/dist/completion/catalog.d.ts +11 -0
- package/dist/completion/catalog.js +60 -0
- package/dist/completion/command.d.ts +45 -0
- package/dist/completion/command.js +88 -0
- package/dist/completion/core.d.ts +63 -0
- package/dist/completion/core.js +237 -0
- package/dist/completion/index.d.ts +9 -0
- package/dist/completion/index.js +5 -0
- package/dist/completion/shell.d.ts +24 -0
- package/dist/completion/shell.js +160 -0
- package/dist/completion-core.d.ts +64 -0
- package/dist/completion-core.js +201 -0
- package/dist/completion.d.ts +8 -2
- package/dist/completion.js +7 -2
- package/dist/components/Commands.d.ts +8 -13
- package/dist/components/Commands.js +40 -89
- package/dist/hooks/use-session-manager.d.ts +1 -1
- package/dist/hooks/use-session-manager.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/views/MenuView.d.ts +1 -1
- package/dist/views/MenuView.js +2 -56
- package/package.json +3 -3
- package/dist/components/LogPanel.d.ts +0 -19
- package/dist/components/LogPanel.js +0 -50
- package/dist/components/TerminalLink.d.ts +0 -13
- package/dist/components/TerminalLink.js +0 -16
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli - 补全核心(与 UI / shell 协议解耦)
|
|
3
|
+
*
|
|
4
|
+
* 集中维护:命令目录条目类型、查询/输入解析、option/value 建议计算、
|
|
5
|
+
* 已用 flag 过滤、命令寻址、prefilled 提取、模糊匹配。
|
|
6
|
+
*
|
|
7
|
+
* 调用方:
|
|
8
|
+
* - TUI Commands 组件复用全部能力(含模糊匹配 / 二级菜单 / 已用 flag 过滤)
|
|
9
|
+
* - shell / 其他入口可按需复用 catalog 与 option 建议部分,不依赖 UI
|
|
10
|
+
*/
|
|
11
|
+
import { parseCommandFlagTokens, splitCommandName, splitFlagTokens } from './command.js';
|
|
12
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
13
|
+
/** 解析 query 是否进入二级菜单模式 */
|
|
14
|
+
export function parseQuery(query, options) {
|
|
15
|
+
if (!query)
|
|
16
|
+
return { activeGroup: null, subQuery: '', topQuery: '' };
|
|
17
|
+
const spaceIndex = query.indexOf(' ');
|
|
18
|
+
if (spaceIndex > 0) {
|
|
19
|
+
const groupPart = query.slice(0, spaceIndex);
|
|
20
|
+
const subQuery = query.slice(spaceIndex + 1).trim();
|
|
21
|
+
const group = options.find((o) => o.type === 'group' && o.name.toLowerCase() === groupPart);
|
|
22
|
+
if (group)
|
|
23
|
+
return { activeGroup: group, subQuery, topQuery: '' };
|
|
24
|
+
}
|
|
25
|
+
const exactGroup = options.find((o) => o.type === 'group' && o.name.toLowerCase() === query);
|
|
26
|
+
if (exactGroup)
|
|
27
|
+
return { activeGroup: exactGroup, subQuery: '', topQuery: '' };
|
|
28
|
+
return { activeGroup: null, subQuery: '', topQuery: query };
|
|
29
|
+
}
|
|
30
|
+
/** 顶级查询过滤 + 评分排序 */
|
|
31
|
+
export function filterTopLevel(options, query) {
|
|
32
|
+
return options
|
|
33
|
+
.filter((item) => fuzzyMatch(item.name, query))
|
|
34
|
+
.sort((a, b) => matchScore(a.name, query) - matchScore(b.name, query));
|
|
35
|
+
}
|
|
36
|
+
/** 二级子命令过滤:同时尝试匹配完整名和 action 名 */
|
|
37
|
+
export function filterSubCommands(options, query) {
|
|
38
|
+
return options
|
|
39
|
+
.filter((opt) => {
|
|
40
|
+
const action = splitCommandName(opt.name).action;
|
|
41
|
+
return fuzzyMatch(action, query) || fuzzyMatch(opt.name, query);
|
|
42
|
+
})
|
|
43
|
+
.sort((a, b) => {
|
|
44
|
+
const aAction = splitCommandName(a.name).action;
|
|
45
|
+
const bAction = splitCommandName(b.name).action;
|
|
46
|
+
return matchScore(aAction, query) - matchScore(bAction, query);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 识别 `<cmd> [--k=v ...] [--partial]` 形式的输入,返回 extras 模式
|
|
51
|
+
*
|
|
52
|
+
* 末尾 `--key=` 进入 value 模式;`--key` / `--` / 空 进入 name 模式
|
|
53
|
+
*/
|
|
54
|
+
export function parseExtrasMode(rawQuery, options) {
|
|
55
|
+
const split = splitCommandAndFlags(rawQuery, options);
|
|
56
|
+
if (!split || !split.command.options?.length)
|
|
57
|
+
return null;
|
|
58
|
+
const { command, flagTokens, endsWithSpace } = split;
|
|
59
|
+
if (flagTokens.length === 0 && !endsWithSpace)
|
|
60
|
+
return null;
|
|
61
|
+
const newFlag = endsWithSpace || flagTokens.length === 0;
|
|
62
|
+
const usedNames = collectUsedNames(newFlag ? flagTokens : flagTokens.slice(0, -1));
|
|
63
|
+
if (newFlag)
|
|
64
|
+
return { kind: 'name', command, partial: '', usedNames };
|
|
65
|
+
const last = flagTokens[flagTokens.length - 1];
|
|
66
|
+
if (!last.startsWith('--'))
|
|
67
|
+
return null;
|
|
68
|
+
const eqIdx = last.indexOf('=');
|
|
69
|
+
if (eqIdx >= 0) {
|
|
70
|
+
const rawKey = last.slice(2, eqIdx);
|
|
71
|
+
const partial = last.slice(eqIdx + 1);
|
|
72
|
+
const opt = command.options?.find((o) => o.name.toLowerCase() === rawKey.toLowerCase());
|
|
73
|
+
if (!opt?.enumValues?.length)
|
|
74
|
+
return null;
|
|
75
|
+
return { kind: 'value', command, key: opt.name, partial, values: opt.enumValues };
|
|
76
|
+
}
|
|
77
|
+
return { kind: 'name', command, partial: last.slice(2), usedNames };
|
|
78
|
+
}
|
|
79
|
+
/** extras 模式建议项(option 名或枚举值) */
|
|
80
|
+
export function buildExtrasSuggestions(mode) {
|
|
81
|
+
if (mode.kind === 'value') {
|
|
82
|
+
const partial = mode.partial.toLowerCase();
|
|
83
|
+
return mode.values
|
|
84
|
+
.filter((v) => !partial || v.toLowerCase().startsWith(partial))
|
|
85
|
+
.map((v) => ({ name: v, description: '', type: 'value' }));
|
|
86
|
+
}
|
|
87
|
+
const partial = mode.partial.toLowerCase();
|
|
88
|
+
return (mode.command.options ?? [])
|
|
89
|
+
.filter((o) => !mode.usedNames.has(o.name.toLowerCase()))
|
|
90
|
+
.filter((o) => !partial || o.name.toLowerCase().startsWith(partial))
|
|
91
|
+
.map((o) => ({ name: o.name, description: o.description, type: 'option' }));
|
|
92
|
+
}
|
|
93
|
+
/** extras 模式 Tab 补全:替换末尾未完成 token,或在末尾空格后追加新 flag */
|
|
94
|
+
export function applyExtrasCompletion(input, mode, selected) {
|
|
95
|
+
const lastSpace = input.lastIndexOf(' ');
|
|
96
|
+
const prefix = input.slice(0, lastSpace + 1);
|
|
97
|
+
if (mode.kind === 'value')
|
|
98
|
+
return `${prefix}--${mode.key}=${selected} `;
|
|
99
|
+
const isBoolean = mode.command.options?.find((o) => o.name === selected)?.isBoolean === true;
|
|
100
|
+
return `${prefix}${isBoolean ? `--${selected} ` : `--${selected}=`}`;
|
|
101
|
+
}
|
|
102
|
+
/** 输入包含 flag 时尝试解析对应命令;不依赖命令是否声明 options,仅用于建议回显 */
|
|
103
|
+
export function resolveMatchedCommandWithFlags(rawQuery, options) {
|
|
104
|
+
const split = splitFlagTokens(rawQuery);
|
|
105
|
+
if (!split || split.flagTokens.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (split.cmdTokens.length === 0 || split.cmdTokens.length > 2)
|
|
108
|
+
return null;
|
|
109
|
+
return resolveCommand(split.cmdTokens.join(' '), options);
|
|
110
|
+
}
|
|
111
|
+
/** Enter 提交时把 `/cmd --k1=v1 --k2 v2` 解析为命令 + prefilled */
|
|
112
|
+
export function extractCommandAndPrefilled(input, options) {
|
|
113
|
+
const raw = input.startsWith('/') ? input.slice(1) : '';
|
|
114
|
+
const split = splitCommandAndFlags(raw, options);
|
|
115
|
+
if (!split || split.flagTokens.length === 0)
|
|
116
|
+
return { command: null, prefilled: {} };
|
|
117
|
+
return {
|
|
118
|
+
command: split.command,
|
|
119
|
+
prefilled: parseCommandFlagTokens(split.flagTokens, split.command.options),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** 判断输入是否已经包含 `--flag` token */
|
|
123
|
+
export function hasFlagToken(input) {
|
|
124
|
+
const raw = input.startsWith('/') ? input.slice(1) : input;
|
|
125
|
+
const split = splitFlagTokens(raw);
|
|
126
|
+
return !!split && split.flagTokens.length > 0;
|
|
127
|
+
}
|
|
128
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* 把 `<cmd...> [--flag ...]` 查询拆分为命令 + flag tokens
|
|
131
|
+
*
|
|
132
|
+
* 在 splitFlagTokens 基础上叠加目录解析,限制命令 tokens ≤ 2(顶级命令 或 group+action)
|
|
133
|
+
*/
|
|
134
|
+
function splitCommandAndFlags(rawQuery, options) {
|
|
135
|
+
const split = splitFlagTokens(rawQuery);
|
|
136
|
+
if (!split || split.cmdTokens.length === 0 || split.cmdTokens.length > 2)
|
|
137
|
+
return null;
|
|
138
|
+
const command = resolveCommand(split.cmdTokens.join(' '), options);
|
|
139
|
+
if (!command)
|
|
140
|
+
return null;
|
|
141
|
+
return { command, flagTokens: split.flagTokens, endsWithSpace: split.endsWithSpace };
|
|
142
|
+
}
|
|
143
|
+
/** 通过 `<name>` 或 `<group> <action>` 解析命令 */
|
|
144
|
+
function resolveCommand(cmdPart, options) {
|
|
145
|
+
const parts = cmdPart.trim().split(/\s+/);
|
|
146
|
+
if (parts.length === 1)
|
|
147
|
+
return findCommandByName(options, parts[0]);
|
|
148
|
+
if (parts.length !== 2)
|
|
149
|
+
return null;
|
|
150
|
+
const [groupName, action] = parts;
|
|
151
|
+
const group = options.find((o) => o.type === 'group' && o.name.toLowerCase() === groupName.toLowerCase());
|
|
152
|
+
if (!group?.children)
|
|
153
|
+
return null;
|
|
154
|
+
return group.children.find((c) => splitCommandName(c.name).action.toLowerCase() === action.toLowerCase()) ?? null;
|
|
155
|
+
}
|
|
156
|
+
function findCommandByName(options, name) {
|
|
157
|
+
const target = name.toLowerCase();
|
|
158
|
+
for (const opt of options) {
|
|
159
|
+
if (opt.type === 'group') {
|
|
160
|
+
const found = opt.children?.find((c) => c.name.toLowerCase() === target || splitCommandName(c.name).action.toLowerCase() === target);
|
|
161
|
+
if (found)
|
|
162
|
+
return found;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (opt.name.toLowerCase() === target)
|
|
166
|
+
return opt;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function collectUsedNames(flagTokens) {
|
|
171
|
+
const used = new Set();
|
|
172
|
+
for (const t of flagTokens) {
|
|
173
|
+
if (!t.startsWith('--'))
|
|
174
|
+
continue;
|
|
175
|
+
const eq = t.indexOf('=');
|
|
176
|
+
const name = (eq >= 0 ? t.slice(2, eq) : t.slice(2)).toLowerCase();
|
|
177
|
+
if (name)
|
|
178
|
+
used.add(name);
|
|
179
|
+
}
|
|
180
|
+
return used;
|
|
181
|
+
}
|
|
182
|
+
function matchScore(text, query) {
|
|
183
|
+
const lower = text.toLowerCase();
|
|
184
|
+
if (lower.startsWith(query))
|
|
185
|
+
return 0;
|
|
186
|
+
if (lower.includes(query))
|
|
187
|
+
return 1;
|
|
188
|
+
return 2;
|
|
189
|
+
}
|
|
190
|
+
function fuzzyMatch(text, query) {
|
|
191
|
+
const lower = text.toLowerCase();
|
|
192
|
+
if (lower.includes(query))
|
|
193
|
+
return true;
|
|
194
|
+
let qi = 0;
|
|
195
|
+
for (let i = 0; i < lower.length && qi < query.length; i++) {
|
|
196
|
+
if (lower[i] === query[qi])
|
|
197
|
+
qi++;
|
|
198
|
+
}
|
|
199
|
+
return qi === query.length;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=completion-core.js.map
|
package/dist/completion.d.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* 通过 Commander.js 适配器集成,支持 zsh / bash / fish
|
|
6
6
|
* install / uninstall 需手动检测 shell 类型并读写配置文件
|
|
7
7
|
*/
|
|
8
|
+
import tab from '@bomb.sh/tab/commander';
|
|
8
9
|
import type { Command } from 'commander';
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 将 @bomb.sh/tab 补全能力注册到 Commander program(原地修改,包装 program.parse)
|
|
12
|
+
*
|
|
13
|
+
* 返回 RootCommand,可通过 `result.commands.get(name)?.options.get(key)?.handler = ...`
|
|
14
|
+
* 注册 option 值补全。
|
|
15
|
+
*/
|
|
16
|
+
export declare function initTabCompletion(program: Command): ReturnType<typeof tab>;
|
|
11
17
|
/** 安装 shell 自动补全到当前 shell 配置文件 */
|
|
12
18
|
export declare function installShellCompletion(): void;
|
|
13
19
|
/** 卸载 shell 自动补全(从配置文件中删除相关行) */
|
package/dist/completion.js
CHANGED
|
@@ -44,9 +44,14 @@ function getShellConfig(shell) {
|
|
|
44
44
|
// ============================================================
|
|
45
45
|
// 公开 API
|
|
46
46
|
// ============================================================
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* 将 @bomb.sh/tab 补全能力注册到 Commander program(原地修改,包装 program.parse)
|
|
49
|
+
*
|
|
50
|
+
* 返回 RootCommand,可通过 `result.commands.get(name)?.options.get(key)?.handler = ...`
|
|
51
|
+
* 注册 option 值补全。
|
|
52
|
+
*/
|
|
48
53
|
export function initTabCompletion(program) {
|
|
49
|
-
tab(program);
|
|
54
|
+
return tab(program);
|
|
50
55
|
}
|
|
51
56
|
/** 安装 shell 自动补全到当前 shell 配置文件 */
|
|
52
57
|
export function installShellCompletion() {
|
|
@@ -5,29 +5,24 @@
|
|
|
5
5
|
* 编辑器使用自定义 Editor 支持多行编辑和完整终端快捷键
|
|
6
6
|
* 通过 useKeymap 注册建议导航和 Tab 补全,避免快捷键冲突
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - 输入 `/groupName` 精确匹配后展开二级命令列表
|
|
11
|
-
* - 输入 `/groupName action` 过滤二级命令
|
|
8
|
+
* 补全核心逻辑(query 解析、extras 模式、命令寻址、prefilled 提取)
|
|
9
|
+
* 已下沉至 completion/core.ts,本文件只保留状态/快捷键/渲染
|
|
12
10
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
readonly children?: CommandItem[];
|
|
19
|
-
}
|
|
11
|
+
import type { CommandOptionMeta } from '../completion/command.js';
|
|
12
|
+
import type { CommandCatalogItem } from '../completion/core.js';
|
|
13
|
+
export type { CommandOptionMeta };
|
|
14
|
+
/** 兼容别名:原 CommandItem 现统一为 completion/core 中的 CommandCatalogItem */
|
|
15
|
+
export type CommandItem = CommandCatalogItem;
|
|
20
16
|
interface CommandPaletteProps {
|
|
21
17
|
readonly commandOptions: CommandItem[];
|
|
22
18
|
readonly isActive: boolean;
|
|
23
19
|
/** 建议列表最大显示行数(根据终端高度动态计算) */
|
|
24
20
|
readonly maxSuggestions?: number;
|
|
25
|
-
readonly onSelect: (name: string) => void;
|
|
21
|
+
readonly onSelect: (name: string, prefilled?: Record<string, string>) => void;
|
|
26
22
|
readonly onUserInput: (input: string) => void;
|
|
27
23
|
readonly onExit: () => void;
|
|
28
24
|
/** 当 Tab 无法补全时(无建议),通知父组件切换面板 */
|
|
29
25
|
readonly onTabFallthrough?: () => void;
|
|
30
26
|
}
|
|
31
27
|
export declare const Commands: import("react").NamedExoticComponent<CommandPaletteProps>;
|
|
32
|
-
export {};
|
|
33
28
|
//# sourceMappingURL=Commands.d.ts.map
|
|
@@ -6,19 +6,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* 编辑器使用自定义 Editor 支持多行编辑和完整终端快捷键
|
|
7
7
|
* 通过 useKeymap 注册建议导航和 Tab 补全,避免快捷键冲突
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - 输入 `/groupName` 精确匹配后展开二级命令列表
|
|
12
|
-
* - 输入 `/groupName action` 过滤二级命令
|
|
9
|
+
* 补全核心逻辑(query 解析、extras 模式、命令寻址、prefilled 提取)
|
|
10
|
+
* 已下沉至 completion/core.ts,本文件只保留状态/快捷键/渲染
|
|
13
11
|
*/
|
|
14
12
|
import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react';
|
|
15
13
|
import { Box, Text } from 'ink';
|
|
14
|
+
import { splitCommandName } from '../completion/command.js';
|
|
15
|
+
import { applyExtrasCompletion, buildCompletionState, buildSuggestions, extractCommandAndPrefilled, hasFlagToken, } from '../completion/core.js';
|
|
16
16
|
import { useKeymap, KeymapLayer } from '../hooks/use-keymap.js';
|
|
17
17
|
import { Editor } from './Editor.js';
|
|
18
18
|
import { computeWindow } from './Prompts/OptionList.js';
|
|
19
19
|
const SUGGESTION_PAGE_SIZE = 10;
|
|
20
20
|
const EXIT_COMMAND = 'exit';
|
|
21
|
-
const BACK_COMMAND = '__back__';
|
|
22
21
|
// ─── Component ──────────────────────────────────────────────
|
|
23
22
|
export const Commands = memo(function Commands({ commandOptions, isActive, maxSuggestions, onSelect, onUserInput, onExit, onTabFallthrough, }) {
|
|
24
23
|
const pageSize = maxSuggestions ? Math.min(SUGGESTION_PAGE_SIZE, maxSuggestions) : SUGGESTION_PAGE_SIZE;
|
|
@@ -26,34 +25,21 @@ export const Commands = memo(function Commands({ commandOptions, isActive, maxSu
|
|
|
26
25
|
const [suggestIndex, setSuggestIndex] = useState(0);
|
|
27
26
|
const [resetKey, setResetKey] = useState(0);
|
|
28
27
|
const showSuggestions = commandInput.startsWith('/');
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const { activeGroup,
|
|
32
|
-
const suggestions = useMemo(() =>
|
|
33
|
-
if (!showSuggestions)
|
|
34
|
-
return [];
|
|
35
|
-
// 二级模式:展示 group 的子命令
|
|
36
|
-
if (activeGroup) {
|
|
37
|
-
const children = activeGroup.children ?? [];
|
|
38
|
-
const backItem = { name: BACK_COMMAND, description: '返回上级', type: 'back' };
|
|
39
|
-
if (!subQuery)
|
|
40
|
-
return [backItem, ...children];
|
|
41
|
-
const filtered = filterAndSort(children, subQuery);
|
|
42
|
-
return [backItem, ...filtered];
|
|
43
|
-
}
|
|
44
|
-
if (!topQuery)
|
|
45
|
-
return commandOptions;
|
|
46
|
-
return filterTopLevel(commandOptions, topQuery);
|
|
47
|
-
}, [showSuggestions, activeGroup, subQuery, topQuery, commandOptions]);
|
|
28
|
+
const rawQuery = showSuggestions ? commandInput.slice(1) : '';
|
|
29
|
+
const completionState = useMemo(() => buildCompletionState(rawQuery, commandOptions), [rawQuery, commandOptions]);
|
|
30
|
+
const { activeGroup, extrasMode } = completionState;
|
|
31
|
+
const suggestions = useMemo(() => (showSuggestions ? buildSuggestions(completionState, commandOptions) : []), [showSuggestions, completionState, commandOptions]);
|
|
48
32
|
// 查询变化时重置选中索引
|
|
49
33
|
useEffect(() => {
|
|
50
|
-
|
|
34
|
+
if (extrasMode) {
|
|
35
|
+
setSuggestIndex(0);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
51
38
|
setSuggestIndex(activeGroup && suggestions.length > 1 ? 1 : 0);
|
|
52
|
-
}, [showSuggestions,
|
|
39
|
+
}, [showSuggestions, rawQuery, completionState.mode]);
|
|
53
40
|
// ref 供 keymap handler 使用,避免陈旧闭包
|
|
54
|
-
const stateRef = useRef({ suggestions, suggestIndex, showSuggestions,
|
|
55
|
-
stateRef.current = { suggestions, suggestIndex, showSuggestions,
|
|
56
|
-
// ── 重置输入的辅助函数 ──
|
|
41
|
+
const stateRef = useRef({ suggestions, suggestIndex, showSuggestions, completionState, commandInput, commandOptions });
|
|
42
|
+
stateRef.current = { suggestions, suggestIndex, showSuggestions, completionState, commandInput, commandOptions };
|
|
57
43
|
const resetInput = useCallback((value) => {
|
|
58
44
|
setCommandInput(value);
|
|
59
45
|
setResetKey((p) => p + 1);
|
|
@@ -63,7 +49,14 @@ export const Commands = memo(function Commands({ commandOptions, isActive, maxSu
|
|
|
63
49
|
setCommandInput(value);
|
|
64
50
|
}, []);
|
|
65
51
|
const handleSubmit = useCallback((_value) => {
|
|
66
|
-
const { suggestions: sug, suggestIndex: idx, showSuggestions: show,
|
|
52
|
+
const { suggestions: sug, suggestIndex: idx, showSuggestions: show, commandInput: input, commandOptions: opts } = stateRef.current;
|
|
53
|
+
// 包含 flag 的输入:只要能解析出命令就直接执行,携带 prefilled
|
|
54
|
+
const { command, prefilled } = extractCommandAndPrefilled(input, opts);
|
|
55
|
+
if (command) {
|
|
56
|
+
resetInput('');
|
|
57
|
+
onSelect(command.name, prefilled);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
67
60
|
if (show && sug.length > 0) {
|
|
68
61
|
const selected = sug[idx];
|
|
69
62
|
if (!selected)
|
|
@@ -119,11 +112,19 @@ export const Commands = memo(function Commands({ commandOptions, isActive, maxSu
|
|
|
119
112
|
useKeymap({
|
|
120
113
|
key: 'tab',
|
|
121
114
|
handler: () => {
|
|
122
|
-
const { suggestions: sug, suggestIndex: idx, showSuggestions: show,
|
|
115
|
+
const { suggestions: sug, suggestIndex: idx, showSuggestions: show, completionState: state, commandInput: input } = stateRef.current;
|
|
116
|
+
const { activeGroup: group, extrasMode: extras } = state;
|
|
123
117
|
if (show && sug.length > 0) {
|
|
124
118
|
const selected = sug[idx];
|
|
125
119
|
if (!selected)
|
|
126
120
|
return;
|
|
121
|
+
if (extras) {
|
|
122
|
+
resetInput(applyExtrasCompletion(input, extras, selected.name));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// 已带 flag 的输入回退到「展示匹配命令」状态:Tab 不应清空已输入参数
|
|
126
|
+
if (hasFlagToken(input))
|
|
127
|
+
return;
|
|
127
128
|
if (selected.type === 'back') {
|
|
128
129
|
resetInput('/');
|
|
129
130
|
return;
|
|
@@ -133,7 +134,7 @@ export const Commands = memo(function Commands({ commandOptions, isActive, maxSu
|
|
|
133
134
|
return;
|
|
134
135
|
}
|
|
135
136
|
if (group) {
|
|
136
|
-
const action = selected.name.includes(':') ? selected.name
|
|
137
|
+
const action = selected.name.includes(':') ? splitCommandName(selected.name).action : selected.name;
|
|
137
138
|
resetInput(`/${group.name} ${action}`);
|
|
138
139
|
return;
|
|
139
140
|
}
|
|
@@ -171,7 +172,7 @@ function SuggestionList({ suggestions, selectedIndex, pageSize }) {
|
|
|
171
172
|
return _jsx(BackItem, { isFocused: isFocused }, "__back__");
|
|
172
173
|
if (item.type === 'group')
|
|
173
174
|
return _jsx(GroupItem, { item: item, isFocused: isFocused }, item.name);
|
|
174
|
-
return _jsx(SuggestionItem, { item: item, isFocused: isFocused }, item.name);
|
|
175
|
+
return _jsx(SuggestionItem, { item: item, isFocused: isFocused, displayName: formatSuggestionName(item) }, item.name);
|
|
175
176
|
}), startIndex + windowSize < totalItems && _jsxs(Text, { dimColor: true, children: [" \u2193 \u8FD8\u6709 ", totalItems - startIndex - windowSize, " \u9879"] })] }));
|
|
176
177
|
}
|
|
177
178
|
function BackItem({ isFocused }) {
|
|
@@ -180,8 +181,12 @@ function BackItem({ isFocused }) {
|
|
|
180
181
|
function GroupItem({ item, isFocused }) {
|
|
181
182
|
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isFocused ? 'cyan' : undefined, children: isFocused ? '❯' : ' ' }), _jsxs(Text, { color: isFocused ? 'cyan' : undefined, bold: isFocused, children: [item.name, " \u25B8"] }), _jsx(Text, { dimColor: true, children: item.description })] }));
|
|
182
183
|
}
|
|
183
|
-
function SuggestionItem({ item, isFocused }) {
|
|
184
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isFocused ? 'cyan' : undefined, children: isFocused ? '❯' : ' ' }), _jsx(Text, { color: item.disabled ? undefined : isFocused ? 'cyan' : undefined, bold: isFocused && !item.disabled, dimColor: item.disabled, children:
|
|
184
|
+
function SuggestionItem({ item, isFocused, displayName }) {
|
|
185
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isFocused ? 'cyan' : undefined, children: isFocused ? '❯' : ' ' }), _jsx(Text, { color: item.disabled ? undefined : isFocused ? 'cyan' : undefined, bold: isFocused && !item.disabled, dimColor: item.disabled, children: displayName }), _jsx(Text, { dimColor: true, children: item.description })] }));
|
|
186
|
+
}
|
|
187
|
+
/** option 候选项加 `--` 前缀,与子命令区分 */
|
|
188
|
+
function formatSuggestionName(item) {
|
|
189
|
+
return item.type === 'option' ? `--${item.name}` : item.name;
|
|
185
190
|
}
|
|
186
191
|
function HintBar({ showSuggestions, isSubMenu }) {
|
|
187
192
|
const hint = !showSuggestions
|
|
@@ -191,58 +196,4 @@ function HintBar({ showSuggestions, isSubMenu }) {
|
|
|
191
196
|
: '↑↓ 导航 Tab 补全 Enter 执行';
|
|
192
197
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) }));
|
|
193
198
|
}
|
|
194
|
-
// ─── Helpers ────────────────────────────────────────────────
|
|
195
|
-
/** 解析 query,判断是否进入二级菜单模式 */
|
|
196
|
-
function parseQuery(query, options) {
|
|
197
|
-
if (!query)
|
|
198
|
-
return { activeGroup: null, subQuery: '', topQuery: '' };
|
|
199
|
-
const spaceIndex = query.indexOf(' ');
|
|
200
|
-
if (spaceIndex > 0) {
|
|
201
|
-
const groupPart = query.slice(0, spaceIndex);
|
|
202
|
-
const subQuery = query.slice(spaceIndex + 1).trim();
|
|
203
|
-
const group = options.find((o) => o.type === 'group' && o.name.toLowerCase() === groupPart);
|
|
204
|
-
if (group)
|
|
205
|
-
return { activeGroup: group, subQuery, topQuery: '' };
|
|
206
|
-
}
|
|
207
|
-
// 精确匹配 group(无空格时也检查)
|
|
208
|
-
const exactGroup = options.find((o) => o.type === 'group' && o.name.toLowerCase() === query);
|
|
209
|
-
if (exactGroup)
|
|
210
|
-
return { activeGroup: exactGroup, subQuery: '', topQuery: '' };
|
|
211
|
-
return { activeGroup: null, subQuery: '', topQuery: query };
|
|
212
|
-
}
|
|
213
|
-
function filterTopLevel(options, query) {
|
|
214
|
-
return options.filter((item) => fuzzyMatch(item.name, query)).sort((a, b) => matchScore(a.name, query) - matchScore(b.name, query));
|
|
215
|
-
}
|
|
216
|
-
function filterAndSort(options, query) {
|
|
217
|
-
return options
|
|
218
|
-
.filter((opt) => {
|
|
219
|
-
// 匹配子命令时,同时尝试匹配完整名和去掉前缀的 action 名
|
|
220
|
-
const action = opt.name.includes(':') ? opt.name.split(':').pop() : opt.name;
|
|
221
|
-
return fuzzyMatch(action, query) || fuzzyMatch(opt.name, query);
|
|
222
|
-
})
|
|
223
|
-
.sort((a, b) => {
|
|
224
|
-
const aAction = a.name.includes(':') ? a.name.split(':').pop() : a.name;
|
|
225
|
-
const bAction = b.name.includes(':') ? b.name.split(':').pop() : b.name;
|
|
226
|
-
return matchScore(aAction, query) - matchScore(bAction, query);
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
function matchScore(text, query) {
|
|
230
|
-
const lower = text.toLowerCase();
|
|
231
|
-
if (lower.startsWith(query))
|
|
232
|
-
return 0;
|
|
233
|
-
if (lower.includes(query))
|
|
234
|
-
return 1;
|
|
235
|
-
return 2;
|
|
236
|
-
}
|
|
237
|
-
function fuzzyMatch(text, query) {
|
|
238
|
-
const lower = text.toLowerCase();
|
|
239
|
-
if (lower.includes(query))
|
|
240
|
-
return true;
|
|
241
|
-
let qi = 0;
|
|
242
|
-
for (let i = 0; i < lower.length && qi < query.length; i++) {
|
|
243
|
-
if (lower[i] === query[qi])
|
|
244
|
-
qi++;
|
|
245
|
-
}
|
|
246
|
-
return qi === query.length;
|
|
247
|
-
}
|
|
248
199
|
//# sourceMappingURL=Commands.js.map
|
|
@@ -33,7 +33,7 @@ export interface SessionManager {
|
|
|
33
33
|
readonly activeSessions: [string, RunSession][];
|
|
34
34
|
readonly runningSessions: [string, RunSession][];
|
|
35
35
|
readonly runningScriptNames: Set<string>;
|
|
36
|
-
startSession(name: string): string;
|
|
36
|
+
startSession(name: string, prefilled?: Record<string, string>): string;
|
|
37
37
|
startInputSession(input: string): string;
|
|
38
38
|
suspendSession(sessionId: string): void;
|
|
39
39
|
resumeSession(sessionId: string): void;
|
|
@@ -44,13 +44,14 @@ export function useSessionManager({ runner, directScriptName, onExit, onLeaveSes
|
|
|
44
44
|
}
|
|
45
45
|
}, [snapshot, directScriptName, onExit, coreManager]);
|
|
46
46
|
// ── 操作方法 ──
|
|
47
|
-
const startSession = useCallback((name) => {
|
|
47
|
+
const startSession = useCallback((name, prefilled) => {
|
|
48
48
|
const sessionId = coreManager.createSession({
|
|
49
49
|
commandName: name,
|
|
50
50
|
source: 'cli',
|
|
51
51
|
createLogger: () => new InkLogger(),
|
|
52
52
|
createPrompt: () => new InkPrompt(),
|
|
53
53
|
createSpinner: () => new InkSpinner(),
|
|
54
|
+
prefilled,
|
|
54
55
|
});
|
|
55
56
|
focusedRef.current = { id: sessionId };
|
|
56
57
|
onFocusSession(sessionId);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/views/MenuView.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ interface MenuViewProps {
|
|
|
15
15
|
readonly badges: StatusBadge[];
|
|
16
16
|
readonly storeSnapshot: PluginStoreSnapshot;
|
|
17
17
|
readonly toast: InkToast;
|
|
18
|
-
readonly onSelect: (commandName: string) => void;
|
|
18
|
+
readonly onSelect: (commandName: string, prefilled?: Record<string, string>) => void;
|
|
19
19
|
readonly onFocusSession: (sessionId: string) => void;
|
|
20
20
|
readonly onExit: () => void;
|
|
21
21
|
readonly onUserInput: (input: string) => void;
|
package/dist/views/MenuView.js
CHANGED
|
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useCallback, memo } from 'react';
|
|
|
9
9
|
import { Box, Text, useWindowSize } from 'ink';
|
|
10
10
|
import Link from 'ink-link';
|
|
11
11
|
import { resolveBadgeProp } from '@xfe-repo/cli-core';
|
|
12
|
+
import { buildCommandCatalog } from '../completion/catalog.js';
|
|
12
13
|
import { useKeymap, KeymapLayer } from '../hooks/use-keymap.js';
|
|
13
14
|
import { useElapsedTime } from '../hooks/use-elapsed-time.js';
|
|
14
15
|
import { useToast } from '../hooks/use-adapters.js';
|
|
@@ -41,7 +42,7 @@ export const MenuView = memo(function MenuView({ projectName, commandsWithSource
|
|
|
41
42
|
if (!hasActiveSessions)
|
|
42
43
|
setFocusArea('commands');
|
|
43
44
|
}, [hasActiveSessions]);
|
|
44
|
-
const commandOptions = useMemo(() =>
|
|
45
|
+
const commandOptions = useMemo(() => buildCommandCatalog(commandsWithSource, { runningCommandNames }), [commandsWithSource, runningCommandNames]);
|
|
45
46
|
const headerBadges = useMemo(() => badges.filter((b) => matchSlot(b, 'header')), [badges]);
|
|
46
47
|
const footerBadges = useMemo(() => badges.filter((b) => matchSlot(b, 'footer')), [badges]);
|
|
47
48
|
const toasts = useToast(toast);
|
|
@@ -91,61 +92,6 @@ export const MenuView = memo(function MenuView({ projectName, commandsWithSource
|
|
|
91
92
|
}) })), toasts.length > 0 && (_jsx(Box, { flexDirection: "column", alignItems: "flex-end", children: toasts.map((t) => (_jsx(Toast, { message: t.message, level: t.level }, t.id))) }))] })] }));
|
|
92
93
|
});
|
|
93
94
|
// ─── Helpers ────────────────────────────────────────────────
|
|
94
|
-
function buildCommandOptions(commandsWithSource, runningCommandNames) {
|
|
95
|
-
// 按插件来源分组
|
|
96
|
-
const sourceGroups = new Map();
|
|
97
|
-
for (const entry of commandsWithSource) {
|
|
98
|
-
const group = sourceGroups.get(entry.source) ?? [];
|
|
99
|
-
group.push(entry);
|
|
100
|
-
sourceGroups.set(entry.source, group);
|
|
101
|
-
}
|
|
102
|
-
const options = [];
|
|
103
|
-
// 保持插件注册顺序(Map 插入序 = commandEntries 中首次出现的 source 顺序)
|
|
104
|
-
for (const [source] of sourceGroups) {
|
|
105
|
-
const entries = sourceGroups.get(source);
|
|
106
|
-
// 检查是否所有命令都是同一 namespace:action 格式
|
|
107
|
-
const namespacePrefix = detectNamespace(entries);
|
|
108
|
-
if (namespacePrefix) {
|
|
109
|
-
// 折叠为 group 项
|
|
110
|
-
const children = entries.map((e) => toCommandItem(e, runningCommandNames));
|
|
111
|
-
options.push({
|
|
112
|
-
name: namespacePrefix,
|
|
113
|
-
description: `${children.length} 个命令`,
|
|
114
|
-
type: 'group',
|
|
115
|
-
children,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
entries.forEach((entry) => options.push(toCommandItem(entry, runningCommandNames)));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
options.push({ name: 'exit', description: '退出 CLI' });
|
|
123
|
-
return options;
|
|
124
|
-
}
|
|
125
|
-
/** 检测一组命令是否共享同一个 namespace 前缀 */
|
|
126
|
-
function detectNamespace(entries) {
|
|
127
|
-
if (entries.length < 2)
|
|
128
|
-
return null;
|
|
129
|
-
const prefixes = entries.map((e) => {
|
|
130
|
-
const idx = e.command.name.indexOf(':');
|
|
131
|
-
return idx > 0 ? e.command.name.slice(0, idx) : null;
|
|
132
|
-
});
|
|
133
|
-
const first = prefixes[0];
|
|
134
|
-
if (!first)
|
|
135
|
-
return null;
|
|
136
|
-
if (prefixes.every((p) => p === first))
|
|
137
|
-
return first;
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
function toCommandItem(entry, runningCommandNames) {
|
|
141
|
-
const { name, simpleDescription } = entry.command;
|
|
142
|
-
const isRunning = runningCommandNames.has(name);
|
|
143
|
-
return {
|
|
144
|
-
name,
|
|
145
|
-
description: isRunning ? `(运行中) ${simpleDescription}` : simpleDescription,
|
|
146
|
-
disabled: isRunning,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
95
|
/** 判断 badge 是否匹配目标 slot(兼容单个或数组) */
|
|
150
96
|
function matchSlot(badge, target) {
|
|
151
97
|
return Array.isArray(badge.slot) ? badge.slot.includes(target) : badge.slot === target;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xfe-repo/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "XFE CLI - Ink-based terminal UI for project scaffolding",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"ink-link": "^5.0.0",
|
|
18
18
|
"react": "^19.1.0",
|
|
19
19
|
"zod": "^4.3.6",
|
|
20
|
-
"@xfe-repo/cli-core": "2.0.
|
|
21
|
-
"@xfe-repo/cli-presets": "2.0.
|
|
20
|
+
"@xfe-repo/cli-core": "2.0.5",
|
|
21
|
+
"@xfe-repo/cli-presets": "2.0.5"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^24.3.0",
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @xfe-repo/cli - 日志面板组件
|
|
3
|
-
*
|
|
4
|
-
* 滚动日志面板,展示带颜色的日志条目
|
|
5
|
-
*/
|
|
6
|
-
import type { LogEntry } from '../adapters/ink-adapter.js';
|
|
7
|
-
interface LogPanelProps {
|
|
8
|
-
entries: LogEntry[];
|
|
9
|
-
/** 最大显示行数 */
|
|
10
|
-
maxLines?: number;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* LogPanel - 滚动日志面板
|
|
14
|
-
*
|
|
15
|
-
* 显示最近的日志条目,自动滚动到底部
|
|
16
|
-
*/
|
|
17
|
-
export declare const LogPanel: import("react").NamedExoticComponent<LogPanelProps>;
|
|
18
|
-
export {};
|
|
19
|
-
//# sourceMappingURL=LogPanel.d.ts.map
|