diff --git a/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs b/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs index 99c692f1a..343c72fa8 100644 --- a/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs +++ b/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs @@ -45,18 +45,18 @@ public static class ExternalEditorLauncher } } - /// 解析并启动编辑器进程 — $EDITOR 环境变量(支持带参数形态如 "code -w", - /// 按空白拆分首段为可执行名 + 余段前置参数 + temp 文件路径尾参);未设置时平台兜底 + /// 解析并启动编辑器进程 — $EDITOR 环境变量(支持带参数形态如 "code -w" 与 + /// 引号路径形态如 "C:\Program Files\editor\e.exe" -w — 经 ParseEditorCommand 手写扫描解析; + /// 旧空白 Split 会把含空格路径拆成残引号断路径);未设置时平台兜底 ///(Windows notepad.exe / 其余 vi)。启动失败返回 null。 private static Process? StartEditor(string filePath) { - var editor = Environment.GetEnvironmentVariable("EDITOR"); - var fileName = string.IsNullOrWhiteSpace(editor) + // M1 — 引号路径解析(解析后仍异常的形态由下方 catch 统一 null 降级) + var (exe, args) = ParseEditorCommand(Environment.GetEnvironmentVariable("EDITOR") ?? string.Empty); + var fileName = exe.Length == 0 ? (OperatingSystem.IsWindows() ? "notepad.exe" : "vi") - : editor.Trim().Split(' ', 2)[0]; - var prefixArguments = string.IsNullOrWhiteSpace(editor) || !editor.Contains(' ') - ? string.Empty - : editor[(editor.IndexOf(' ') + 1)..].Trim() + " "; + : exe; + var prefixArguments = args.Length == 0 ? string.Empty : args + " "; try { @@ -86,4 +86,33 @@ public static class ExternalEditorLauncher return null; } } + + /// 解析 $EDITOR 命令串为可执行名 + 前置参数(M1 — 引号路径支持): + /// 手写扫描替代空白 Split — 跳过前导空白 → 首字符为引号时读闭合引号内段为可执行名 + ///(残缺引号防御:到串尾未闭合则引号后整段当 exe — 含空格启动失败由 StartEditor 容错降级)→ + /// 否则读到首个空白为可执行名(shell 惯例 — 无引号空格即分隔符,含空格路径需引号包裹)→ + /// 余段(含引号原样透传 ProcessStartInfo.Arguments)Trim 为前置参数; + /// 空/全空白输入返回 ("", "") 由调用方走平台兜底。 + internal static (string Exe, string Args) ParseEditorCommand(string editor) + { + var span = editor.AsSpan().TrimStart(); + if (span.IsEmpty) + { + return (string.Empty, string.Empty); + } + + if (span[0] == '"') + { + var rest = span[1..]; + var close = rest.IndexOf('"'); + return close < 0 + ? (rest.ToString(), string.Empty) + : (rest[..close].ToString(), rest[(close + 1)..].Trim().ToString()); + } + + var split = span.IndexOf(' '); + return split < 0 + ? (span.ToString(), string.Empty) + : (span[..split].ToString(), span[(split + 1)..].Trim().ToString()); + } } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 2c24c4563..45a322279 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -229,6 +229,13 @@ private void HandleExitCtrlD() /// 元组 = (发起时原文, 编辑后文本 — null = 编辑器失败/取消丢弃信号)。 private readonly ConcurrentQueue<(string Original, string? Edited)> _externalEditorResults = new(); + /// ctrl+x 前缀序列标记(M2 — 基准 defaultBindings.ts:83-84 ctrl+x ctrl+e → + /// chat:externalEditor 第二键位):ctrl+x 置位,紧随其后的 ctrl+e 消费触发外部编辑器; + /// HandleCtrlKey 入口捕获复位 — 任何其他 ctrl 键到达即作废前缀态(无超时简化:基准无超时, + /// 陈旧前缀由下一次 ctrl 键到达自然清除;ctrl+x 自身重新置位延续等待 — readline 前缀语义, + /// 可扩展承载后续 ctrl+x ctrl+k 等序列,本批次仅接编辑器不预埋)。 + private bool _ctrlXPrefix; + /// 主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。 internal bool IsRunning => _running; @@ -784,6 +791,13 @@ internal void HandleRawKeyPress(KeyPress key) /// ,其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。 private void HandleKeyPress(KeyPress key) { + // M2 序列前缀清除 — 非 ctrl 键(打字/箭头/Esc 等)插断 ctrl+x 序列: + // 防陈旧前缀被后续 ctrl+e 误触外部编辑器(HandleCtrlKey 入口只清 ctrl 键路径) + if (!key.Ctrl && _ctrlXPrefix) + { + _ctrlXPrefix = false; + } + if (_permission.IsPending) { HandlePermissionKey(key); @@ -993,9 +1007,17 @@ private void HandleKeyPress(KeyPress key) } } - /// Ctrl 组合分派(对齐基准 handleCtrl 表)。 + /// Ctrl 组合分派(对齐基准 handleCtrl 表)。 + /// M2 新增 ctrl+x ctrl+e 第二键位(基准 defaultBindings.ts:83-84 chat:externalEditor — + /// ctrl+x 为前缀键:置 _ctrlXPrefix 序列标记,紧随其后的 ctrl+e 触发外部编辑器; + /// 入口捕获复位 — 任何其他 ctrl 键到达即作废前缀态,无超时简化; + /// 前缀态优先于 End() 编辑语义 — 单独 ctrl+e 仍是行尾,语义不破坏)。 private void HandleCtrlKey(string name) { + // ctrl+x 前缀捕获复位(M2 — 进入即复位:仅 "e" 分支消费触发; + // "x" 分支重新置位延续等待,其余 ctrl 键到达即作废前缀态) + var ctrlXPrefix = _ctrlXPrefix; + _ctrlXPrefix = false; switch (name) { case "a": @@ -1019,7 +1041,16 @@ private void HandleCtrlKey(string name) break; case "e": - _input.End(); + // M2 — 前缀态优先劫持进外部编辑器;非前缀保持 End() 编辑语义(单独 ctrl+e 仍是行尾) + if (ctrlXPrefix) + { + LaunchExternalEditor(); + } + else + { + _input.End(); + } + break; case "f": _input.Right(); @@ -1060,6 +1091,11 @@ private void HandleCtrlKey(string name) case "w": _input.KillWordBefore(); break; + case "x": + // M2 — ctrl+x 前缀键(仅接 ctrl+x ctrl+e 编辑器;基准 killAgents ctrl+x ctrl+k + // 未做不预埋 — 序列标记 _ctrlXPrefix 可扩展承载后续序列) + _ctrlXPrefix = true; + break; case "y": _input.Yank(); break; diff --git a/native_tui/src/NativeTui.Core/repl/permissions/PermissionTextWrap.cs b/native_tui/src/NativeTui.Core/repl/permissions/PermissionTextWrap.cs index 89b4ff3d6..3214bbeb4 100644 --- a/native_tui/src/NativeTui.Core/repl/permissions/PermissionTextWrap.cs +++ b/native_tui/src/NativeTui.Core/repl/permissions/PermissionTextWrap.cs @@ -2,17 +2,19 @@ namespace NativeTui.Core.Repl; /// /// 权限对话框文本折行(S8)— 命令全文等宽展示的词边界折行纯函数: -/// 显式换行符先拆段,各段按宽度 word-wrap(空格分词 + 单词超宽硬切)。 -/// 消费方:ToolPermissionForms 命令区折行(MeasureHeight 与 Render 同参数保证行数一致)。 +/// 显式换行符先拆段,各段按显示列宽 word-wrap(空格分词 + 单词超宽硬切 — 复用 +/// Grid.RuneWidth East Asian Width 判定,CJK/全角/emoji 2 列、组合符 0 列,ASCII 恒 1 列)。 +/// 消费方:ToolPermissionForms 命令区折行(MeasureHeight 与 Render 同参数保证行数一致) +/// + BackgroundTaskDetailDialog 描述折行。 /// public static class PermissionTextWrap { /// - /// 折行文本 — 显式 '\n' 拆段后各段 word-wrap;空文本返回空数组。 + /// 折行文本 — 显式 '\n' 拆段后各段按显示列宽 word-wrap;空文本返回空数组。 /// width <= 0 时防御性单行原样返回(调用方宽度契约破坏时不丢内容)。 /// /// 待折行文本(可含显式换行符)。 - /// 目标行宽(显示列数)。 + /// 目标行宽(显示列数 — 宽字符占 2 列)。 /// 折行后的行集(无尾随空格)。 public static string[] Wrap(string text, int width) { @@ -31,17 +33,17 @@ public static string[] Wrap(string text, int width) .ToArray(); } - /// 单段 word-wrap — 空格分词逐词拼接,当前行放不下则换行;单词本身超宽按宽度硬切。 + /// 单段 word-wrap — 空格分词逐词拼接,当前行放不下则换行;单词本身超宽按显示列宽硬切。 private static IEnumerable WrapSegment(string segment, int width) { var lines = new List(); - var current = new StringBuilder(); + var current = new LineBuffer(); foreach (var word in segment.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { AppendWord(lines, current, word, width); } - if (current.Length > 0 || lines.Count == 0) + if (!current.IsEmpty || lines.Count == 0) { lines.Add(current.ToString()); } @@ -49,45 +51,107 @@ private static IEnumerable WrapSegment(string segment, int width) return lines; } - /// 追加单词 — 能放下则拼接(带间隔空格),放不下则当前行落盘开新行;超宽单词硬切片。 - private static void AppendWord(List lines, StringBuilder current, string word, int width) + /// 追加单词 — 能放下则拼接(带间隔空格),放不下则当前行落盘开新行;超宽单词按显示列宽硬切片。 + private static void AppendWord(List lines, LineBuffer current, string word, int width) { - if (word.Length > width) + var wordWidth = RuneWidth.OfText(word); + if (wordWidth > width) { FlushCurrent(lines, current); lines.AddRange(ChunkWord(word, width)); return; } - var needed = current.Length == 0 ? word.Length : current.Length + 1 + word.Length; + var needed = current.IsEmpty ? wordWidth : current.Width + 1 + wordWidth; if (needed > width) { FlushCurrent(lines, current); } - else if (current.Length > 0) + else if (!current.IsEmpty) { - current.Append(' '); + current.AppendSeparator(); } - current.Append(word); + current.Append(word, wordWidth); } /// 当前行落盘(非空时)并清空缓冲。 - private static void FlushCurrent(List lines, StringBuilder current) + private static void FlushCurrent(List lines, LineBuffer current) { - if (current.Length > 0) + if (current.Flush() is { } line) { - lines.Add(current.ToString()); - current.Clear(); + lines.Add(line); } } - /// 超宽单词硬切 — 按 width 等宽切片。 + /// 超宽单词硬切 — 按显示列宽逐 rune 累计切片(切点取不超 width 的最大字符前缀); + /// 行首保底收 1 个 rune(width<2 遇宽字符防空切片死循环 — 超宽尾列由 Screen 截断兜底,不丢内容); + /// 零宽 rune(组合符/变体选择器)不占列,跟随基字符同片不被切开。 private static IEnumerable ChunkWord(string word, int width) { - for (var offset = 0; offset < word.Length; offset += width) + var start = 0; + var column = 0; + var cursor = 0; + foreach (var rune in word.EnumerateRunes()) + { + var runeWidth = RuneWidth.Of(rune); + if (column > 0 && column + runeWidth > width) + { + yield return word.Substring(start, cursor - start); + start = cursor; + column = 0; + } + + column += runeWidth; + cursor += rune.Utf16SequenceLength; + } + + if (cursor > start) { - yield return word.Substring(offset, Math.Min(width, word.Length - offset)); + yield return word.Substring(start, cursor - start); } } + + /// 当前行缓冲 — 文本与显示列宽同步累计(追加即累宽、落盘后归零,免重复测宽遍历)。 + private sealed class LineBuffer + { + private readonly StringBuilder _text = new(); + + /// 当前行显示列宽(含词间空格 1 列)。 + public int Width { get; private set; } + + /// 当前行是否为空(无任何字符)。 + public bool IsEmpty => _text.Length == 0; + + /// 追加词间间隔空格(占 1 显示列)。 + public void AppendSeparator() + { + _text.Append(' '); + Width += 1; + } + + /// 追加词文本(显示列宽由调用方一次测得传入 — 免二次遍历)。 + public void Append(string text, int textWidth) + { + _text.Append(text); + Width += textWidth; + } + + /// 落盘当前行并重置(空行返回 null 不落盘)。 + public string? Flush() + { + if (_text.Length == 0) + { + return null; + } + + var line = _text.ToString(); + _text.Clear(); + Width = 0; + return line; + } + + /// 当前行文本(空行即空串 — 空段落盘占位)。 + public override string ToString() => _text.ToString(); + } } diff --git a/native_tui/tests/NativeTui.Host.Tests/ExternalEditorLauncherTests.cs b/native_tui/tests/NativeTui.Host.Tests/ExternalEditorLauncherTests.cs new file mode 100644 index 000000000..864a5aba0 --- /dev/null +++ b/native_tui/tests/NativeTui.Host.Tests/ExternalEditorLauncherTests.cs @@ -0,0 +1,85 @@ +namespace NativeTui.Host.Tests; + +/// +/// ExternalEditorLauncher.ParseEditorCommand 纯函数测试(M1 — $EDITOR 引号路径解析): +/// 常规形态(空输入兜底/无参数/带参数/前后空白)+ 引号形态(含空格路径+参数/只有引号路径/ +/// 残缺引号到串尾/多参数原样/参数内嵌引号原样透传)。纯函数无进程依赖 — +/// StartEditor 进程启动面不在单测覆盖(对齐 Core 侧 ExternalEditorTests 假编辑器委托分层)。 +/// +public sealed class ExternalEditorLauncherTests +{ + /// 常规形态 — 无引号无反斜杠(InlineData 源串可读直书)。 + [Theory] + [InlineData("", "", "")] + [InlineData(" ", "", "")] + [InlineData("code", "code", "")] + [InlineData("code -w", "code", "-w")] + [InlineData(" code -w ", "code", "-w")] + public void ParseEditorCommand_PlainForms_ResolvesExeAndArgs(string editor, string expectedExe, string expectedArgs) + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand(editor); + + Assert.Equal(expectedExe, exe); + Assert.Equal(expectedArgs, args); + } + + /// 引号路径+参数 — 含空格路径剥离引号完整作 exe(旧空白 Split 拆坏形态的修复目标)。 + [Fact] + public void ParseEditorCommand_QuotedPathWithArgs_ResolvesFullPath() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\editor\\e.exe\" -w"); + + Assert.Equal("C:\\Program Files\\editor\\e.exe", exe); + Assert.Equal("-w", args); + } + + /// 只有引号路径 — 完整路径作 exe,无参数。 + [Fact] + public void ParseEditorCommand_QuotedPathOnly_ResolvesFullPathWithoutArgs() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\editor\\e.exe\""); + + Assert.Equal("C:\\Program Files\\editor\\e.exe", exe); + Assert.Equal(string.Empty, args); + } + + /// 残缺引号到串尾 — 引号后整段当 exe(防御不炸解析;含空格启动失败由 StartEditor 容错降级)。 + [Fact] + public void ParseEditorCommand_UnclosedQuote_TreatsRemainderAsExe() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\e.exe -w"); + + Assert.Equal("C:\\Program Files\\e.exe -w", exe); + Assert.Equal(string.Empty, args); + } + + /// 引号路径 + 多参数 — 余段原样作 args(中间空白保留)。 + [Fact] + public void ParseEditorCommand_QuotedPathWithMultipleArgs_PassesArgsVerbatim() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"e.exe\" -w --goto file.md"); + + Assert.Equal("e.exe", exe); + Assert.Equal("-w --goto file.md", args); + } + + /// 参数内嵌引号 — 原样透传(ProcessStartInfo.Arguments 自行处理参数引用)。 + [Fact] + public void ParseEditorCommand_ArgsWithEmbeddedQuotes_PassesVerbatim() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"e.exe\" -c \"set ts=4\""); + + Assert.Equal("e.exe", exe); + Assert.Equal("-c \"set ts=4\"", args); + } + + /// 前导空白 + 引号路径 — TrimStart 后走引号分支正常解析。 + [Fact] + public void ParseEditorCommand_LeadingSpacesBeforeQuotedPath_SkipsSpaces() + { + var (exe, args) = ExternalEditorLauncher.ParseEditorCommand(" \"C:\\Program Files\\e.exe\" -w"); + + Assert.Equal("C:\\Program Files\\e.exe", exe); + Assert.Equal("-w", args); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs index 5eb7b1f10..e1dd1630d 100644 --- a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs @@ -80,24 +80,24 @@ public void Render_FinalizedAt_RelativeSeconds_OrDash() Assert.Equal(" 终态时间 —", RowText.Of(runningScreen, 8, 0, 40).TrimEnd()); } - /// 描述超宽折行 — 词边界折行(PermissionTextWrap 复用)续行缩进对齐值列 col 11 + + /// 描述超宽折行 — 词边界折行(PermissionTextWrap 复用,显示列宽计量)续行缩进对齐值列 col 11 + /// MeasureHeight 随折行数增长(MeasureHeight/Render 同参数防行数漂移)。 - /// 描述用 ASCII 词组 — PermissionTextWrap 按字符数折行(ASCII 语义),汉字 2 cell - /// 显示宽会超值区被 Screen 截断(既有工具局限,宽字符折行为登记遗留非本对话框回归)。 + /// 描述用汉字词组 — 值区宽 13 显示列:"检查构建" 8 列收首行, + /// "验证发布" 8+1+8=17 > 13 换行 → 描述 2 行(宽字符 2 cell 语义下折行正确不超宽)。 [Fact] public void Render_LongDescription_WrapsAtValueColumn() { using var screen = new Screen(cols: 24, rows: 12); var dialog = new BackgroundTaskDetailDialog( - new BackgroundTaskEntry("fork", "f-1", "aaaa bbbb cccc dddd", "running"), nowMs: 10_000); + new BackgroundTaskEntry("fork", "f-1", "检查构建 验证发布", "running"), nowMs: 10_000); - // 值区宽 = 24 - 11 = 13 字符:第一行收 2 词(9 字符),"cccc dddd" 换行 → 描述 2 行 + // 值区宽 = 24 - 11 = 13 显示列:第一行收 "检查构建"(8 列),"验证发布"(8+1+8=17 > 13)换行 → 描述 2 行 Assert.Equal(12, dialog.MeasureHeight(24)); dialog.Render(screen, new Rect(0, 0, 24, 12), Theme.Dark); - Assert.Equal(" 任务描述 aaaa bbbb", RowText.Of(screen, 6, 0, 24).TrimEnd()); - // 续行缩进对齐值列(col 11 起 — 与字段值共用起点) - Assert.Equal("cccc dddd", RowText.Of(screen, 7, 11, 13).TrimEnd()); + Assert.Equal(" 任务描述 检查构建", RowText.Of(screen, 6, 0, 24).TrimEnd()); + // 续行缩进对齐值列(col 11 起 — 与字段值共用起点,8 列 ≤ 13 不被 Screen 截断) + Assert.Equal("验证发布", RowText.Of(screen, 7, 11, 13).TrimEnd()); // 折行后状态/终态/提示行随之下移一行(row 8/9/11) Assert.Equal(" 任务状态 运行中", RowText.Of(screen, 8, 0, 24).TrimEnd()); Assert.Equal(" esc 返回 · left 关闭", RowText.Of(screen, 11, 0, 24).TrimEnd()); diff --git a/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs b/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs index 63ccb1348..3a8992488 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs @@ -5,6 +5,8 @@ namespace NativeTui.Tests.Repl; /// ctrl+g/ctrl+x ctrl+e → chat:externalEditor → $EDITOR):委托调用触发(原文透传 + /// 会话标记置位)、空输入无操作、编辑结果回填(输入未变)、输入已变丢弃(用户编辑优先)、 /// null 失败降级丢弃、会话进行中防重入、未注入委托降级无操作。 +/// M2 追加 ctrl+x ctrl+e 第二键位:序列触发编辑器、单独 ctrl+e 保持 End() 编辑语义、 +/// 前缀被其他 ctrl 键作废、连续二轮再次触发。 /// 假编辑器 = TaskCompletionSource 可控委托(Host 侧 ExternalEditorLauncher 进程依赖 /// 不在 Core 测试覆盖 — 登记遗留见任务报告)。构造形态对齐 StashInputTests。 /// @@ -43,6 +45,18 @@ private static (ReplShell Shell, FakeEditor Editor) CreateShell(bool injectEdito private static void CtrlG(ReplShell shell) => shell.HandleKey(new ConsoleKeyInfo('g', ConsoleKey.G, shift: false, alt: false, control: true)); + /// ctrl+x 前缀注入(M2 — 同 CtrlG 形态)。 + private static void CtrlX(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('x', ConsoleKey.X, shift: false, alt: false, control: true)); + + /// ctrl+e 注入(M2 — 同 CtrlG 形态)。 + private static void CtrlE(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('e', ConsoleKey.E, shift: false, alt: false, control: true)); + + /// ctrl+a 注入(M2 — 前缀作废用"其他 ctrl 键";Home 导航无害)。 + private static void CtrlA(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('a', ConsoleKey.A, shift: false, alt: false, control: true)); + /// 逐字符打字注入(形态对齐 StashInputTests.Type)。 private static void Type(ReplShell shell, string text) { @@ -174,4 +188,87 @@ public async Task CtrlG_EmptyEditedText_ClearsInput() Assert.Equal(string.Empty, shell.Input.Text); Assert.False(shell.ExternalEditorActive); } + + /// ctrl+x 后紧随 ctrl+e — 序列触发外部编辑器(原文透传 + 会话标记置位 + 任务句柄就绪)。 + [Fact] + public void CtrlXCtrlE_WithText_InvokesEditorWithCurrentInput() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft via prefix"); + + CtrlX(shell); + CtrlE(shell); + + Assert.Equal(["draft via prefix"], editor.Received); + Assert.True(shell.ExternalEditorActive); + Assert.NotNull(shell.ExternalEditorTask); + } + + /// 单独 ctrl+e(无前缀)— 不触发编辑器,保持 End() 编辑语义(光标回行尾)。 + [Fact] + public void CtrlE_Alone_PreservesEndNavigation() + { + var (shell, editor) = CreateShell(); + Type(shell, "hello"); + // 光标移行首(功能键 '\0' 形态 — 对齐 ReplShellPanelNavigationTests.Press 先例) + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Home, shift: false, alt: false, control: false)); + + CtrlE(shell); + + Assert.Empty(editor.Received); + Assert.False(shell.ExternalEditorActive); + Assert.Equal(5, shell.Input.CursorCol); + } + + /// ctrl+x 后按其他 ctrl 键(ctrl+a)再 ctrl+e — 前缀作废不触发编辑器。 + [Fact] + public void CtrlX_OtherCtrlKey_ThenCtrlE_DoesNotInvoke() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + + CtrlX(shell); + CtrlA(shell); + CtrlE(shell); + + Assert.Empty(editor.Received); + Assert.False(shell.ExternalEditorActive); + } + + /// 连续二轮 ctrl+x ctrl+e — 首轮回填后次轮再次触发(会话标记已复位不误挡)。 + [Fact] + public async Task CtrlXCtrlE_SecondRound_TriggersAgain() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlX(shell); + CtrlE(shell); + editor.Completion.SetResult("first edit"); + await shell.ExternalEditorTask!; + shell.DrainEngine(); + + CtrlX(shell); + CtrlE(shell); + + Assert.Equal(["draft", "first edit"], editor.Received); + Assert.True(shell.ExternalEditorActive); + } + + /// ctrl+x 后普通打字再 ctrl+e — 前缀被非 ctrl 键清除不触发编辑器 + ///(M2 收尾修复守卫:打字插断序列,ctrl+e 回归 End 编辑语义)。 + [Fact] + public void CtrlX_ThenTyping_ThenCtrlE_PrefixCleared_DoesNotInvoke() + { + var (shell, editor) = CreateShell(); + Type(shell, "d"); + + CtrlX(shell); + Type(shell, "raft"); + + CtrlE(shell); + + Assert.Empty(editor.Received); + Assert.False(shell.ExternalEditorActive); + Assert.Equal("draft", shell.Input.Text); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/PermissionFormsTests.cs b/native_tui/tests/NativeTui.Tests/repl/PermissionFormsTests.cs index 4913bbf8b..19578be44 100644 --- a/native_tui/tests/NativeTui.Tests/repl/PermissionFormsTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/PermissionFormsTests.cs @@ -211,6 +211,55 @@ public void Wrap_ExplicitNewline_Splits() Assert.Equal(["echo a", "echo b"], PermissionTextWrap.Wrap("echo a\necho b", 40)); } + /// 折行 — 纯汉字词组按显示列宽折行(汉字 2 列:"检查 构建" 4+1+4=9 列 ≤ 13, + /// 追加 "验证" 9+1+4=14 > 13 换行 — 旧字符数计量 4 词共 9 字符全收 18 显示列,超宽被 Screen 截断)。 + [Fact] + public void Wrap_WideWords_DisplayColumnBoundary() + { + Assert.Equal(["检查 构建", "验证 发布"], PermissionTextWrap.Wrap("检查 构建 验证 发布", 13)); + } + + /// 折行 — 混合中英词组按显示列宽拼接判定("abc 检查" 3+1+4=8 列 ≤ 9, + /// 追加 "def" 8+1+3=12 > 9 换行 — 词内宽窄字符列宽混算)。 + [Fact] + public void Wrap_MixedWideNarrow_DisplayColumnBoundary() + { + Assert.Equal(["abc 检查", "def"], PermissionTextWrap.Wrap("abc 检查 def", 9)); + } + + /// 折行 — 连续汉字超宽单词按显示列宽硬切("检查构建验证" 12 列 ≤ 13 收同片, + /// "发" 前 12+2=14 > 13 截止 — 切点取不超 width 的最大字符数;旧字符数计量按 6 字切片对汉字会切出 12 列侥幸等宽、7 字以上即超宽截断)。 + [Fact] + public void Wrap_OverlongWideWord_DisplayColumnChunks() + { + Assert.Equal(["检查构建验证", "发布"], PermissionTextWrap.Wrap("检查构建验证发布", 13)); + } + + /// 折行 — 宽窄混合超宽单词硬切(切点含窄字符后遇宽字提前截止: + /// "测试a" 2+2+1=5 列收片、"bc测" 1+1+2=4 列、"试" 2 列 — 逐 rune 累计列宽不按字符数)。 + [Fact] + public void Wrap_OverlongMixedWord_DisplayColumnChunks() + { + Assert.Equal(["测试a", "bc测", "试"], PermissionTextWrap.Wrap("测试abc测试", 5)); + } + + /// 折行 — emoji 超宽单词硬切按 rune 边界(代理对不拆半:"a😀" 1+2=3 列收片、"b" — + /// RuneWidth 判定表覆盖 emoji 区段,Utf16SequenceLength 前进 2 char 整体切片)。 + [Fact] + public void Wrap_OverlongEmojiWord_RuneBoundaryChunks() + { + Assert.Equal(["a😀", "b"], PermissionTextWrap.Wrap("a😀b", 3)); + } + + /// 折行 — ASCII 回归锁(显示列宽计量下每字符恒 1 列,判定与旧字符数计量完全等价: + /// "aaaa bb ccc" 恰好 11 列填满不换行 + 连续空格归一单空格既有语义保持)。 + [Fact] + public void Wrap_AsciiRegression_DisplayColumnEquivalence() + { + Assert.Equal(["aaaa bb ccc"], PermissionTextWrap.Wrap("aaaa bb ccc", 11)); + Assert.Equal(["ab cd", "ef"], PermissionTextWrap.Wrap("ab cd ef", 7)); + } + // ---- DangerousCommandCatalog 危险检测 ---- /// 危险模式矩阵 — 对齐基准 destructiveCommandWarning.ts 全模式(中文警告文案)。