feat: add autocomplete foundation

- add reusable autocomplete provider contracts

- integrate slash command completions with editor select list
This commit is contained in:
chuan
2026-06-04 02:23:06 +08:00
parent 83967fa691
commit 41c0d91d77
14 changed files with 383 additions and 3 deletions
+32
View File
@@ -269,6 +269,38 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
参考:`tmp/tui/src/autocomplete.ts``tmp/tui/src/components/editor.ts`
本次增量已完成:
- 新增 `TinyTUI.Autocomplete` 模块,定义 `IAutocompleteProvider``AutocompleteRequest``AutocompleteSuggestions``AutocompleteApplyContext``AutocompleteApplyResult`,先把“查询候选”和“应用候选”从 Editor 中拆成可复用契约
- 新增 `AutocompleteProviderBase`,提供默认的前缀替换实现,后续文件路径、变量、命令参数等 provider 可以只覆盖查询或特殊应用逻辑
- 新增 `SlashCommandAutocompleteProvider``SlashCommand`,支持行首 `/` 命令名候选,确认后自动写回 `/{command} ` 并把光标放到参数位置
- `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 取消
这样设计的原因:
- provider 契约返回 `items + prefix`,与 `tmp/tui/src/autocomplete.ts` 的核心模型一致,Editor 不需要知道候选来自命令、路径还是特殊前缀
- 应用候选独立成 `ApplyCompletion`,是为了保留 slash command 自动补空格、路径目录不补空格、引号去重等后续差异,不把这些规则硬塞进 Editor
- Editor 内部持有 `SelectList`,先把补全选择和确认流程跑通,后续 overlay 只需要挂载 `RenderAutocomplete` 或直接挂载同一个列表组件
- 补全确认通过现有 `Edit` 包装写回文本,所以撤销栈、redo 清理和 `OnChanged` 行为与普通编辑保持一致
怎么看懂当前代码:
- 先看 `Autocomplete/IAutocompleteProvider.cs`,它定义自动补全框架的两个关键动作:查询候选和应用候选
- 再看 `Autocomplete/SlashCommandAutocompleteProvider.cs`,它展示了一个最小 provider 如何根据当前行和光标返回候选,以及如何把候选写回文本
- 最后看 `Components/Editor/Editor.Autocomplete.cs`,这里是 Editor 的补全状态机:启动查询、用 `SelectList` 展示候选、确认应用、取消清理
- `Components/Editor/Editor.cs``HandleInput` 入口可以看到补全输入优先级:活动补全先消费导航和确认,普通 Tab 强制触发补全,普通文本输入后尝试自然补全
对比 `tmp/tui` 后续仍需补齐:
- C# 侧 provider 契约已用 `ValueTask` 支持异步形态,但 Editor 当前 `HandleInput` 仍是同步接口,会阻塞等待 provider;后续需要 Runtime 级 invalidate 和取消令牌来实现真正非阻塞异步补全
- 还没有移植文件路径 provider、`@` 附件前缀、引号路径、`~/` 展开、目录优先排序和 fd fuzzy 搜索
- 还没有实现 slash command 参数补全,参考实现里的 `getArgumentCompletions` 需要在 C# 侧扩展到 `SlashCommand`
- 还没有把补全列表通过 `OverlayManager` 自动挂到光标附近,目前只提供 `RenderAutocomplete` 和内部 `SelectList` 供后续 overlay 接线
- 还没有做补全预览文本、候选变化 debounce、过期请求丢弃和 AbortController 等请求竞争处理
- 当前默认前缀替换基类按字符串长度处理 prefix,后续支持宽字符或跨行补全时需要把 provider 光标协议统一成 Rune 列或专门的文本范围类型
### 8. 图像和特殊终端内容
目标:为 Kitty / iTerm2 图像和特殊终端序列预留完整框架入口
@@ -0,0 +1,11 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 描述应用一个补全候选时的编辑器上下文
/// </summary>
public sealed record AutocompleteApplyContext(
IReadOnlyList<string> Lines,
int CursorLine,
int CursorColumn,
AutocompleteItem Item,
string Prefix);
@@ -0,0 +1,6 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 表示补全候选应用后的编辑器文本和光标位置
/// </summary>
public sealed record AutocompleteApplyResult(IReadOnlyList<string> Lines, int CursorLine, int CursorColumn);
@@ -0,0 +1,12 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 表示自动补全列表中的一个候选项
/// </summary>
public sealed record AutocompleteItem(string Value, string Label, string? Description = null)
{
/// <summary>
/// 使用同一段文本创建值和标签一致的候选项
/// </summary>
public static AutocompleteItem FromText(string text) => new(text, text);
}
@@ -0,0 +1,28 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 提供通用前缀替换逻辑的自动补全 provider 基类
/// </summary>
public abstract class AutocompleteProviderBase : IAutocompleteProvider
{
/// <inheritdoc />
public abstract ValueTask<AutocompleteSuggestions?> GetSuggestionsAsync(AutocompleteRequest request);
/// <inheritdoc />
public virtual AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context)
{
var lines = context.Lines.ToArray();
if (context.CursorLine < 0 || context.CursorLine >= lines.Length)
return new AutocompleteApplyResult(lines, context.CursorLine, context.CursorColumn);
var line = lines[context.CursorLine];
var prefixStart = Math.Clamp(context.CursorColumn - context.Prefix.Length, 0, line.Length);
var cursor = Math.Clamp(context.CursorColumn, 0, line.Length);
lines[context.CursorLine] = line[..prefixStart] + context.Item.Value + line[cursor..];
return new AutocompleteApplyResult(
lines,
context.CursorLine,
prefixStart + context.Item.Value.Length);
}
}
@@ -0,0 +1,11 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 描述一次自动补全查询所需的编辑器上下文
/// </summary>
public sealed record AutocompleteRequest(
IReadOnlyList<string> Lines,
int CursorLine,
int CursorColumn,
bool Force,
CancellationToken CancellationToken = default);
@@ -0,0 +1,6 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 表示 provider 返回的自动补全候选和需要替换的前缀
/// </summary>
public sealed record AutocompleteSuggestions(IReadOnlyList<AutocompleteItem> Items, string Prefix);
@@ -0,0 +1,17 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 为编辑器或命令输入提供自动补全候选
/// </summary>
public interface IAutocompleteProvider
{
/// <summary>
/// 获取当前光标位置可用的候选项
/// </summary>
ValueTask<AutocompleteSuggestions?> GetSuggestionsAsync(AutocompleteRequest request);
/// <summary>
/// 将用户选中的候选项应用到文本模型
/// </summary>
AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context);
}
+6
View File
@@ -0,0 +1,6 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 描述一个可由斜杠命令补全的命令
/// </summary>
public sealed record SlashCommand(string Name, string? Description = null, string? ArgumentHint = null);
@@ -0,0 +1,66 @@
namespace TinyTUI.Autocomplete;
/// <summary>
/// 为行首斜杠命令提供候选项
/// </summary>
public sealed class SlashCommandAutocompleteProvider(IEnumerable<SlashCommand> commands) : AutocompleteProviderBase
{
private readonly IReadOnlyList<SlashCommand> _commands = [.. commands];
/// <inheritdoc />
public override ValueTask<AutocompleteSuggestions?> GetSuggestionsAsync(AutocompleteRequest request)
{
if (request.CursorLine < 0 || request.CursorLine >= request.Lines.Count)
return ValueTask.FromResult<AutocompleteSuggestions?>(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);
// 当前增量只处理命令名补全 命令参数补全后续由组合 provider 扩展
if (beforeCursor.Contains(' ', StringComparison.Ordinal))
return ValueTask.FromResult<AutocompleteSuggestions?>(null);
var query = beforeCursor[1..];
var items = _commands
.Where(command => command.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderBy(command => command.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase)
.Select(command => new AutocompleteItem(command.Name, command.Name, BuildDescription(command)))
.ToArray();
return ValueTask.FromResult<AutocompleteSuggestions?>(
items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor));
}
/// <inheritdoc />
public override AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context)
{
var lines = context.Lines.ToArray();
if (context.CursorLine < 0 || context.CursorLine >= lines.Length)
return new AutocompleteApplyResult(lines, context.CursorLine, context.CursorColumn);
var line = lines[context.CursorLine];
var prefixStart = Math.Clamp(context.CursorColumn - context.Prefix.Length, 0, line.Length);
var cursor = Math.Clamp(context.CursorColumn, 0, line.Length);
var replacement = "/" + context.Item.Value + " ";
lines[context.CursorLine] = line[..prefixStart] + replacement + line[cursor..];
return new AutocompleteApplyResult(lines, context.CursorLine, prefixStart + replacement.Length);
}
/// <summary>
/// 合并参数提示和描述用于 SelectList 的说明列
/// </summary>
private static string? BuildDescription(SlashCommand command)
{
if (command.ArgumentHint is null)
return command.Description;
return command.Description is null
? command.ArgumentHint
: $"{command.ArgumentHint} - {command.Description}";
}
}
@@ -0,0 +1,125 @@
using TinyTUI.Autocomplete;
using TinyTUI.Input;
namespace TinyTUI.Components;
public sealed partial class Editor
{
/// <summary>
/// 获取或设置当前编辑器使用的自动补全 provider
/// </summary>
public IAutocompleteProvider? AutocompleteProvider { get; set; }
/// <summary>
/// 获取当前是否正在显示补全候选
/// </summary>
public bool IsAutocompleteActive => _autocompleteSuggestions is not null;
private AutocompleteSuggestions? _autocompleteSuggestions;
/// <summary>
/// 渲染当前补全候选列表供 overlay 或外部布局挂载
/// </summary>
public IReadOnlyList<string> RenderAutocomplete(int width)
=> IsAutocompleteActive ? AutocompleteList.Render(width) : [];
/// <summary>
/// 触发补全查询并刷新内部 SelectList
/// </summary>
public bool StartAutocomplete(bool force = false)
{
if (AutocompleteProvider is not { } provider)
return false;
var request = new AutocompleteRequest([.. _lines], _cursorRow, _cursorColumn, force);
var suggestions = provider.GetSuggestionsAsync(request).AsTask().GetAwaiter().GetResult();
if (suggestions is null || suggestions.Items.Count == 0)
{
CancelAutocomplete();
return false;
}
_autocompleteSuggestions = suggestions;
AutocompleteList.SetItems(suggestions.Items.Select(static item => new SelectItem(item.Value, item.Label, item.Description)));
AutocompleteList.SetFilter(string.Empty);
AutocompleteList.SetSelectedIndex(0);
return true;
}
/// <summary>
/// 取消当前补全状态
/// </summary>
public void CancelAutocomplete()
{
_autocompleteSuggestions = null;
AutocompleteList.SetItems(Array.Empty<SelectItem>());
}
/// <summary>
/// 将当前选中的补全候选应用到编辑器文本
/// </summary>
public bool ConfirmAutocomplete()
{
if (_autocompleteSuggestions is not { } suggestions ||
AutocompleteProvider is not { } provider ||
AutocompleteList.SelectedItemModel is not { } selected)
{
return false;
}
var item = new AutocompleteItem(selected.Value, selected.Label, selected.Description);
var context = new AutocompleteApplyContext([.. _lines], _cursorRow, _cursorColumn, item, suggestions.Prefix);
Edit(() =>
{
var result = provider.ApplyCompletion(context);
ApplyAutocompleteResult(result);
});
CancelAutocomplete();
return true;
}
/// <summary>
/// 把补全结果写回编辑器内部文本模型并约束光标位置
/// </summary>
private void ApplyAutocompleteResult(AutocompleteApplyResult result)
{
_lines.Clear();
_lines.AddRange(result.Lines.Count == 0 ? [string.Empty] : result.Lines);
_cursorRow = Math.Clamp(result.CursorLine, 0, _lines.Count - 1);
_cursorColumn = Math.Clamp(result.CursorColumn, 0, GetRuneCount(_lines[_cursorRow]));
_preferredVisualColumn = null;
}
/// <summary>
/// 活动补全优先消费选择和确认相关快捷键
/// </summary>
private bool TryHandleAutocompleteInput(TuiInputEvent input)
{
if (!IsAutocompleteActive)
return false;
if (_keybindings.Matches(input, TuiKeybindings.AutocompleteCancel))
{
CancelAutocomplete();
return true;
}
if (_keybindings.Matches(input, TuiKeybindings.AutocompleteConfirm))
return ConfirmAutocomplete();
// 候选列表继续复用 SelectList 的导航逻辑 保持滚动和选择事件一致
if (_keybindings.Matches(input, TuiKeybindings.SelectUp) ||
_keybindings.Matches(input, TuiKeybindings.SelectDown) ||
_keybindings.Matches(input, TuiKeybindings.SelectHome) ||
_keybindings.Matches(input, TuiKeybindings.SelectEnd))
{
AutocompleteList.HandleInput(input);
return true;
}
return false;
}
}
+33 -3
View File
@@ -6,10 +6,10 @@ namespace TinyTUI.Components;
/// <summary>
/// 支持多行文本编辑的基础编辑器组件
/// </summary>
public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent, IFocusableComponent
public sealed partial class Editor : IInputComponent, IFocusableComponent
{
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
private readonly ITextMeasurer _textMeasurer;
private readonly KeybindingRegistry _keybindings;
private readonly List<string> _lines = [string.Empty];
private readonly Stack<EditorSnapshot> _undoStack = [];
private readonly Stack<EditorSnapshot> _redoStack = [];
@@ -23,6 +23,20 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin
private int _scrollOffset;
private int? _preferredVisualColumn;
/// <summary>
/// 创建多行编辑器组件
/// </summary>
public Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null)
{
_textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
_keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
AutocompleteList = new SelectList(_textMeasurer, _keybindings)
{
Height = 5,
Focused = true,
};
}
/// <summary>
/// 获取或设置编辑器高度
/// </summary>
@@ -41,6 +55,11 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin
/// <inheritdoc />
public bool Focused { get; set; }
/// <summary>
/// 获取编辑器内部复用的补全列表组件
/// </summary>
public SelectList AutocompleteList { get; }
/// <summary>
/// 获取当前编辑器文本
/// </summary>
@@ -68,15 +87,26 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
if (TryHandleAutocompleteInput(input))
return;
switch (input)
{
case { Kind: TuiInputEventKind.Text }:
Edit(() => InsertText(input.Value));
StartAutocomplete(force: false);
break;
case { Kind: TuiInputEventKind.Paste }:
Edit(() => InsertText(input.Value.Replace("\r\n", "\n")));
CancelAutocomplete();
break;
case { Kind: TuiInputEventKind.Key }:
if (_keybindings.Matches(input, TuiKeybindings.AutocompleteTrigger))
{
StartAutocomplete(force: true);
return;
}
HandleKey(input);
break;
}
+6
View File
@@ -22,6 +22,9 @@ public static class TuiKeybindings
public const string EditorClear = "tui.editor.clear";
public const string EditorNewLine = "tui.editor.newLine";
public const string EditorSubmit = "tui.editor.submit";
public const string AutocompleteTrigger = "tui.autocomplete.trigger";
public const string AutocompleteConfirm = "tui.autocomplete.confirm";
public const string AutocompleteCancel = "tui.autocomplete.cancel";
public const string InputNewLine = "tui.input.newLine";
public const string InputSubmit = "tui.input.submit";
public const string InputCancel = "tui.input.cancel";
@@ -54,6 +57,9 @@ public static class TuiKeybindings
Define(EditorClear, [KeyNames.Ctrl("c")], "Clear current input"),
Define(EditorNewLine, [KeyNames.Enter], "Insert editor newline"),
Define(EditorSubmit, [KeyNames.Ctrl(KeyNames.Enter), KeyNames.Ctrl("s")], "Submit editor input"),
Define(AutocompleteTrigger, [KeyNames.Tab], "Trigger autocomplete"),
Define(AutocompleteConfirm, [KeyNames.Tab, KeyNames.Enter], "Confirm autocomplete"),
Define(AutocompleteCancel, [KeyNames.Escape], "Cancel autocomplete"),
Define(InputNewLine, [KeyNames.Shift(KeyNames.Enter)], "Insert newline"),
Define(InputSubmit, [KeyNames.Enter], "Submit input"),
Define(InputCancel, [KeyNames.Escape], "Cancel input"),
+24
View File
@@ -1,4 +1,5 @@
using TinyTUI.Components;
using TinyTUI.Autocomplete;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
@@ -41,6 +42,29 @@ loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
AssertTrue(loader.IsCanceled, "cancellable loader token");
AssertTrue(canceled, "cancellable loader callback");
var autocompleteEditor = new Editor(measurer)
{
AutocompleteProvider = new SlashCommandAutocompleteProvider(
[
new SlashCommand("help", "show help"),
new SlashCommand("history", "show history"),
]),
};
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Text, "/h"));
AssertTrue(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete active");
AssertTrue(autocompleteEditor.RenderAutocomplete(40).Any(line => line.Contains("help", StringComparison.Ordinal)), "slash command autocomplete render");
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Down));
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
AssertEqual("/history ", autocompleteEditor.Value, "slash command autocomplete confirm");
AssertFalse(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete closes after confirm");
autocompleteEditor.Value = "/h";
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab));
AssertTrue(autocompleteEditor.IsAutocompleteActive, "forced autocomplete active");
autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
AssertFalse(autocompleteEditor.IsAutocompleteActive, "autocomplete cancel");
AssertEqual("/h", autocompleteEditor.Value, "autocomplete cancel keeps text");
Console.WriteLine("TinyTUI component checks passed");
static void AssertEqual<T>(T expected, T actual, string name)