feat: add slash command argument autocomplete
- support argument completion callbacks on slash commands - apply selected argument suggestions without rewriting command names - add autocomplete provider regression tests
This commit is contained in:
@@ -594,6 +594,7 @@ SettingsList 后续仍需补齐:
|
||||
- 新增 `SlashCommandAutocompleteProvider` 和 `SlashCommand`,支持行首 `/` 命令名候选,确认后自动写回 `/{command} ` 并把光标放到参数位置
|
||||
- 新增 `FilePathAutocompleteProvider`,支持普通路径和 `@` 附件路径补全,覆盖 `./`、`../`、`/`、`~/`、含路径分隔符的 token、quoted path 和路径含空格自动加引号
|
||||
- 新增 `CompositeAutocompleteProvider`,按顺序组合 slash command、文件路径或后续自定义 provider,让 Editor 仍只依赖一个 `IAutocompleteProvider`
|
||||
- `SlashCommand` 新增参数补全入口,支持 `/command argPrefix` 场景调用命令自己的补全回调,返回候选时只把 `argPrefix` 作为待替换前缀
|
||||
- `Editor` 增加 `AutocompleteProvider`、`AutocompleteList`、`IsAutocompleteActive`、`StartAutocomplete`、`ConfirmAutocomplete`、`CancelAutocomplete` 和 `RenderAutocomplete`,补全列表复用现有 `SelectList` 的选择、滚动和渲染能力
|
||||
- 新增 `tui.autocomplete.trigger`、`tui.autocomplete.confirm`、`tui.autocomplete.cancel` 默认动作,Tab 触发或确认补全,Enter 在补全激活时确认,Escape 取消补全
|
||||
- 扩展 `TinyTUI.ComponentChecks` 覆盖 slash command 候选渲染、方向键切换候选、Tab 确认应用和 Escape 取消
|
||||
@@ -603,6 +604,7 @@ SettingsList 后续仍需补齐:
|
||||
|
||||
- provider 契约返回 `items + prefix`,与 `tmp/tui/src/autocomplete.ts` 的核心模型一致,Editor 不需要知道候选来自命令、路径还是特殊前缀
|
||||
- 应用候选独立成 `ApplyCompletion`,是为了保留 slash command 自动补空格、路径目录不补空格、引号去重等后续差异,不把这些规则硬塞进 Editor
|
||||
- slash command 参数补全复用默认 prefix 替换,而命令名补全仍由专门分支自动补 `/{command} `,这样参数候选确认时不会重写命令名,也不会把参数误当成新的 slash command
|
||||
- 路径 provider 只做当前目录同步枚举和稳定排序,不引入 fd/fuzzy 搜索,是为了先把可预测的补全协议、引用规则和确认行为稳定下来,避免把异步搜索和请求取消提前塞进 Editor
|
||||
- 组合 provider 记录最近一次返回候选的 provider,确认时把 `ApplyCompletion` 分派回原 provider,这样 slash command 和路径补全可以共用一个 Editor 入口
|
||||
- Editor 内部持有 `SelectList`,先把补全选择和确认流程跑通,后续 overlay 只需要挂载 `RenderAutocomplete` 或直接挂载同一个列表组件
|
||||
@@ -611,7 +613,7 @@ SettingsList 后续仍需补齐:
|
||||
怎么看懂当前代码:
|
||||
|
||||
- 先看 `Autocomplete/IAutocompleteProvider.cs`,它定义自动补全框架的两个关键动作:查询候选和应用候选
|
||||
- 再看 `Autocomplete/SlashCommandAutocompleteProvider.cs`,它展示了一个最小 provider 如何根据当前行和光标返回候选,以及如何把候选写回文本
|
||||
- 再看 `Autocomplete/SlashCommandAutocompleteProvider.cs`,它展示了一个最小 provider 如何根据当前行和光标返回命令名或参数候选,以及如何把候选写回文本
|
||||
- 然后看 `Autocomplete/FilePathAutocompleteProvider.cs`,重点是 `ExtractAtPrefix` / `ExtractPathPrefix` 负责判断当前 token,`ResolveSearchScope` 负责把显示路径映射到真实目录,`ApplyCompletion` 负责目录不补空格、文件补空格和 closing quote 去重
|
||||
- 如果要同时启用命令和路径补全,看 `Autocomplete/CompositeAutocompleteProvider.cs`,provider 顺序就是优先级,先返回候选的 provider 会负责后续确认应用
|
||||
- 最后看 `Components/Editor/Editor.Autocomplete.cs`,这里是 Editor 的补全状态机:启动查询、用 `SelectList` 展示候选、确认应用、取消清理
|
||||
@@ -620,17 +622,19 @@ SettingsList 后续仍需补齐:
|
||||
对比 `tmp/tui/src/autocomplete.ts` 和 `tmp/tui/test/autocomplete.test.ts` 的结论:
|
||||
|
||||
- 已对齐的核心行为:prefix 提取仍以“光标前 token”为边界,支持 `@`、`@"..."`、普通 `"..."`、路径分隔符和强制 Tab 补全;候选值保留用户输入的显示前缀,目录追加 `/`,文件确认后追加空格
|
||||
- 已对齐的 slash command 参数行为:`/command argPrefix` 会先定位命令名,再调用该命令的参数补全回调;回调无结果或不存在时返回 null;确认参数候选时只替换 `argPrefix`,不会重写 `/command `
|
||||
- 已对齐的引用行为:路径含空格或原输入已经处于 quoted path 时自动生成 quoted value;确认 quoted completion 时如果光标后已有 closing quote,会移除旧 quote,避免出现重复 closing quote
|
||||
- 已对齐的排序行为:目录优先,再按名称进行大小写不敏感排序,并增加原始名称排序作为稳定兜底
|
||||
- 当前 C# 版本更好的点:路径 provider 被拆成独立类型,不和 slash command 逻辑混在一个 `CombinedAutocompleteProvider` 里;组合能力由 `CompositeAutocompleteProvider` 提供,后续变量、命令参数或远程资源补全可以按优先级插入
|
||||
- 当前 C# 版本更好的点:测试不依赖外部 `fd` 是否安装,普通路径和 `@` 附件路径都用临时目录同步验证,结果更稳定
|
||||
- 当前 C# 版本更好的点:`AutocompleteRequest` 使用 `CancellationToken` 和 `ValueTask` 预留异步 provider 形态,后续迁移 fd/fuzzy 时不需要改 provider 主接口
|
||||
- 当前 C# 版本更好的点:命令名应用和参数应用分成两个清晰路径,参数补全直接复用 `AutocompleteProviderBase` 的前缀替换逻辑,比参考实现里在一个 `applyCompletion` 中靠字符串上下文猜测更容易测试和维护
|
||||
|
||||
对比 `tmp/tui` 后续仍需补齐:
|
||||
|
||||
- C# 侧 provider 契约已用 `ValueTask` 支持异步形态,但 Editor 当前 `HandleInput` 仍是同步接口,会阻塞等待 provider;后续需要 Runtime 级 invalidate 和取消令牌来实现真正非阻塞异步补全
|
||||
- 文件路径 provider 已支持本地同步目录枚举、`@` 附件前缀、引号路径、`~/` 展开和目录优先排序,但还没有移植 fd fuzzy 搜索、递归匹配、`.git` 排除、隐藏文件策略配置或 symlink 递归跟随
|
||||
- 还没有实现 slash command 参数补全,参考实现里的 `getArgumentCompletions` 需要在 C# 侧扩展到 `SlashCommand`
|
||||
- slash command 参数补全当前只把第一个空格后的完整文本作为 `argumentPrefix` 传给命令回调,与参考实现一致;后续如果要支持多参数、引号内参数或 option/value 级别补全,需要为参数范围引入更明确的解析协议
|
||||
- 还没有把补全列表通过 `OverlayManager` 自动挂到光标附近,目前只提供 `RenderAutocomplete` 和内部 `SelectList` 供后续 overlay 接线
|
||||
- 还没有做补全预览文本、候选变化 debounce、过期请求丢弃和 AbortController 等请求竞争处理
|
||||
- `CompositeAutocompleteProvider` 当前用最近一次查询记录确认 provider,适合现有同步 Editor 流程;如果后续多个 Editor 共享同一个组合 provider 或实现并发异步查询,应把 provider 归属放进 suggestions 上下文或引入补全 session
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
namespace TinyTUI.Autocomplete;
|
||||
|
||||
/// <summary>
|
||||
/// 为斜杠命令提供参数补全候选
|
||||
/// </summary>
|
||||
public delegate ValueTask<IReadOnlyList<AutocompleteItem>?> SlashCommandArgumentCompletionProvider(
|
||||
string argumentPrefix,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个可由斜杠命令补全的命令
|
||||
/// </summary>
|
||||
public sealed record SlashCommand(string Name, string? Description = null, string? ArgumentHint = null);
|
||||
public sealed record SlashCommand(
|
||||
string Name,
|
||||
string? Description = null,
|
||||
string? ArgumentHint = null,
|
||||
SlashCommandArgumentCompletionProvider? ArgumentCompletionProvider = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前参数前缀对应的补全候选
|
||||
/// </summary>
|
||||
public ValueTask<IReadOnlyList<AutocompleteItem>?> GetArgumentCompletionsAsync(
|
||||
string argumentPrefix,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ArgumentCompletionProvider is null
|
||||
? ValueTask.FromResult<IReadOnlyList<AutocompleteItem>?>(null)
|
||||
: ArgumentCompletionProvider(argumentPrefix, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -8,20 +8,20 @@ public sealed class SlashCommandAutocompleteProvider(IEnumerable<SlashCommand> c
|
||||
private readonly IReadOnlyList<SlashCommand> _commands = [.. commands];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AutocompleteSuggestions?> GetSuggestionsAsync(AutocompleteRequest request)
|
||||
public override async ValueTask<AutocompleteSuggestions?> GetSuggestionsAsync(AutocompleteRequest request)
|
||||
{
|
||||
if (request.CursorLine < 0 || request.CursorLine >= request.Lines.Count)
|
||||
return ValueTask.FromResult<AutocompleteSuggestions?>(null);
|
||||
return null;
|
||||
|
||||
var line = request.Lines[request.CursorLine];
|
||||
var cursor = Math.Clamp(request.CursorColumn, 0, line.Length);
|
||||
var beforeCursor = line[..cursor];
|
||||
if (!beforeCursor.StartsWith('/'))
|
||||
return ValueTask.FromResult<AutocompleteSuggestions?>(null);
|
||||
return null;
|
||||
|
||||
// 当前增量只处理命令名补全 命令参数补全后续由组合 provider 扩展
|
||||
if (beforeCursor.Contains(' ', StringComparison.Ordinal))
|
||||
return ValueTask.FromResult<AutocompleteSuggestions?>(null);
|
||||
var spaceIndex = beforeCursor.IndexOf(' ', StringComparison.Ordinal);
|
||||
if (spaceIndex >= 0)
|
||||
return await GetArgumentSuggestionsAsync(beforeCursor, spaceIndex, request.CancellationToken);
|
||||
|
||||
var query = beforeCursor[1..];
|
||||
var items = _commands
|
||||
@@ -31,12 +31,40 @@ public sealed class SlashCommandAutocompleteProvider(IEnumerable<SlashCommand> c
|
||||
.Select(command => new AutocompleteItem(command.Name, command.Name, BuildDescription(command)))
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult<AutocompleteSuggestions?>(
|
||||
items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor));
|
||||
return items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context)
|
||||
=> context.Prefix.StartsWith('/')
|
||||
? ApplyCommandNameCompletion(context)
|
||||
: base.ApplyCompletion(context);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定命令的参数补全候选
|
||||
/// </summary>
|
||||
private async ValueTask<AutocompleteSuggestions?> GetArgumentSuggestionsAsync(
|
||||
string beforeCursor,
|
||||
int spaceIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandName = beforeCursor[1..spaceIndex];
|
||||
var command = _commands.FirstOrDefault(command => command.Name.Equals(commandName, StringComparison.Ordinal));
|
||||
if (command is null)
|
||||
return null;
|
||||
|
||||
var argumentPrefix = beforeCursor[(spaceIndex + 1)..];
|
||||
var items = await command.GetArgumentCompletionsAsync(argumentPrefix, cancellationToken);
|
||||
// 空候选和无回调都交给后续 provider,避免让补全列表显示空状态
|
||||
return items is null || items.Count == 0
|
||||
? null
|
||||
: new AutocompleteSuggestions(items, argumentPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用命令名候选并自动补上参数分隔空格
|
||||
/// </summary>
|
||||
private static AutocompleteApplyResult ApplyCommandNameCompletion(AutocompleteApplyContext context)
|
||||
{
|
||||
var lines = context.Lines.ToArray();
|
||||
if (context.CursorLine < 0 || context.CursorLine >= lines.Length)
|
||||
|
||||
@@ -172,6 +172,92 @@ public sealed class AutocompleteProviderTests : IDisposable
|
||||
Assert.Equal("src.txt", path?.Items.Single().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlashCommandProviderReturnsArgumentCompletions()
|
||||
{
|
||||
var provider = new SlashCommandAutocompleteProvider(
|
||||
[
|
||||
new SlashCommand(
|
||||
"model",
|
||||
ArgumentCompletionProvider: (prefix, _) => ValueTask.FromResult<IReadOnlyList<AutocompleteItem>?>(
|
||||
[new AutocompleteItem("gpt-5", "gpt-5", $"prefix:{prefix}")]))
|
||||
]);
|
||||
|
||||
var result = await GetSuggestions(provider, "/model gp");
|
||||
|
||||
Assert.NotNull(result);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("gp", result.Prefix);
|
||||
Assert.Equal("gpt-5", item.Value);
|
||||
Assert.Equal("prefix:gp", item.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlashCommandProviderAppliesArgumentCompletionWithoutRewritingCommandName()
|
||||
{
|
||||
var provider = new SlashCommandAutocompleteProvider(
|
||||
[
|
||||
new SlashCommand(
|
||||
"model",
|
||||
ArgumentCompletionProvider: static (_, _) => ValueTask.FromResult<IReadOnlyList<AutocompleteItem>?>(
|
||||
[AutocompleteItem.FromText("gpt-5")]))
|
||||
]);
|
||||
const string line = "/model gp";
|
||||
var result = await GetSuggestions(provider, line);
|
||||
var item = Assert.Single(result!.Items);
|
||||
|
||||
var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length, item, result.Prefix));
|
||||
|
||||
Assert.Equal("/model gpt-5", applied.Lines[0]);
|
||||
Assert.Equal("/model gpt-5".Length, applied.CursorColumn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlashCommandProviderDoesNotRewriteCommandNameWhenArgumentPrefixIsEmpty()
|
||||
{
|
||||
var provider = new SlashCommandAutocompleteProvider(
|
||||
[
|
||||
new SlashCommand(
|
||||
"model",
|
||||
ArgumentCompletionProvider: static (_, _) => ValueTask.FromResult<IReadOnlyList<AutocompleteItem>?>(
|
||||
[AutocompleteItem.FromText("gpt-5")]))
|
||||
]);
|
||||
const string line = "/model ";
|
||||
var result = await GetSuggestions(provider, line);
|
||||
var item = Assert.Single(result!.Items);
|
||||
|
||||
var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length, item, result.Prefix));
|
||||
|
||||
Assert.Equal("/model gpt-5", applied.Lines[0]);
|
||||
Assert.Equal("/model gpt-5".Length, applied.CursorColumn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlashCommandProviderReturnsNoArgumentSuggestionsWithoutCallback()
|
||||
{
|
||||
var provider = new SlashCommandAutocompleteProvider([new SlashCommand("model")]);
|
||||
|
||||
var result = await GetSuggestions(provider, "/model gp");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlashCommandProviderKeepsCommandNameCompletion()
|
||||
{
|
||||
var provider = new SlashCommandAutocompleteProvider(
|
||||
[
|
||||
new SlashCommand("help", "show help"),
|
||||
new SlashCommand("history", "show history"),
|
||||
]);
|
||||
|
||||
var result = await GetSuggestions(provider, "/h");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/h", result.Prefix);
|
||||
Assert.Equal(["help", "history"], result.Items.Select(static item => item.Value).ToArray());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_basePath))
|
||||
|
||||
Reference in New Issue
Block a user