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 全模式(中文警告文案)。