diff --git a/TODO.md b/TODO.md index 5e392d9..cf0643a 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/TinyTUI/Autocomplete/SlashCommand.cs b/src/TinyTUI/Autocomplete/SlashCommand.cs index e82f540..d202b0b 100644 --- a/src/TinyTUI/Autocomplete/SlashCommand.cs +++ b/src/TinyTUI/Autocomplete/SlashCommand.cs @@ -1,6 +1,28 @@ namespace TinyTUI.Autocomplete; +/// +/// 为斜杠命令提供参数补全候选 +/// +public delegate ValueTask?> SlashCommandArgumentCompletionProvider( + string argumentPrefix, + CancellationToken cancellationToken); + /// /// 描述一个可由斜杠命令补全的命令 /// -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) +{ + /// + /// 获取当前参数前缀对应的补全候选 + /// + public ValueTask?> GetArgumentCompletionsAsync( + string argumentPrefix, + CancellationToken cancellationToken = default) + => ArgumentCompletionProvider is null + ? ValueTask.FromResult?>(null) + : ArgumentCompletionProvider(argumentPrefix, cancellationToken); +} diff --git a/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs b/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs index 278b5b3..7247c0a 100644 --- a/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs +++ b/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs @@ -8,20 +8,20 @@ public sealed class SlashCommandAutocompleteProvider(IEnumerable c private readonly IReadOnlyList _commands = [.. commands]; /// - public override ValueTask GetSuggestionsAsync(AutocompleteRequest request) + public override async ValueTask GetSuggestionsAsync(AutocompleteRequest request) { if (request.CursorLine < 0 || request.CursorLine >= request.Lines.Count) - return ValueTask.FromResult(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(null); + return null; - // 当前增量只处理命令名补全 命令参数补全后续由组合 provider 扩展 - if (beforeCursor.Contains(' ', StringComparison.Ordinal)) - return ValueTask.FromResult(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 c .Select(command => new AutocompleteItem(command.Name, command.Name, BuildDescription(command))) .ToArray(); - return ValueTask.FromResult( - items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor)); + return items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor); } /// public override AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context) + => context.Prefix.StartsWith('/') + ? ApplyCommandNameCompletion(context) + : base.ApplyCompletion(context); + + /// + /// 获取指定命令的参数补全候选 + /// + private async ValueTask 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); + } + + /// + /// 应用命令名候选并自动补上参数分隔空格 + /// + private static AutocompleteApplyResult ApplyCommandNameCompletion(AutocompleteApplyContext context) { var lines = context.Lines.ToArray(); if (context.CursorLine < 0 || context.CursorLine >= lines.Length) diff --git a/test/TinyTUI.Tests/Autocomplete/AutocompleteProviderTests.cs b/test/TinyTUI.Tests/Autocomplete/AutocompleteProviderTests.cs index 6eb457e..74cbdf2 100644 --- a/test/TinyTUI.Tests/Autocomplete/AutocompleteProviderTests.cs +++ b/test/TinyTUI.Tests/Autocomplete/AutocompleteProviderTests.cs @@ -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?>( + [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?>( + [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?>( + [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))