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:
chuan
2026-06-04 09:59:11 +08:00
Unverified
parent f03d269bb6
commit 602e271f6c
4 changed files with 151 additions and 11 deletions
+6 -2
View File
@@ -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
+23 -1
View File
@@ -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))