From f59a6a99e279123bb2b024dcc341d6dad1cd5c71 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 05:50:42 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20M8-4/5/6=20=E5=B9=B6=E8=A1=8C?=
=?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E6=89=B9=E6=AC=A1=E2=80=94=E2=80=94?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=81=9A=E5=90=88=E6=8A=98=E5=8F=A0+?=
=?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=97=AD=E7=8E=AF+=E6=B6=88=E6=81=AF?=
=?UTF-8?q?=E5=A4=8D=E5=88=B6=20|=20=E5=86=B3=E7=AD=96:=20=E4=B8=89?=
=?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E6=96=87=E4=BB=B6=E9=9D=A2=E4=BA=92?=
=?UTF-8?q?=E6=96=A5=E5=88=86=E5=B7=A5(MessageItems/Driver/Renderer=20vs?=
=?UTF-8?q?=20ReplShell/CursorBar=20vs=20EngineSession)+=E4=B8=BB=E6=99=BA?=
=?UTF-8?q?=E8=83=BD=E4=BD=93=E6=94=B6=E7=BC=96=20ReplShell=20=E8=90=BD?=
=?UTF-8?q?=E5=9C=B0=E4=BB=B6;=20M8-4=3DCollapsedToolItem=20=E6=9B=BF?=
=?UTF-8?q?=E6=8D=A2=E5=BC=8F=E8=81=9A=E5=90=88(=E8=BF=9E=E7=BB=AD=20Read/?=
=?UTF-8?q?Grep/Glob=20=E7=AC=AC=E4=BA=8C=E6=AC=A1=E8=B5=B7=E5=8E=9F?=
=?UTF-8?q?=E4=BD=8D=E6=8A=98=E5=8F=A0'=E8=AF=BB=E5=8F=96=20N=20=E4=B8=AA?=
=?UTF-8?q?=E6=96=87=E4=BB=B6'+=E6=9C=80=E6=96=B0=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E2=8E=BF=E6=8F=90=E7=A4=BA=E8=A1=8C,=E4=BA=A7=E7=94=9F?=
=?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=B6=88=E6=81=AF=E9=A1=B9=E4=BA=8B=E4=BB=B6?=
=?UTF-8?q?=E6=96=AD=E9=93=BE)+DiffResultItem=20ToggleExpand(transcript=20?=
=?UTF-8?q?verbose=20=E6=80=81=E5=85=A8=E5=B1=95=E5=BC=80=E6=8E=A5?=
=?UTF-8?q?=E7=BA=BF);=20M8-5=3DTuiCommandCallbacks=20=E5=BB=B6=E8=BF=9F?=
=?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=AE=B9=E5=99=A8+clearScreen/onExitRequeste?=
=?UTF-8?q?d=20=E9=80=8F=E4=BC=A0+ReplShell=20=E5=85=AC=E5=BC=80=20ClearMe?=
=?UTF-8?q?ssagesExceptBannerAndSpinner/RequestExit(volatile=20=5Frunning)?=
=?UTF-8?q?+RenderAndFlush=20=E6=B6=88=E8=B4=B9=E5=9D=97(2J+3J=20=E6=B8=85?=
=?UTF-8?q?=E5=B1=8F+scrollback+=E5=9F=BA=E7=BA=BF=E9=87=8D=E7=BD=AE)+Csi.?=
=?UTF-8?q?EraseSavedLines;=20M8-6=3D=E5=AF=BC=E8=88=AA=E6=80=81=20c/p=20?=
=?UTF-8?q?=E5=A4=8D=E5=88=B6(ToolCallItem=20=E8=B6=85=E5=AE=BD=E5=B1=8F?=
=?UTF-8?q?=E6=97=A0=E6=8D=9F=E6=B8=B2=E6=9F=93=E6=8F=90=E5=8F=96)+Message?=
=?UTF-8?q?ActionsBar=20=E6=BB=9A=E5=8A=A8=E5=8C=BA=E5=B0=BE=E9=83=A8?=
=?UTF-8?q?=E5=B8=B8=E9=A9=BB=E9=A1=B9(enter=20=E5=B1=95=E5=BC=80=C2=B7c?=
=?UTF-8?q?=20=E5=A4=8D=E5=88=B6=C2=B7p=20=E5=A4=8D=E5=88=B6=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=C2=B7esc=20=E8=BF=94=E5=9B=9E)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/EngineSession.cs | 54 ++-
native_tui/apps/NativeTui.Host/Program.cs | 5 +
native_tui/src/NativeTui.Core/ansi/Csi.cs | 3 +
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 89 +++-
.../NativeTui.Core/repl/CollapsedToolItem.cs | 73 ++++
.../NativeTui.Core/repl/MessageActionsBar.cs | 266 ++++++++++++
.../src/NativeTui.Core/repl/MessageItems.cs | 23 +-
.../NativeTui.Core/repl/MessageRenderer.cs | 22 +-
.../src/NativeTui.Core/repl/ReplShell.cs | 132 +++++-
.../EngineSessionCommandCallbacksTests.cs | 167 ++++++++
.../NativeTui.Host.Tests/GlobalUsings.cs | 4 +
.../bridge/ReplBridgeDriverTests.cs | 132 ++++++
.../repl/CollapsedToolItemTests.cs | 108 +++++
.../repl/DiffResultItemTests.cs | 48 +++
.../repl/MessageActionsCopyTests.cs | 404 ++++++++++++++++++
15 files changed, 1509 insertions(+), 21 deletions(-)
create mode 100644 native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs
create mode 100644 native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs
create mode 100644 native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index a6c910c42..9533d2672 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -13,13 +13,15 @@ public sealed class EngineSession : IAsyncDisposable {
private readonly TuiPermissionBridge _permissionBridge;
private readonly PermissionChecker? _checker;
private readonly IFastModeService? _fastMode;
+ private readonly TuiCommandCallbacks _commandCallbacks;
- private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode) {
+ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode, TuiCommandCallbacks commandCallbacks) {
_engine = engine;
_client = client;
_permissionBridge = permissionBridge;
_checker = checker;
_fastMode = fastMode;
+ _commandCallbacks = commandCallbacks;
}
/// 回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。
@@ -28,6 +30,9 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// 权限确认桥 — Program 在 shell 构造后 Bind(引擎 PendingConfirmation → TUI 对话框闭环,M7-3)。
public TuiPermissionBridge PermissionBridge => _permissionBridge;
+ /// 命令交互回调桥 — Program 在 shell 构造后 Bind(/clear 清滚动区、/exit 结束主循环 → ReplShell 能力,M8-5)。
+ public TuiCommandCallbacks CommandCallbacks => _commandCallbacks;
+
/// 引擎服务容器 — 宿主组装层消费(命令清单/历史种子等只读解析)。
public IServiceProvider Services => _engine.Services;
@@ -55,13 +60,17 @@ public static async Task CreateAsync(CancellationToken cancellati
var fastMode = engine.Services.GetService();
var mainAgent = EngineAgentFactory.CreateMainAgent(engine.Services, new Session().ObjectId);
+ // M8-5 命令交互回调桥 — 回调管道随回合执行器闭包传递(Bind 前回调为 null 安全降级,
+ // Program 在 shell 构造后 Bind:/clear → 清滚动区、/exit → 主循环结束)
+ var commandCallbacks = new TuiCommandCallbacks();
+
// 首次 submit 时绑定回合驱动器 — consumer 即进程内事件出口(client 构造,全程同一实例,对齐 BridgeServeCommand 先例)
SessionController? controller = null;
var client = new EngineTurnClient(
(input, consumer, turnCt) => {
// TASK064 斜杠命令分流(对齐 CliSession.ProcessInputAsync:/ 开头走命令执行不进 LLM)
if (input.StartsWith('/')) {
- return DispatchSlashCommandAsync(engine.Services, input, consumer, turnCt);
+ return DispatchSlashCommandAsync(engine.Services, input, consumer, commandCallbacks, turnCt);
}
controller ??= new SessionController(
@@ -71,7 +80,7 @@ public static async Task CreateAsync(CancellationToken cancellati
},
BuildPermissionModeApplier(checker));
- var session = new EngineSession(engine, client, permissionBridge, checker, fastMode);
+ var session = new EngineSession(engine, client, permissionBridge, checker, fastMode, commandCallbacks);
client.Enqueue(BuildSessionInfoEvent(checker, fastMode, engine.Config));
return session;
} catch {
@@ -136,14 +145,20 @@ private static async Task RunTurnAsync(SessionController controller, string inpu
/// 输出经 AnsiStripper 剥离 SGR 色码(命令侧彩色输出与 TUI Cell/StyleId 渲染模型不兼容 —
/// ESC 零宽跳过后残骸显示为 "38;2;153;153;153m──" 形态)再经 consumer.OnText/OnDone 上屏
///(AssistantMessage Markdown 渲染);未知命令同样上屏诱导(/help 提示)— 不落 LLM
- ///(对齐"命令不触发 AI":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。
- private static async Task DispatchSlashCommandAsync(
- IServiceProvider services, string input, IEventConsumer consumer, CancellationToken ct) {
+ ///(对齐"命令不触发 AI":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。
+ /// M8-5 交互回调接线:clearScreen/onExitRequested 经 TuiCommandCallbacks 透传(/clear 清 TUI 滚动区、
+ /// /exit 结束主循环;Bind 前为 null — 命令侧 ?.Invoke 安全降级对齐 Runner 可空语义);
+ /// confirm/prompt/readPassword 首版不接(TUI raw mode 无同步 Console 通道 — 模态对话框增强项遗留,
+ /// 命令侧降级:确认默认拒绝/输入 null)。internal static — 回调透传链测试面(fake DI 驱动真实命令注册链)。
+ internal static async Task DispatchSlashCommandAsync(
+ IServiceProvider services, string input, IEventConsumer consumer, TuiCommandCallbacks commandCallbacks, CancellationToken ct) {
var result = await SlashCommandRunner.RunAsync(
input, services,
+ clearScreen: commandCallbacks.ClearScreen,
confirm: null,
prompt: null,
readPassword: null,
+ onExitRequested: commandCallbacks.OnExitRequested,
cancellationToken: ct).ConfigureAwait(false);
var output = AnsiStripper.Strip(result.Output).TrimEnd();
@@ -173,3 +188,30 @@ public async ValueTask DisposeAsync() {
await _engine.Host.DisposeAsync().ConfigureAwait(false);
}
}
+
+///
+/// TUI 命令交互回调桥(M8-5)— SlashCommandRunner 交互回调的延迟绑定容器:
+/// EngineSession.CreateAsync 组装期创建并随回合执行器闭包传递(Bind 前回调为 null —
+/// 命令侧 ?.Invoke 安全降级,对齐 Runner 可空语义:清屏跳过/退出信号丢弃/确认默认拒绝);
+/// Program 在 shell 构造后 Bind:/clear → ReplShell 清滚动区、/exit → ReplShell 主循环结束。
+/// 对齐 TuiPermissionBridge.Bind 延迟绑定形态(shell 构造晚于引擎会话,引擎回合线程并发读 — volatile)。
+///
+public sealed class TuiCommandCallbacks {
+ private volatile Action? _clearScreen;
+ private volatile Action? _onExitRequested;
+
+ /// 绑定 TUI 能力(Program 交互模式组装点 — shell 构造后、Run 前调用;重绑幂等覆盖)。
+ public void Bind(Action? clearScreen, Action? onExitRequested) {
+ _clearScreen = clearScreen;
+ _onExitRequested = onExitRequested;
+ }
+
+ /// 清屏回调(/clear → 清 TUI 滚动区,保留 banner+spinner 常驻项;Bind 前为 null — 命令侧跳过)。
+ public Action? ClearScreen => _clearScreen;
+
+ /// 退出请求回调(/exit ShouldContinue=false → 结束 TUI 主循环;Bind 前为 null — 信号降级丢弃)。
+ public Action? OnExitRequested => _onExitRequested;
+
+ /// 已绑定任一回调(internal — Program 接线测试断言面)。
+ internal bool IsBound => _clearScreen is not null || _onExitRequested is not null;
+}
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index 20beeb097..106311893 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -164,6 +164,11 @@ private static async Task RunInteractiveAsync() {
pickerEngine.Attach(shell);
await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false);
session.PermissionBridge.Bind(shell);
+ // M8-5 命令交互回调接线(/clear 清 TUI 滚动区+scrollback、/exit 结束主循环)—
+ // ReplShell 公开入口已由主智能体收编落地(ClearMessagesExceptBannerAndSpinner/RequestExit)
+ session.CommandCallbacks.Bind(
+ clearScreen: shell.ClearMessagesExceptBannerAndSpinner,
+ onExitRequested: shell.RequestExit);
shell.Run();
shell.WriteExit();
}
diff --git a/native_tui/src/NativeTui.Core/ansi/Csi.cs b/native_tui/src/NativeTui.Core/ansi/Csi.cs
index 5444539e5..f2ca905b8 100644
--- a/native_tui/src/NativeTui.Core/ansi/Csi.cs
+++ b/native_tui/src/NativeTui.Core/ansi/Csi.cs
@@ -26,6 +26,9 @@ public static string CursorPosition(int row, int col) =>
/// 全屏擦除。
public static string EraseScreen() => "\x1b[2J";
+ /// 清滚动缓冲区(scrollback — CSI 3J,/clear 清对话历史滚入区)。
+ public static string EraseSavedLines() => "\x1b[3J";
+
/// 擦除光标到行尾。
public static string EraseLineToEnd() => "\x1b[0K";
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index e6e30707b..7bad073e4 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -6,6 +6,8 @@ namespace NativeTui.Core.Bridge;
/// 权限模式回执(permission_mode_changed 更新 footer)、
/// 回合生命周期驱动 Busy + SpinnerItem(BeginTurn/EndTurn),消息事件插入滚动区
/// (spinner 恒为末项),token_delta 累加 ResponseLength,错误追加 ErrorMessageItem;
+/// 工具调用聚合(M8-4):连续同名可聚合调用(Read/Grep/Glob)合并为 CollapsedToolItem
+/// (首次独立 ToolCallItem,第二次同名替换,后续 Record — 工具结果不断链,其他消息事件/回合边界断链);
/// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态)。
/// 模拟路径与真实 bridge 路径共用同一套回合视图状态。
///
@@ -28,6 +30,31 @@ public sealed class ReplBridgeDriver
/// 后续增量原地追加;AssistantMessage 原位替换为完整消息项;EndTurn 兜底定格/清理。
private StreamingTextItem? _streamingItem;
+ /// 活动工具调用聚合链(M8-4)— null = 无链(最近事件已断;详见 ToolCallChain)。
+ private ToolCallChain? _activeToolChain;
+
+ ///
+ /// 工具调用聚合链(M8-4)— Driver 私有状态:连续同名可聚合调用(Read/Grep/Glob)的合并追踪。
+ /// 链生命周期:首次调用独立上屏 ToolCallItem(链头候选)→ 第二次同名到达原位替换为 CollapsedToolItem
+ /// (Count=2 起折叠形态)→ 后续同名 RecordCall 原地更新。断链条件:不同名/非聚合工具到达、
+ /// 其他消息事件产生(流式正文/助手消息/错误/子代理组)、回合边界(BeginTurn/EndTurn);
+ /// 工具结果不断链(聚合仅统计 ToolCallItem — Read→Result→Read 仍聚合,结果照旧独立上屏)。
+ ///
+ private sealed class ToolCallChain(string toolName, string firstArgs, ToolCallItem head)
+ {
+ /// 链工具名(同名合并键)。
+ public string ToolName => toolName;
+
+ /// 链首次调用参数(替换链头时构造聚合项的历史首条)。
+ public string FirstArgs => firstArgs;
+
+ /// 链头独立项(首次上屏的 ToolCallItem — 第二次同名到达时定位原位替换)。
+ public ToolCallItem Head => head;
+
+ /// 激活的聚合项(null = 尚未聚合 — 链头仍为独立 ToolCallItem)。
+ public CollapsedToolItem? Collapsed { get; set; }
+ }
+
/// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项;
/// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景);
/// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用)。
@@ -81,7 +108,7 @@ public void Apply(BridgeEvent bridgeEvent)
// durationMs 供引擎侧比对,视图侧模式切换由后续事件(tool_call/token_delta)驱动。
break;
case BridgeEvent.ToolCall(var name, var args):
- AddMessage(new ToolCallItem(name, args));
+ ApplyToolCall(name, args);
_spinner.Mode = SpinnerMode.ToolUse;
_spinner.HasActiveTools = true;
break;
@@ -105,6 +132,7 @@ public void Apply(BridgeEvent bridgeEvent)
if (text is { Length: > 0 }) {
// M8-1 逐字流式 — 惰性建流式项(spinner 前)+ 原地追加(每帧全量重渲自然增长)
if (_streamingItem is null) {
+ EndToolChain();
_streamingItem = new StreamingTextItem();
AddMessage(_streamingItem);
}
@@ -112,6 +140,7 @@ public void Apply(BridgeEvent bridgeEvent)
}
break;
case BridgeEvent.AssistantMessage(var text, var markdown, var messageId, var thinkingDurationMs, var thinkingContent):
+ EndToolChain();
ReplaceStreamingWithFinal(markdown is not null
? new MarkdownMessageItem(markdown, _codeHighlighter, thinkingDurationMs, thinkingContent, messageId)
: new AssistantMessageItem(text, thinkingDurationMs, thinkingContent, messageId));
@@ -137,6 +166,7 @@ public void Apply(BridgeEvent bridgeEvent)
break;
case BridgeEvent.Error(var message):
+ EndToolChain();
AddMessage(new ErrorMessageItem(message));
break;
}
@@ -145,6 +175,7 @@ public void Apply(BridgeEvent bridgeEvent)
/// 子代理启动 — 惰性建组(组项落位 spinner 前)+ 条目追加 + spinner 切 ToolUse(子代理运行中主循环有活跃工具)。
private void ApplySubagentStarted(string subagentId, string agentType, string description)
{
+ EndToolChain();
if (_activeSubagentGroup is null)
{
_activeSubagentGroup = new SubagentGroupCoordinator();
@@ -156,6 +187,60 @@ private void ApplySubagentStarted(string subagentId, string agentType, string de
_spinner.HasActiveTools = true;
}
+ /// ToolCall 路由(M8-4 聚合)— 同名可聚合链上合并(已聚合 Record / 未聚合替换链头),
+ /// 不同名或非聚合工具断链后独立上屏(单次调用保持 ToolCallItem 类型 — 视觉与断言面零变化)。
+ private void ApplyToolCall(string name, string args)
+ {
+ if (_activeToolChain is { } chain && chain.ToolName == name)
+ {
+ ExtendChain(chain, args);
+ return;
+ }
+
+ EndToolChain();
+ if (CollapsedToolItem.IsAggregatable(name))
+ {
+ var head = new ToolCallItem(name, args);
+ AddMessage(head);
+ _activeToolChain = new ToolCallChain(name, args, head);
+ return;
+ }
+
+ AddMessage(new ToolCallItem(name, args));
+ }
+
+ /// 链上后续同名调用 — 已聚合则原地 Record(计数与最新提示行更新),
+ /// 未聚合则原位替换链头为聚合项(Count=2 起折叠形态 — 位置不变视觉无跳变)。
+ private void ExtendChain(ToolCallChain chain, string args)
+ {
+ if (chain.Collapsed is { } collapsed)
+ {
+ collapsed.RecordCall(args);
+ return;
+ }
+
+ var merged = new CollapsedToolItem(chain.ToolName, [chain.FirstArgs, args]);
+ ReplaceItem(chain.Head, merged);
+ chain.Collapsed = merged;
+ }
+
+ /// 断链(幂等)— 不同名工具/其他消息事件/回合边界调用。
+ private void EndToolChain() => _activeToolChain = null;
+
+ /// 消息项原位替换(聚合链头 → 聚合项;位置不变视觉无跳变,未找到时退化为 AddMessage)。
+ private void ReplaceItem(IScrollItem oldItem, IScrollItem newItem)
+ {
+ var index = _view.Messages.IndexOf(oldItem);
+ if (index < 0)
+ {
+ AddMessage(newItem);
+ return;
+ }
+
+ _view.Messages.RemoveAt(index);
+ _view.Messages.Insert(index, newItem);
+ }
+
/// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置";供应商 null → 空,banner 退化无前缀)
/// + footer 初始模式 + bypass 可用性 + 登录门禁态(footer 未登录提示真实化 — TASK060)。
private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd, string? vendor, bool loggedIn)
@@ -179,6 +264,7 @@ public void BeginTurn()
{
IsBusy = true;
_activeSubagentGroup = null;
+ EndToolChain();
DiscardStreamingItem();
_view.Footer = _view.Footer with { Busy = true };
_spinner.Message = string.Empty;
@@ -200,6 +286,7 @@ public void EndTurn()
IsBusy = false;
_activeSubagentGroup?.FinalizePending();
_activeSubagentGroup = null;
+ EndToolChain();
FinalizeStreamingItem();
_view.Footer = _view.Footer with { Busy = false };
_spinner.Message = string.Empty;
diff --git a/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs b/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs
new file mode 100644
index 000000000..464352422
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs
@@ -0,0 +1,73 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 工具调用聚合项(M8-4)— 连续同名可聚合工具调用(Read/Grep/Glob)的单行摘要投影,
+/// 对齐基准 CollapsedReadSearchContent 的 "Read N files" 折叠形态(中文文案):
+/// 单次(Count=1)渲染与 ToolCallItem 像素一致(● bold 工具名 + (参数) — 单次视觉零变化);
+/// 聚合态(Count≥2)摘要行 "● 读取 N 个文件"(● bullet + 动词 bold + 数量段默认样式)+
+/// 最新操作提示行 " ⎿ {LastArgs}" 整行 dim(基准组下方 latest file_path 行)。
+/// 高度随 Count 变化(2 → 3)由 ScrollBox 每帧全量重渲自动生效(无需失效通知)。
+/// 消费方:ReplBridgeDriver 聚合路由(第二次同名调用替换链头 ToolCallItem + 后续 RecordCall 原地更新)。
+///
+public sealed class CollapsedToolItem(string toolName, IReadOnlyList calls) : IScrollItem, ICursorNavigable, ITextMatchable
+{
+ /// 历史调用参数清单(全量保留 — transcript 搜索匹配任一,历史路径不丢)。
+ private readonly List _calls = [.. calls];
+
+ /// 聚合的工具名(同名合并键 — Driver 链路由判定)。
+ public string ToolName => toolName;
+
+ /// 聚合调用次数。
+ public int Count => _calls.Count;
+
+ /// 最新一次调用参数(聚合态提示行正文)。
+ public string LastArgs => _calls[^1];
+
+ /// 聚合摘要文案(断言面)— "读取 N 个文件" / "搜索 N 次" / "查找 N 个模式" / 兜底 "Tool × N"。
+ public string SummaryText
+ {
+ get
+ {
+ var (verb, countPhrase) = Summarize(toolName, Count);
+ return $"{verb} {countPhrase}";
+ }
+ }
+
+ /// 是否可聚合工具 — Read/Grep/Glob(基准语义:高频批量读/搜/找才折叠;Edit/Bash/Write 等独立上屏)。
+ public static bool IsAggregatable(string toolName) => toolName is "Read" or "Grep" or "Glob";
+
+ /// 后续同名调用到达 — 计数与最新提示行原地更新(下帧渲染自动反映)。
+ public void RecordCall(string args) => _calls.Add(args);
+
+ /// 内容高度 = margin 1 + 摘要 1 +(聚合态)最新提示行 1。
+ public int MeasureHeight(int width) => _calls.Count > 1 ? 3 : 2;
+
+ /// 渲染:margin 留空 → 单次 = ToolCall 形态 / 聚合态 = 摘要行 + 最新提示行。
+ public void Render(Screen screen, Rect frame, Theme theme)
+ {
+ if (_calls.Count == 1)
+ {
+ MessageRenderer.RenderToolCall(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, toolName, _calls[0]);
+ return;
+ }
+
+ var (verb, countPhrase) = Summarize(toolName, Count);
+ MessageRenderer.RenderCollapsedToolHeader(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, verb, countPhrase);
+ MessageRenderer.RenderCollapsedToolLastHint(screen, frame with { Y = frame.Y + 2, Height = 1 }, theme, LastArgs);
+ }
+
+ /// 摘要分段派生 — 动词段(bold 渲染)+ 数量段(默认样式渲染);未知工具兜底 "Tool × N"(Driver 白名单外的防御形态)。
+ private static (string Verb, string CountPhrase) Summarize(string toolName, int count) => toolName switch
+ {
+ "Read" => ("读取", $"{count} 个文件"),
+ "Grep" => ("搜索", $"{count} 次"),
+ "Glob" => ("查找", $"{count} 个模式"),
+ _ => (toolName, $"× {count}"),
+ };
+
+ /// 工具名 + 摘要 + 任一调用参数匹配(transcript 搜索 — 大小写不敏感)。
+ public bool ContainsText(string query) =>
+ toolName.Contains(query, StringComparison.OrdinalIgnoreCase)
+ || SummaryText.Contains(query, StringComparison.OrdinalIgnoreCase)
+ || _calls.Exists(args => args.Contains(query, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs b/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs
new file mode 100644
index 000000000..3e3ffde6c
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs
@@ -0,0 +1,266 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 消息操作底部操作条(M8-6)— 消息光标导航态激活期间挂载于滚动区尾部 spinner 之前
+/// (闲时 spinner Hidden 高度 0,操作条即滚动区末行 = 视觉输入框上方;对齐基准 messageActions
+/// 底部键位提示条):margin 1 + dim 全宽 ─ 上边框 + 1 空格缩进 dim 键位提示行(间隔符复用
+/// FooterBar 的 MiddotSeparator 先例)。非 ICursorNavigable(不参与导航、不被选中高亮投影);
+/// ReplShell 挂载/卸载(BeginCursorNavigation 进入 / EscapeCursorNavigation 退出)。
+///
+public sealed class MessageActionsBar : IScrollItem
+{
+ /// 键位提示文案(c 复制全文 / p 复制主参数 — M8-6 新增两键 + 既有导航键全集)。
+ internal const string HintsText =
+ $"enter 展开{Figures.MiddotSeparator}c 复制{Figures.MiddotSeparator}p 复制参数"
+ + $"{Figures.MiddotSeparator}{Figures.UpArrow}{Figures.DownArrow} 导航{Figures.MiddotSeparator}esc 返回";
+
+ /// 内容高度 = margin 1 + 上边框 1 + 键位提示行 1。
+ public int MeasureHeight(int width) => 3;
+
+ /// 渲染:margin 行留空 → dim 全宽 ─ 上边框 → 1 空格缩进 dim 键位提示行。
+ public void Render(Screen screen, Rect frame, Theme theme)
+ {
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ RenderTopBorder(screen, frame with { Y = frame.Y + 1, Height = 1 }, dim);
+ screen.WriteText(frame.Y + 2, frame.X, $" {HintsText}", dim, frame.Right);
+ }
+
+ /// dim 全宽上边框(─ 逐格填充,对齐权限对话框边框形态)。
+ private static void RenderTopBorder(Screen screen, Rect row, int dim)
+ {
+ for (var col = row.X; col < row.Right; col++)
+ {
+ screen[row.Y, col] = new Cell(new Rune('─'), dim, CellWidth.Default);
+ }
+ }
+}
+
+///
+/// 消息复制文本提取器(M8-6)— 光标导航态 c(全文)/ p(主参数)按消息项类型取文本
+/// (基准 PRIMARY_INPUT 按工具名映射 path/command/pattern 的本地简化):
+/// 用户/助手/错误/工具结果取 Text;diff 取 Summary(p)/ Summary + 补丁全量(c);
+/// 工具调用经无损渲染行提取(toolName 与 args 无公开属性 — MessageItems.cs 并行冻结禁改,
+/// 超宽单行屏渲染后剥前缀:c = "工具名(参数)"、p = 括号内参数);Markdown 递归块拼接。
+/// 不可提取类型(子代理分组/权限对话框等)返回 null(调用方静默降级 no-op)。
+///
+internal static class MessageActionsCopy
+{
+ /// 工具调用渲染提取屏宽度(参数无损上限 — 超长截断对齐渲染裁剪语义)。
+ private const int ExtractWidth = 4096;
+
+ /// 按消息项类型提取复制文本(primaryOnly=true 取主参数 p / false 取全文 c;null = 不可复制)。
+ public static string? TryGetText(IScrollItem item, bool primaryOnly) => item switch
+ {
+ UserMessageItem user => user.Text,
+ AssistantMessageItem assistant => assistant.Text,
+ ErrorMessageItem error => error.Text,
+ ToolResultItem result => result.Text,
+ DiffResultItem diff => primaryOnly ? diff.Summary : DiffFullText(diff),
+ ToolCallItem tool => primaryOnly ? ToolCallArgs(tool) : ToolCallLine(tool),
+ MarkdownMessageItem markdown => MarkdownFullText(markdown),
+ _ => null,
+ };
+
+ /// diff 全文 = Summary + 各 hunk(头 + 前缀行 — 渲染同构文本形态)。
+ private static string DiffFullText(DiffResultItem diff)
+ {
+ var builder = new StringBuilder(diff.Summary);
+ foreach (var hunk in diff.Patch)
+ {
+ builder.Append('\n').Append(hunk.Header);
+ foreach (var line in hunk.Lines)
+ {
+ builder.Append('\n').Append(line.Kind switch
+ {
+ DiffLineKind.Added => "+",
+ DiffLineKind.Removed => "-",
+ _ => " ",
+ }).Append(line.Content);
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ /// 工具调用主参数 — 渲染行剥 ● 前缀 + 工具名 + 括号(格式异常防御回退整行参数段)。
+ private static string? ToolCallArgs(ToolCallItem item)
+ {
+ var line = ToolCallLine(item);
+ if (line is null)
+ {
+ return null;
+ }
+
+ var open = line.IndexOf('(');
+ if (open <= 0 || line[^1] != ')')
+ {
+ return line;
+ }
+
+ return line[(open + 1)..^1];
+ }
+
+ /// 工具调用渲染行提取("● 工具名(参数)" — 剥 ● 前缀后全文形态)。
+ private static string? ToolCallLine(ToolCallItem item)
+ {
+ var rendered = RenderToolCallRow(item);
+ return rendered is { Length: > 2 } && rendered.StartsWith($"{Figures.MessageBullet} ", StringComparison.Ordinal)
+ ? rendered[2..]
+ : rendered;
+ }
+
+ /// 无损渲染工具调用行 — 超宽两行屏(参数不截断;行 0 = item 自带 margin 留空,
+ /// 行 1 = 正文)渲染后提取正文行纯文本(样式无关:零宽字符跳过/宽字符占位尾格跳过由
+ /// WriteText 与提取共同保证)。
+ private static string? RenderToolCallRow(ToolCallItem item)
+ {
+ using var screen = new Screen(ExtractWidth, 2);
+ item.Render(screen, new Rect(0, 0, ExtractWidth, 2), Theme.Dark);
+ return RowPlainText(screen, 1);
+ }
+
+ /// 行纯文本提取 — 尾部空格列截断 + 宽字符占位尾格跳过;全空行返回 null。
+ private static string? RowPlainText(Screen screen, int row)
+ {
+ var last = -1;
+ for (var col = screen.Cols - 1; col >= 0; col--)
+ {
+ if (screen[row, col].Value != new Rune(' '))
+ {
+ last = col;
+ break;
+ }
+ }
+
+ if (last < 0)
+ {
+ return null;
+ }
+
+ var builder = new StringBuilder();
+ for (var col = 0; col <= last; col++)
+ {
+ var cell = screen[row, col];
+ if (cell.Width == CellWidth.SpacerTail)
+ {
+ continue;
+ }
+
+ builder.Append(cell.Value.ToString());
+ }
+
+ return builder.ToString();
+ }
+
+ /// Markdown 全文 — 块递归拼接(段/标题段序列、代码原文、引用与列表项递归、
+ /// 分隔线、表格单元格 | 连接),顶层块间换行。
+ private static string? MarkdownFullText(MarkdownMessageItem item)
+ {
+ var builder = new StringBuilder();
+ foreach (var block in item.Document.Blocks)
+ {
+ AppendBlock(builder, block);
+ builder.Append('\n');
+ }
+
+ if (builder.Length == 0)
+ {
+ return null;
+ }
+
+ var text = builder.ToString().TrimEnd('\n');
+ return text.Length == 0 ? null : text;
+ }
+
+ /// 单块文本追加(嵌套块递归)。
+ private static void AppendBlock(StringBuilder builder, MarkdownBlock block)
+ {
+ switch (block)
+ {
+ case MarkdownParagraph paragraph:
+ AppendSegments(builder, paragraph.Segments);
+ break;
+ case MarkdownHeading heading:
+ AppendSegments(builder, heading.Segments);
+ break;
+ case MarkdownCode code:
+ builder.Append(code.Text);
+ break;
+ case MarkdownQuote quote:
+ AppendChildBlocks(builder, quote.Children);
+ break;
+ case MarkdownList list:
+ AppendListItems(builder, list);
+ break;
+ case MarkdownRule:
+ builder.Append("---");
+ break;
+ case MarkdownTable table:
+ AppendTable(builder, table);
+ break;
+ }
+ }
+
+ /// 子块序列追加(块间换行 — 引用/列表项子块)。
+ private static void AppendChildBlocks(StringBuilder builder, IReadOnlyList children)
+ {
+ for (var i = 0; i < children.Count; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append('\n');
+ }
+
+ AppendBlock(builder, children[i]);
+ }
+ }
+
+ /// 列表项追加(无序 "- " / 有序 "N. " 前缀 + 项内子块递归)。
+ private static void AppendListItems(StringBuilder builder, MarkdownList list)
+ {
+ for (var i = 0; i < list.Items.Count; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append('\n');
+ }
+
+ builder.Append(list.Ordered ? $"{list.Start + i}. " : "- ");
+ AppendChildBlocks(builder, list.Items[i].Children);
+ }
+ }
+
+ /// 表格追加(表头 + 数据行,单元格 | 连接 — 渲染同构文本形态)。
+ private static void AppendTable(StringBuilder builder, MarkdownTable table)
+ {
+ AppendRow(builder, table.Headers);
+ foreach (var row in table.Rows)
+ {
+ builder.Append('\n');
+ AppendRow(builder, row);
+ }
+ }
+
+ /// 单行追加(单元格 | 连接,每单元格为段序列)。
+ private static void AppendRow(StringBuilder builder, IReadOnlyList> cells)
+ {
+ for (var i = 0; i < cells.Count; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(" | ");
+ }
+
+ AppendSegments(builder, cells[i]);
+ }
+ }
+
+ /// 行内段序列追加(拼接段文本)。
+ private static void AppendSegments(StringBuilder builder, IReadOnlyList segments)
+ {
+ foreach (var segment in segments)
+ {
+ builder.Append(segment.Text);
+ }
+ }
+}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
index 7fbacc0d9..aaf26b664 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
@@ -138,8 +138,9 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
/// 带补丁的工具结果项(M7-2 StructuredDiff)— summary 行沿用 MessageResponse 形态,
/// 后接 hunk 块(4 空格缩进:header dim / added DiffAddedWord / removed DiffRemovedWord / context dim);
/// patch 全量渲染(基准 update 渲染完整 diff 不截断 — FileWriteTool/UI.tsx "update renders the full diff"),
-/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例,
-/// Ctrl+O 展开交互为 M7 后续项)。
+/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例);
+/// M8-4 工具折叠交互统一:IsExpanded/ToggleExpand 切换展开态 — 展开全量渲染 patch(折叠提示行消失)、
+/// 折叠回 10 行预览 + 提示行;MeasureHeight 随展开态变化(ScrollBox 每帧全量重渲自动生效 — 无需失效通知)。
///
public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem, ICursorNavigable, ITextMatchable
{
@@ -155,19 +156,27 @@ public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollIt
/// 结构化补丁 hunks(驱动侧断言与渲染共享)。
public DiffHunk[] Patch { get; } = patch;
+ /// 展开态(Ctrl+O 切换 — M8-4 工具折叠交互统一):true = 全量渲染 patch,false = 10 行预览 + 折叠提示。
+ public bool IsExpanded { get; private set; }
+
+ /// 切换展开态(高度变化由下次 MeasureHeight 反映 — 对齐 ToggleThinking 先例)。
+ public void ToggleExpand() => IsExpanded = !IsExpanded;
+
/// 补丁总行数(每 hunk = header 1 行 + diff 行数)。
private int TotalPatchRows => Patch.Sum(static hunk => 1 + hunk.Lines.Length);
- /// 内容高度 = margin 1 + summary 1 + 预览行数(超预览时为预览上限 + 折叠提示 1 行)。
+ /// 内容高度 = margin 1 + summary 1 + 展开全量 patch 行数 / 折叠 min(总行数, 预览上限) + 超限折叠提示 1。
public int MeasureHeight(int width) =>
- 2 + Math.Min(TotalPatchRows, PreviewPatchRows) + (TotalPatchRows > PreviewPatchRows ? 1 : 0);
+ 2 + (IsExpanded
+ ? TotalPatchRows
+ : Math.Min(TotalPatchRows, PreviewPatchRows) + (TotalPatchRows > PreviewPatchRows ? 1 : 0));
- /// 渲染:margin 留空 → summary(⎿ 形态)→ hunk 预览行 → 折叠提示(如有)。
+ /// 渲染:margin 留空 → summary(⎿ 形态)→ hunk 行(展开全量 / 折叠预览上限)→ 折叠提示(折叠态超限时)。
public void Render(Screen screen, Rect frame, Theme theme)
{
MessageRenderer.RenderToolResult(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, Summary);
- var renderedRows = Math.Min(TotalPatchRows, PreviewPatchRows);
+ var renderedRows = Math.Min(TotalPatchRows, IsExpanded ? TotalPatchRows : PreviewPatchRows);
var row = frame.Y + 2;
foreach (var hunk in Patch)
{
@@ -192,7 +201,7 @@ public void Render(Screen screen, Rect frame, Theme theme)
}
}
- if (TotalPatchRows > PreviewPatchRows)
+ if (!IsExpanded && TotalPatchRows > PreviewPatchRows)
{
MessageRenderer.RenderDiffCollapseHint(screen, RowFrame(frame, row), theme, TotalPatchRows - PreviewPatchRows);
}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
index 7363bc364..0da17f6da 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
@@ -119,13 +119,33 @@ public static void RenderDiffLine(Screen screen, Rect frame, Theme theme, DiffLi
screen.WriteText(frame.Y, frame.X, $" {prefix}{line.Content}", style, frame.Right);
}
- /// 渲染折叠提示:" … +N lines (Ctrl+O)"(dim;Ctrl+O 展开交互为 M7 后续项)。
+ /// 渲染折叠提示:" … +N lines (Ctrl+O)"(dim;Ctrl+O 切换 DiffResultItem.IsExpanded — M8-4 展开后此行消失)。
public static void RenderDiffCollapseHint(Screen screen, Rect frame, Theme theme, int hiddenLines)
{
var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
screen.WriteText(frame.Y, frame.X, $" … +{hiddenLines} lines (Ctrl+O)", dim, frame.Right);
}
+ ///
+ /// 渲染聚合摘要行(M8-4,基准 CollapsedReadSearchContent "Read N files" 形态):
+ /// ● + 空格 + 动词段 bold + 空格 + 数量段默认样式(结构对齐 RenderToolCall 的 bold 段 + 默认段)。
+ ///
+ public static void RenderCollapsedToolHeader(Screen screen, Rect frame, Theme theme, string verb, string countPhrase)
+ {
+ var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Text });
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ screen.WriteText(frame.Y, frame.X, $"{Figures.MessageBullet} ", bullet, frame.Right);
+ var col = screen.WriteText(frame.Y, frame.X + 2, verb, bold, frame.Right);
+ screen.WriteText(frame.Y, col, $" {countPhrase}", 0, frame.Right);
+ }
+
+ /// 渲染聚合最新操作提示行(M8-4):" ⎿ " 前缀 + 最新参数摘要,整行 dim(基准组下方 latest file_path 行)。
+ public static void RenderCollapsedToolLastHint(Screen screen, Rect frame, Theme theme, string lastArgs)
+ {
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ screen.WriteText(frame.Y, frame.X, $"{Figures.ToolResultIndent}{lastArgs}", dim, frame.Right);
+ }
+
/// 渲染权限对话框顶边框 — 整行 ─ 前景 permission 色(基准 borderStyle=round 无角,仅 top,全宽)。
public static void RenderPermissionBorder(Screen screen, Rect frame, Theme theme)
{
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index e36f23694..6d09d8d15 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -120,7 +120,10 @@ public sealed class ReplShell
private Screen _back = null!;
private bool _frontValid;
private long _animTimeMs;
- private bool _running = true;
+ private volatile bool _running = true;
+
+ /// 滚动区已请求清屏标记(M8-5 — /clear 回调置位,主循环渲染前消费;volatile 跨线程)。
+ private volatile bool _screenClearPending;
/// inline 模式上一帧尾光标(内容坐标)。
private CursorPos _cursor;
@@ -708,7 +711,7 @@ private void ToggleTranscriptMode()
}
}
- /// 进入 transcript:全部 CoT 展开(verbose — 基准 verbose=true 渲染)+ footer 专用提示 + 清查询态。
+ /// 进入 transcript:全部 CoT 展开 + Diff 全量展开(verbose — 基准 verbose=true 渲染)+ footer 专用提示 + 清查询态。
private void EnterTranscriptMode()
{
_transcriptMode = true;
@@ -723,13 +726,17 @@ private void EnterTranscriptMode()
{
md.ToggleThinking();
}
+ else if (item is DiffResultItem { IsExpanded: false } diff)
+ {
+ diff.ToggleExpand();
+ }
}
_savedNotification = _view.Footer.Notification;
_view.Footer = _view.Footer with { Notification = TranscriptFooterHint };
}
- /// 退出 transcript:全部 CoT 收起(基准 hidePastThinking)+ footer 还原。
+ /// 退出 transcript:全部 CoT 收起 + Diff 收起(基准 hidePastThinking + 折叠还原)+ footer 还原。
private void ExitTranscriptMode()
{
_transcriptMode = false;
@@ -744,6 +751,10 @@ private void ExitTranscriptMode()
{
md.ToggleThinking();
}
+ else if (item is DiffResultItem { IsExpanded: true } diff)
+ {
+ diff.ToggleExpand();
+ }
}
_view.Footer = _view.Footer with { Notification = _savedNotification };
@@ -977,10 +988,14 @@ private void HandleSearchKey(KeyPress key)
}
}
+ /// 底部操作条渲染项(M8-6 消息操作复制 — 导航态激活挂载于 spinner 之前 / 退出卸载)。
+ private readonly MessageActionsBar _actionsBar = new();
+
///
/// 消息光标导航模态按键(交互层 Step1,对齐基准 MESSAGE_ACTIONS 上下文键绑定):
/// ↑/↓/k/j 相邻可导航消息逐条移动、Shift+↑/↓ 跳相邻用户消息、Ctrl+↑/↓ 首末可导航消息、
/// Enter 切换选中分组展开态(基准展开 grouped_tool_use → verbose 逐代理详细渲染)、
+ /// c 复制选中消息全文 / p 复制工具主参数(M8-6 — 复制后不退出导航,基准 copy 键位)、
/// Esc 两级退出(任一分组展开先全部收起、再退出导航);
/// 其余按键全部吞掉(模态 — 基准全屏模式 PromptInput 卸载语义,不进输入缓冲)。
///
@@ -1037,14 +1052,25 @@ private void HandleCursorNavKey(KeyPress key)
case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "j":
_cursorNav.Move(items, 1);
return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "c":
+ CopySelectedMessage(primaryOnly: false);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "p":
+ CopySelectedMessage(primaryOnly: true);
+ return;
default:
// 模态:其余键吞掉(不进输入缓冲 — 基准光标态 PromptInput 卸载)
return;
}
}
- /// 进入消息光标导航(Shift+↑ — 定位最后一条用户消息;无可导航消息 no-op)。
- private void BeginCursorNavigation() => _cursorNav.Begin(_view.Messages.Items);
+ /// 进入消息光标导航(Shift+↑ — 定位最后一条用户消息;无可导航消息 no-op)
+ /// + 挂载底部操作条(激活成功才挂 — 无可导航消息空会话不出操作条)。
+ private void BeginCursorNavigation()
+ {
+ _cursorNav.Begin(_view.Messages.Items);
+ ShowActionsBar();
+ }
/// Enter 切换选中分组展开态(选中非分组消息 no-op — 基准仅 grouped_tool_use 可展开)。
private void ToggleSelectedGroupExpansion()
@@ -1057,7 +1083,8 @@ private void ToggleSelectedGroupExpansion()
}
}
- /// Esc 两级退出 — 任一分组展开先全部收起(导航保持);否则退出光标导航。
+ /// Esc 两级退出 — 任一分组展开先全部收起(导航保持、操作条保持);
+ /// 否则退出光标导航并卸载底部操作条。
private void EscapeCursorNavigation()
{
var expandedGroups = _view.Messages.Items
@@ -1075,6 +1102,7 @@ private void EscapeCursorNavigation()
}
_cursorNav.Exit();
+ HideActionsBar();
}
/// 补全激活时的专属键拦截(基准 Autocomplete 上下文):
@@ -1373,6 +1401,17 @@ private void PumpQueuedInputs()
/// internal = resize/渲染测试的单帧驱动面。
internal void RenderAndFlush()
{
+ if (_screenClearPending)
+ {
+ _screenClearPending = false;
+ // 终端层清屏 + 清 scrollback(对话历史滚入区)+ 光标回原点;渲染基线重置 — 下一帧按首帧语义重绘 banner+spinner+输入区
+ _output.Write(Sgr.Reset + Csi.EraseScreen() + Csi.EraseSavedLines() + Csi.CursorPosition(1, 1));
+ _output.Flush();
+ _frontValid = false;
+ _prevOverride = null;
+ _cursor = new CursorPos(Col: 0, Row: 0);
+ }
+
SyncSuggestionState();
SyncCursorHighlight();
EnsureScreens();
@@ -1490,6 +1529,24 @@ public void WriteExit()
_output.Flush();
}
+ /// 清滚动区(M8-5 — /clear 接线,引擎回合线程回调):
+ /// 移除 banner 与常驻 spinner 之间全部消息项(保留 SeedContent 不变式)+ 置清屏待决标记 —
+ /// 终端层 2J+3J 清屏延到主循环 RenderAndFlush 消费(回调线程不写终端 — 与渲染循环并发写交错)。
+ public void ClearMessagesExceptBannerAndSpinner()
+ {
+ for (var i = _view.Messages.Count - 2; i > 0; i--)
+ {
+ _view.Messages.RemoveAt(i);
+ }
+
+ _view.Messages.ScrollToBottom();
+ _screenClearPending = true;
+ }
+
+ /// 请求退出主循环(M8-5 — /exit 接线,引擎回合线程回调):
+ /// 置 _running=false 对齐 Ctrl+D 语义,Run() 当前拍收尾返回后由宿主 WriteExit 收尾。
+ public void RequestExit() => _running = false;
+
/// 终端光标落到输入文本行(视口模式 — 0 基 Rows-2 → 1 基 Rows-1),列随编辑偏移。
private void PositionCursor()
{
@@ -1508,4 +1565,67 @@ private void PaceTick(long tickStart)
Thread.Sleep(sleepMs);
}
}
+
+ /// 复制选中消息(M8-6 消息操作复制 — 光标导航态 c 全文 / p 工具主参数):
+ /// 剪贴板委托 null(未注入)或选中项无可复制文本时静默无操作(对齐 CopyLastAssistantMessage
+ /// 降级先例);复制后不退出导航(基准 copy 键位后光标保持 — 继续导航)。
+ private void CopySelectedMessage(bool primaryOnly)
+ {
+ if (_copyToClipboard is null)
+ {
+ return;
+ }
+
+ if (_cursorNav.SelectedIndex is not (>= 0 and var index) || index >= _view.Messages.Count)
+ {
+ return;
+ }
+
+ var text = MessageActionsCopy.TryGetText(_view.Messages.Items[index], primaryOnly);
+ if (text is null)
+ {
+ return;
+ }
+
+ _copyToClipboard(text);
+ PostCopyFeedback();
+ }
+
+ /// 复制反馈 — "已复制到剪贴板" 消息插到操作条之前(消息流末尾语义,操作条/spinner
+ /// 尾部常驻位保持;反馈插入选中项之后不影响光标索引);无操作条(非导航态防御)退回
+ /// AddMessage 语义。
+ private void PostCopyFeedback()
+ {
+ var feedback = new AssistantMessageItem("已复制到剪贴板");
+ var barIndex = _view.Messages.IndexOf(_actionsBar);
+ if (barIndex < 0)
+ {
+ AddMessage(feedback);
+ return;
+ }
+
+ _view.Messages.Insert(barIndex, feedback);
+ }
+
+ /// 挂载底部操作条 — 导航态激活时插到常驻 spinner 之前(SpinnerItem 同款尾部常驻模式;
+ /// 闲时 spinner 高度 0 = 操作条即滚动区末行,视觉输入框上方);未激活/已挂载幂等 no-op。
+ private void ShowActionsBar()
+ {
+ if (!_cursorNav.IsCursorActive || _view.Messages.IndexOf(_actionsBar) >= 0)
+ {
+ return;
+ }
+
+ _view.Messages.Insert(_view.Messages.Count - 1, _actionsBar);
+ }
+
+ /// 卸载底部操作条 — 导航态退出时移除(未挂载幂等 no-op)。
+ private void HideActionsBar()
+ {
+ var index = _view.Messages.IndexOf(_actionsBar);
+ if (index >= 0)
+ {
+ _view.Messages.RemoveAt(index);
+ }
+ }
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs
new file mode 100644
index 000000000..664d07d63
--- /dev/null
+++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs
@@ -0,0 +1,167 @@
+namespace NativeTui.Host.Tests;
+
+///
+/// M8-5 命令面交互回调测试 — EngineSession.DispatchSlashCommandAsync 的回调透传链:
+/// TuiCommandCallbacks(Bind 延迟绑定容器,对齐 TuiPermissionBridge 形态)→
+/// SlashCommandRunner.RunAsync(clearScreen/onExitRequested 透传)→ 命令执行
+/// (ClearCommand context.ClearScreen?.Invoke / ExitCommand ShouldContinue=false → onExitRequested)。
+/// fake DI(IChatService/ICodeService/IPlanService/IToolRegistry stub)驱动真实命令注册链
+/// (ChatCommandRegistry 兜底生成注册 — /clear、/exit 命中斜杠不经 MCP),零引擎依赖纯回调管道验证;
+/// 组装层全链(Program 接线 → ReplShell 能力)由主智能体收编 ReplShell 公开入口后闭环。
+///
+public sealed class EngineSessionCommandCallbacksTests {
+ /// /clear 回调透传 — ClearCommand 执行链触发 clearScreen 回调 + 引擎历史真实清除。
+ [Fact]
+ public async Task Dispatch_Clear_InvokesClearScreenCallbackAndClearsHistory() {
+ var chat = new FakeChatService();
+ using var services = BuildFakeServices(chat);
+ var cleared = false;
+ var callbacks = new TuiCommandCallbacks();
+ callbacks.Bind(clearScreen: () => cleared = true, onExitRequested: null);
+ var consumer = new InProcessEventConsumer();
+
+ await EngineSession.DispatchSlashCommandAsync(services, "/clear", consumer, callbacks, CancellationToken.None);
+
+ Assert.True(cleared, "/clear 应触发 clearScreen 回调(清 TUI 滚动区接线点)");
+ Assert.Equal(1, chat.ClearHistoryCalls);
+ }
+
+ /// /clear 输出上屏 — 清空反馈经 consumer.OnText(AnsiStripper 剥离后)→ token_delta。
+ [Fact]
+ public async Task Dispatch_Clear_EmitsFeedbackText() {
+ var chat = new FakeChatService();
+ using var services = BuildFakeServices(chat);
+ var callbacks = new TuiCommandCallbacks();
+ var consumer = new InProcessEventConsumer();
+
+ await EngineSession.DispatchSlashCommandAsync(services, "/clear", consumer, callbacks, CancellationToken.None);
+
+ var events = consumer.DrainEvents();
+ Assert.Contains(events, static e =>
+ e is BridgeEvent.TokenDelta { Text: { } text } && text.Contains("已清空", StringComparison.Ordinal));
+ }
+
+ /// /exit 回调透传 — ExitCommand 非交互直退(ShouldContinue=false)→ onExitRequested 回调触发。
+ [Fact]
+ public async Task Dispatch_Exit_InvokesExitRequestedCallback() {
+ var chat = new FakeChatService();
+ using var services = BuildFakeServices(chat);
+ var exitRequested = false;
+ var callbacks = new TuiCommandCallbacks();
+ callbacks.Bind(clearScreen: null, onExitRequested: () => exitRequested = true);
+ var consumer = new InProcessEventConsumer();
+
+ await EngineSession.DispatchSlashCommandAsync(services, "/exit", consumer, callbacks, CancellationToken.None);
+
+ Assert.True(exitRequested, "/exit ShouldContinue=false 应触发 onExitRequested 回调(TUI 主循环结束接线点)");
+ }
+
+ /// 未绑定降级 — Bind 前回调为 null:/clear、/exit 命令本体照常执行不抛(?.Invoke 安全跳过)。
+ [Fact]
+ public async Task Dispatch_UnboundCallbacks_ClearAndExitExecuteSafely() {
+ var chat = new FakeChatService();
+ using var services = BuildFakeServices(chat);
+ var callbacks = new TuiCommandCallbacks();
+
+ await EngineSession.DispatchSlashCommandAsync(services, "/clear", new InProcessEventConsumer(), callbacks, CancellationToken.None);
+ await EngineSession.DispatchSlashCommandAsync(services, "/exit", new InProcessEventConsumer(), callbacks, CancellationToken.None);
+
+ Assert.Equal(1, chat.ClearHistoryCalls);
+ }
+
+ /// 容器契约 — Bind 前回调 null + IsBound false。
+ [Fact]
+ public void TuiCommandCallbacks_BeforeBind_CallbacksAreNull() {
+ var callbacks = new TuiCommandCallbacks();
+
+ Assert.Null(callbacks.ClearScreen);
+ Assert.Null(callbacks.OnExitRequested);
+ Assert.False(callbacks.IsBound);
+ }
+
+ /// 容器契约 — Bind 后委托同引用暴露 + IsBound true + 调用透传生效。
+ [Fact]
+ public void TuiCommandCallbacks_AfterBind_ExposesBoundDelegates() {
+ var callbacks = new TuiCommandCallbacks();
+ var cleared = false;
+ var exited = false;
+ Action clear = () => cleared = true;
+ Action exit = () => exited = true;
+
+ callbacks.Bind(clear, exit);
+
+ Assert.Same(clear, callbacks.ClearScreen);
+ Assert.Same(exit, callbacks.OnExitRequested);
+ Assert.True(callbacks.IsBound);
+ callbacks.ClearScreen!.Invoke();
+ callbacks.OnExitRequested!.Invoke();
+ Assert.True(cleared);
+ Assert.True(exited);
+ }
+
+ /// fake DI — SlashCommandRunner 必需依赖(IChatService 真实记录 ClearHistory;
+ /// ICodeService/IPlanService/IToolRegistry 为签名占位 stub — /clear、/exit 命中斜杠不触达)。
+ private static ServiceProvider BuildFakeServices(FakeChatService chat) {
+ return new ServiceCollection()
+ .AddSingleton(chat)
+ .AddSingleton(new StubCodeService())
+ .AddSingleton(new StubPlanService())
+ .AddSingleton(new StubToolRegistry())
+ .BuildServiceProvider();
+ }
+
+ /// IChatService 记录桩 — ClearHistoryAsync 计数(/clear 断言面),其余成员不支持。
+ private sealed class FakeChatService : IChatService {
+ public int ClearHistoryCalls;
+
+ public Task ClearHistoryAsync(CancellationToken cancellationToken = default) {
+ ClearHistoryCalls++;
+ return Task.CompletedTask;
+ }
+
+ public Task SendMessageAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public IAsyncEnumerable SendMessageStreamAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public IAsyncEnumerable StreamWithEventsAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetMessageListAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task SetSystemPromptAsync(string systemPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RewindLastTurnAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RewindToMessageIndexAsync(int messageIndex, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RewindToStartAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task LoadSessionMessagesAsync(IReadOnlyList messages, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task CompactHistoryAsync(string summary, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public ValueTask DisposeAsync() => default;
+ }
+
+ /// ICodeService 签名占位桩 — BuildCommandServiceProvider 必需依赖,命令执行不触达。
+ private sealed class StubCodeService : ICodeService {
+ public Task GenerateCodeAsync(string prompt, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task AnalyzeCodeAsync(string code, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ExecuteCodeAsync(string code, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+
+ /// IPlanService 签名占位桩 — BuildCommandServiceProvider 必需依赖,命令执行不触达。
+ private sealed class StubPlanService : IPlanService {
+ public Task ExecutePlanAsync(string userPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ExecutePlanWithResultAsync(string userPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+
+ /// IToolRegistry 签名占位桩 — 使 CmdMap 构造(非 null 走斜杠优先路由);/clear、/exit 命中斜杠,MCP 面不触达。
+ private sealed class StubToolRegistry : IToolRegistry {
+ public ValueTask DisposeAsync() => default;
+ public Task RegisterToolAsync(IToolHandler handler, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RegisterToolsBatchAsync(IReadOnlyList handlers, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task RegisterToolAsync(string name, string description, ToolSchema inputSchema, ToolHandler handler, CancellationToken cancellationToken = default, ToolKind kind = ToolKind.System, string? groupName = null, ToolTimeoutPolicy? timeoutPolicy = null, string? category = null) => throw new NotSupportedException();
+ public Task UnregisterToolAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetToolAsync(string toolName, CancellationToken cancellationToken = default) => Task.FromResult(null);
+ public Task> GetAllToolsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ExecuteToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default, ToolProgressCallback? onProgress = null) => throw new NotSupportedException();
+ public Task GetToolInfoAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetAllToolInfosAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ContainsToolAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetCountAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ClearAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetGroupNamesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetToolsByKindAsync(ToolKind kind, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetToolsByGroupAsync(string groupName, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 0cb11c15b..80a1955d4 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -1,5 +1,7 @@
global using System.Text;
+global using System.Text.Json;
global using System.Collections.Concurrent;
+global using System.Collections.Frozen;
global using Core.Configuration;
global using JoinCode;
global using JoinCode.Abstractions.Configuration;
@@ -9,9 +11,11 @@
global using JoinCode.Abstractions.Exceptions;
global using JoinCode.Abstractions.Interfaces;
global using JoinCode.Abstractions.LLM.Chat;
+global using JoinCode.Abstractions.Models;
global using JoinCode.Abstractions.Models.Diff;
global using JoinCode.Abstractions.Security.Permission;
global using JoinCode.Abstractions.Security.Shell;
+global using JoinCode.Abstractions.Tools;
global using JoinCode.Abstractions.Utils;
global using JoinCode.Adapters;
global using JoinCode.App.Builder;
diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
index 1c8f00648..ef00a91ff 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
@@ -368,4 +368,136 @@ public void BeginTurn_ClearsLeftoverStreamingItem()
Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem);
}
+
+ [Fact]
+ public void Apply_ConsecutiveSameToolCalls_CollapseIntoSingleItem()
+ {
+ // M8-4 聚合路由:首次独立上屏 ToolCallItem,第二次同名到达替换为 CollapsedToolItem(Count=2)
+ _driver.BeginTurn();
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "src/a.cs"));
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "src/b.cs"));
+
+ var collapsed = Assert.IsType(_view.Messages.Items[^2]);
+ Assert.Equal("Read", collapsed.ToolName);
+ Assert.Equal(2, collapsed.Count);
+ Assert.Equal("src/b.cs", collapsed.LastArgs);
+ Assert.Same(_spinner, _view.Messages.Items[^1]);
+ Assert.Equal(3, _view.Messages.Count);
+ }
+
+ [Fact]
+ public void Apply_SingleAggregatableCall_StaysPlainToolCallItem()
+ {
+ // 单次调用不聚合 — 保持 ToolCallItem 类型(现有断言面/视觉形态零变化)
+ _driver.BeginTurn();
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "src/a.cs"));
+
+ Assert.IsType(_view.Messages.Items[^2]);
+ Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem);
+ }
+
+ [Fact]
+ public void Apply_ThirdSameCall_RecordsIntoExistingCollapsedItem()
+ {
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs"));
+ var collapsed = Assert.IsType(_view.Messages.Items[^2]);
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs"));
+
+ // 聚合激活后后续同名原地 Record — 不新增消息项
+ Assert.Same(collapsed, _view.Messages.Items[^2]);
+ Assert.Equal(3, collapsed.Count);
+ Assert.Equal("c.cs", collapsed.LastArgs);
+ Assert.Equal(3, _view.Messages.Count);
+ }
+
+ [Fact]
+ public void Apply_ToolResultBetweenSameCalls_DoesNotBreakAggregation()
+ {
+ // 聚合仅统计 ToolCallItem — 工具结果照旧独立上屏且不打断链
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolResult("41 lines"));
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs"));
+
+ var collapsed = Assert.IsType(_view.Messages.Items[^3]);
+ Assert.Equal(2, collapsed.Count);
+ Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolResultItem));
+ }
+
+ [Fact]
+ public void Apply_DifferentAggregatableTool_BreaksChainForIndependentItems()
+ {
+ // 不同名工具到达即结束当前聚合 — 三次调用互不聚合,各自独立上屏
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolCall("Grep", "pattern"));
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs"));
+
+ Assert.Equal(3, _view.Messages.Items.Count(static item => item is ToolCallItem));
+ Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem);
+ }
+
+ [Fact]
+ public void Apply_NonAggregatableTools_NeverAggregate()
+ {
+ // Edit/Bash/Write 等非聚合类工具照旧独立上屏
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls"));
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "pwd"));
+
+ Assert.Equal(2, _view.Messages.Items.Count(static item => item is ToolCallItem));
+ Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem);
+ }
+
+ [Fact]
+ public void Apply_AssistantMessage_BreaksAggregationChain()
+ {
+ // 文本消息到达断链 — 之后的同名调用重新独立上屏(不被旧聚合吸收)
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs"));
+ _driver.Apply(new BridgeEvent.AssistantMessage("正文"));
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs"));
+
+ var collapsedItems = _view.Messages.Items.OfType().ToList();
+ Assert.Single(collapsedItems);
+ Assert.Equal(2, collapsedItems[0].Count);
+ Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolCallItem));
+ }
+
+ [Fact]
+ public void EndTurn_BreaksAggregationChain()
+ {
+ // 回合边界断链 — 新回合同名调用重新独立上屏
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs"));
+ _driver.EndTurn();
+
+ _driver.BeginTurn();
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs"));
+
+ Assert.Equal(1, _view.Messages.Items.Count(static item => item is CollapsedToolItem));
+ Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolCallItem));
+ }
+
+ [Fact]
+ public void Apply_GrepConsecutiveCalls_AggregatesWithSearchVerb()
+ {
+ _driver.BeginTurn();
+
+ _driver.Apply(new BridgeEvent.ToolCall("Grep", "TODO"));
+ _driver.Apply(new BridgeEvent.ToolCall("Grep", "FIXME"));
+
+ var collapsed = Assert.IsType(_view.Messages.Items[^2]);
+ Assert.Equal("搜索 2 次", collapsed.SummaryText);
+ Assert.Equal("FIXME", collapsed.LastArgs);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs
new file mode 100644
index 000000000..dd6351fe8
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs
@@ -0,0 +1,108 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// 工具调用聚合项测试(M8-4)— 连续同名可聚合调用(Read/Grep/Glob)的单行摘要投影:
+/// 单次(Count=1)渲染与 ToolCallItem 像素一致(● text 色 + bold 工具名 + 默认 (参数) — 单次视觉不变);
+/// 聚合态(Count≥2)摘要行 "● 读取 N 个文件"(● bullet + 动词 bold + 数量段默认样式)+
+/// 最新操作提示行 " ⎿ {最新参数}" 整行 dim(对齐基准 CollapsedReadSearchContent 组下方 latest 行,中文文案)。
+///
+public sealed class CollapsedToolItemTests
+{
+ [Fact]
+ public void Render_SingleCall_MatchesToolCallForm()
+ {
+ using var screen = new Screen(cols: 40, rows: 3);
+ var item = new CollapsedToolItem("Read", ["src/a.cs"]);
+
+ item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark);
+
+ // row0 margin 留空;row1 与 ToolCallItem 像素一致:● text 色 + bold 工具名 + 默认 (参数)
+ var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text });
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ Assert.Equal(new Rune(' '), screen[0, 0].Value);
+ Assert.Equal(new Rune('●'), screen[1, 0].Value);
+ Assert.Equal(bullet, screen[1, 0].StyleId);
+ Assert.Equal(bold, screen[1, 2].StyleId);
+ Assert.Equal(0, screen[1, 6].StyleId);
+ Assert.Equal("● Read(src/a.cs)", RowText.Of(screen, 1, 0, 16));
+ }
+
+ [Fact]
+ public void Render_TwoCalls_AggregateSummaryAndLastHint()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new CollapsedToolItem("Read", ["src/a.cs", "src/b.cs"]);
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ // row1 摘要 = ● text 色 + bold "读取" + 默认 " 2 个文件"(结构对齐 RenderToolCall 的 bold 段 + 默认段;
+ // 汉字占 2 cell(SpacerTail 跳过)— 15 cell 覆盖全行)
+ var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text });
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ Assert.Equal("● 读取 2 个文件", RowText.Of(screen, 1, 0, 15));
+ Assert.Equal(bullet, screen[1, 0].StyleId);
+ Assert.Equal(bold, screen[1, 2].StyleId);
+ Assert.Equal(bold, screen[1, 3].StyleId);
+ Assert.Equal(bold, screen[1, 4].StyleId);
+ Assert.Equal(0, screen[1, 7].StyleId);
+
+ // row2 最新提示 = " ⎿ src/b.cs" 整行 dim(前缀与正文同 dim 样式)
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(" ⎿ src/b.cs", RowText.Of(screen, 2, 0, 13));
+ Assert.Equal(dim, screen[2, 2].StyleId);
+ Assert.Equal(dim, screen[2, 5].StyleId);
+ }
+
+ [Fact]
+ public void MeasureHeight_SingleCallTwoRows_AggregatedThreeRows()
+ {
+ Assert.Equal(2, new CollapsedToolItem("Read", ["a.cs"]).MeasureHeight(width: 80));
+ Assert.Equal(3, new CollapsedToolItem("Read", ["a.cs", "b.cs"]).MeasureHeight(width: 80));
+ }
+
+ [Fact]
+ public void RecordCall_IncrementsCountAndUpdatesLastArgs()
+ {
+ var item = new CollapsedToolItem("Read", ["a.cs"]);
+ Assert.Equal(1, item.Count);
+ Assert.Equal("a.cs", item.LastArgs);
+
+ item.RecordCall("b.cs");
+ item.RecordCall("c.cs");
+
+ Assert.Equal(3, item.Count);
+ Assert.Equal("c.cs", item.LastArgs);
+ }
+
+ [Fact]
+ public void SummaryText_PerToolChineseVerbs()
+ {
+ Assert.Equal("读取 2 个文件", new CollapsedToolItem("Read", ["a.cs", "b.cs"]).SummaryText);
+ Assert.Equal("搜索 3 次", new CollapsedToolItem("Grep", ["a", "b", "c"]).SummaryText);
+ Assert.Equal("查找 2 个模式", new CollapsedToolItem("Glob", ["*.cs", "*.ts"]).SummaryText);
+ Assert.Equal("Bash × 2", new CollapsedToolItem("Bash", ["ls", "pwd"]).SummaryText);
+ }
+
+ [Fact]
+ public void IsAggregatable_OnlyReadGrepGlob()
+ {
+ Assert.True(CollapsedToolItem.IsAggregatable("Read"));
+ Assert.True(CollapsedToolItem.IsAggregatable("Grep"));
+ Assert.True(CollapsedToolItem.IsAggregatable("Glob"));
+ Assert.False(CollapsedToolItem.IsAggregatable("Edit"));
+ Assert.False(CollapsedToolItem.IsAggregatable("Bash"));
+ Assert.False(CollapsedToolItem.IsAggregatable("Write"));
+ }
+
+ [Fact]
+ public void ContainsText_MatchesToolNameSummaryAndAnyCallArgs()
+ {
+ var item = new CollapsedToolItem("Read", ["src/alpha.cs", "src/beta.cs"]);
+
+ Assert.True(item.ContainsText("alpha"));
+ Assert.True(item.ContainsText("BETA"));
+ Assert.True(item.ContainsText("read"));
+ Assert.True(item.ContainsText("读取"));
+ Assert.False(item.ContainsText("gamma"));
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
index 50795dddb..8ba44c37c 100644
--- a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
@@ -181,4 +181,52 @@ public void Patch_ExposedForDriverAssertions()
Assert.Equal("summary", item.Summary);
Assert.Same(hunks, item.Patch);
}
+
+ [Fact]
+ public void IsExpanded_DefaultsFalse()
+ {
+ Assert.False(new DiffResultItem("s", [Hunk(Line(DiffLineKind.Added, "x"))]).IsExpanded);
+ }
+
+ [Fact]
+ public void ToggleExpand_RendersFullPatchWithoutCollapseHint()
+ {
+ // 13 行 patch(header+12):折叠 = 预览 10 + 提示 +3(高度 13);展开 = 全量 13 行(高度 15),
+ // 原提示行位置(row12)起接续渲染隐藏行 line 9..11 — 无折叠提示行
+ using var screen = new Screen(cols: 40, rows: 2 + 13);
+ var item = new DiffResultItem("Edited a.cs", [LongHunk(12)]);
+ Assert.Equal(13, item.MeasureHeight(width: 80));
+
+ item.ToggleExpand();
+
+ Assert.True(item.IsExpanded);
+ Assert.Equal(15, item.MeasureHeight(width: 80));
+ item.Render(screen, new Rect(0, 0, 40, screen.Rows), Theme.Dark);
+ Assert.Equal(" line 9", RowText.Of(screen, 12, 0, 11));
+ Assert.Equal(" line 10", RowText.Of(screen, 13, 0, 12));
+ Assert.Equal(" line 11", RowText.Of(screen, 14, 0, 12));
+ }
+
+ [Fact]
+ public void ToggleExpand_Twice_ReturnsToCollapsedPreview()
+ {
+ var item = new DiffResultItem("s", [LongHunk(12)]);
+
+ item.ToggleExpand();
+ item.ToggleExpand();
+
+ Assert.False(item.IsExpanded);
+ Assert.Equal(13, item.MeasureHeight(width: 80));
+ }
+
+ [Fact]
+ public void ToggleExpand_WithinPreviewLimit_HeightUnchanged()
+ {
+ // patch 2 行 ≤ 预览上限 10:展开态无折叠提示,高度不变(展开只对超限 patch 有视觉差异)
+ var item = new DiffResultItem("s", [Hunk(Line(DiffLineKind.Added, "x"))]);
+
+ item.ToggleExpand();
+
+ Assert.Equal(4, item.MeasureHeight(width: 80));
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs b/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs
new file mode 100644
index 000000000..f3a0cdeda
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs
@@ -0,0 +1,404 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// 消息操作复制测试(M8-6)— 对齐基准 messageActions.tsx 键位语义:
+/// 光标导航态 c 复制选中消息全文 / p 复制工具主参数(按消息项类型取文本:工具调用取 args —
+/// 渲染行无损提取、用户/助手/工具结果取 Text、diff 取 Summary[p]/Summary+补丁全量[c]、
+/// Markdown 递归块拼接)、复制后不退出导航(继续移动)、剪贴板未注入静默降级(对齐
+/// CopyLastAssistantMessage 先例)、复制反馈消息插到操作条之前;底部操作条 MessageActionsBar
+/// 导航态挂载于 spinner 之前(闲时 spinner 高度 0 = 操作条即滚动区末行,视觉输入框上方)、
+/// Esc 退出卸载、分组展开两级 Esc 期间保持、不参与导航不被选中。
+///
+public sealed class MessageActionsCopyTests
+{
+ /// 构造测试 shell(StringWriter 收渲染输出,固定尺寸源,剪贴板委托捕获到 copied;
+ /// clipboard=false 模拟宿主未注入剪贴板能力)。
+ private static (ReplShell Shell, List Copied) CreateShell(IReadOnlyList seed, bool clipboard = true)
+ {
+ var copied = new List();
+ var engine = new FakeReplEngine();
+ var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp");
+ var shell = new ReplShell(engine, banner, new StringWriter(), seedMessages: seed,
+ sizeSource: new FakeTerminalSizeSource(80, 24),
+ clipboard: clipboard ? copied.Add : null);
+ return (shell, copied);
+ }
+
+ /// 构造三代理组(不可复制类型 no-op 场景)。
+ private static SubagentGroupCoordinator CreateGroup()
+ {
+ var coordinator = new SubagentGroupCoordinator();
+ coordinator.AddAgent("a1", "explore", "搜索");
+ coordinator.AddAgent("a2", "reviewer", "审查");
+ return coordinator;
+ }
+
+ /// 构造带补丁的 diff 结果项(p=Summary / c=Summary+补丁全量场景)。
+ private static DiffResultItem CreateDiff() => new("Edited 2 files",
+ [
+ new DiffHunk
+ {
+ Header = "@@ -1,2 +1,3 @@",
+ Lines =
+ [
+ new DiffLine { Kind = DiffLineKind.Context, Content = "上下文" },
+ new DiffLine { Kind = DiffLineKind.Removed, Content = "旧行" },
+ new DiffLine { Kind = DiffLineKind.Added, Content = "新行" },
+ ],
+ },
+ ]);
+
+ /// 构造两块 Markdown 文档项(段落 + 代码块 — 全文递归拼接场景)。
+ private static MarkdownMessageItem CreateMarkdown() => new(new MarkdownDocument(
+ [
+ new MarkdownParagraph([new MarkdownSegment("第一段", MarkdownStyle.None)]),
+ new MarkdownCode("cs", "var x = 1;"),
+ ]));
+
+ /// Shift+↑ 键(进入消息光标导航)。
+ private static ConsoleKeyInfo ShiftUp() => new('\0', ConsoleKey.UpArrow, shift: true, alt: false, control: false);
+
+ /// 普通 ↑ 键。
+ private static ConsoleKeyInfo Up() => new('\0', ConsoleKey.UpArrow, shift: false, alt: false, control: false);
+
+ /// 普通 ↓ 键。
+ private static ConsoleKeyInfo Down() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: false);
+
+ /// Ctrl+↓ 键(末条可导航消息)。
+ private static ConsoleKeyInfo CtrlDown() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: true);
+
+ /// Enter 键。
+ private static ConsoleKeyInfo Enter() => new('\r', ConsoleKey.Enter, shift: false, alt: false, control: false);
+
+ /// Esc 键。
+ private static ConsoleKeyInfo Esc() => new('\0', ConsoleKey.Escape, shift: false, alt: false, control: false);
+
+ /// 可打印字符键。
+ private static ConsoleKeyInfo Char(char c) => new(c, ConsoleKey.None, shift: false, alt: false, control: false);
+
+ /// 滚动区内 "已复制到剪贴板" 反馈消息索引(-1 = 无)。
+ private static int IndexOfFeedback(IReadOnlyList items)
+ {
+ for (var i = 0; i < items.Count; i++)
+ {
+ if (items[i] is AssistantMessageItem { Text: "已复制到剪贴板" })
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /// 滚动区内操作条索引(-1 = 未挂载)。
+ private static int IndexOfActionBar(IReadOnlyList items)
+ {
+ for (var i = 0; i < items.Count; i++)
+ {
+ if (items[i] is MessageActionsBar)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /// 全屏文本包含判断(逐行拼接 cell 文本,跳过宽字符占位尾格)。
+ private static bool ScreenContains(Screen screen, string text)
+ {
+ var builder = new StringBuilder();
+ for (var row = 0; row < screen.Rows; row++)
+ {
+ builder.Clear();
+ for (var col = 0; col < screen.Cols; col++)
+ {
+ var cell = screen[row, col];
+ if (cell.Width == CellWidth.SpacerTail)
+ {
+ continue;
+ }
+
+ builder.Append(cell.Value.ToString());
+ }
+
+ if (builder.ToString().Contains(text, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // ---- c / p 复制(按消息项类型取文本) ----
+
+ /// c — 复制选中用户消息全文(Text)+ 复制后不退出导航(选中保持,继续可移动)。
+ [Fact]
+ public void Copy_c_CopiesSelectedUserText_AndStaysNavigating()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(Char('c'));
+
+ Assert.Single(copied);
+ Assert.Equal("q2", copied[0]);
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ }
+
+ /// c — 导航到助手消息后复制其全文(Text)。
+ [Fact]
+ public void Copy_c_CopiesAssistantText()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('c'));
+
+ Assert.Single(copied);
+ Assert.Equal("a1", copied[0]);
+ }
+
+ /// c — 工具结果消息复制其全文(Text)。
+ [Fact]
+ public void Copy_c_CopiesToolResultText()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolResultItem("结果正文"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('c'));
+
+ Assert.Single(copied);
+ Assert.Equal("结果正文", copied[0]);
+ }
+
+ /// p — 工具调用消息复制主参数(args — 渲染行无损提取,基准 PRIMARY_INPUT 本地简化)。
+ [Fact]
+ public void Copy_p_CopiesToolCallArgs()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolCallItem("Read", "src/main.rs"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('p'));
+
+ Assert.Single(copied);
+ Assert.Equal("src/main.rs", copied[0]);
+ }
+
+ /// c — 工具调用消息复制全文(工具名 + 括号参数 — 渲染行剥 ● 前缀形态)。
+ [Fact]
+ public void Copy_c_CopiesToolCallToolNameAndArgs()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolCallItem("Read", "src/main.rs"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('c'));
+
+ Assert.Single(copied);
+ Assert.Equal("Read(src/main.rs)", copied[0]);
+ }
+
+ /// p — 用户/助手/工具结果消息主参数 = 全文(Text)。
+ [Fact]
+ public void Copy_p_CopiesPlainMessageText()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('p'));
+
+ Assert.Single(copied);
+ Assert.Equal("a1", copied[0]);
+ }
+
+ /// diff — p 复制 Summary;c 复制 Summary + 补丁全量(hunk 头 + +/- 前缀行)。
+ [Fact]
+ public void Copy_Diff_pSummary_cFullPatch()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), CreateDiff(), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('p'));
+ Assert.Single(copied);
+ Assert.Equal("Edited 2 files", copied[0]);
+
+ shell.HandleKey(Char('c'));
+ Assert.Equal(2, copied.Count);
+ Assert.Equal("Edited 2 files\n@@ -1,2 +1,3 @@\n 上下文\n-旧行\n+新行", copied[1]);
+ }
+
+ /// c — Markdown 消息复制全文(块递归拼接:段落 + 代码块,块间换行)。
+ [Fact]
+ public void Copy_c_CopiesMarkdownBlocks()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), CreateMarkdown(), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+
+ shell.HandleKey(Char('c'));
+
+ Assert.Single(copied);
+ Assert.Equal("第一段\nvar x = 1;", copied[0]);
+ }
+
+ /// c/p — 选中不可复制类型(子代理分组)静默 no-op(不崩、不退出导航、无反馈消息)。
+ [Fact]
+ public void Copy_OnNonCopyableGroupItem_NoOpSilent()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new SubagentGroupItem(CreateGroup())]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Down());
+
+ shell.HandleKey(Char('c'));
+ shell.HandleKey(Char('p'));
+
+ Assert.Empty(copied);
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.Equal(-1, IndexOfFeedback(shell.View.Messages.Items));
+ }
+
+ /// 剪贴板委托未注入(null)— c/p 静默降级无操作(不崩、无反馈消息,对齐 CopyLastAssistantMessage 先例)。
+ [Fact]
+ public void Copy_WithoutClipboardDelegate_SilentNoOp()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1")], clipboard: false);
+ shell.SeedContent();
+ var countBeforeActionsBar = shell.View.Messages.Count;
+
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Char('c'));
+ shell.HandleKey(Char('p'));
+
+ Assert.Empty(copied);
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.Equal(countBeforeActionsBar + 1, shell.View.Messages.Count);
+ Assert.Equal(-1, IndexOfFeedback(shell.View.Messages.Items));
+ }
+
+ /// 复制反馈 — "已复制到剪贴板" 消息插到操作条之前(消息流末尾语义,操作条/spinner 尾部常驻保持)
+ /// + 反馈后继续导航与复制(选中索引不受反馈插入影响)。
+ [Fact]
+ public void Copy_AppendsFeedbackAboveActionBar_NavigationStillMoves()
+ {
+ var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Char('c'));
+
+ var items = shell.View.Messages.Items;
+ var feedbackIndex = IndexOfFeedback(items);
+ var barIndex = IndexOfActionBar(items);
+ Assert.True(feedbackIndex >= 0, "反馈消息应已追加");
+ Assert.True(barIndex > feedbackIndex, "反馈消息应在操作条之前");
+ Assert.IsType(items[^1]);
+
+ shell.HandleKey(Up());
+ Assert.Equal(2, shell.CursorNav.SelectedIndex);
+ shell.HandleKey(Char('p'));
+ Assert.Equal(2, copied.Count);
+ Assert.Equal("a1", copied[1]);
+ }
+
+ // ---- 底部操作条挂载 / 卸载 / 渲染 / 导航语义 ----
+
+ /// 操作条挂载 — 导航态激活挂到 spinner 之前(spinner 恒最后,操作条为倒数第二项);
+ /// Esc 退出后卸载(列表复原不含操作条项)。
+ [Fact]
+ public void ActionBar_MountsBeforeSpinnerWhileNavigating_UnmountsOnExit()
+ {
+ var (shell, _) = CreateShell([new UserMessageItem("q1")]);
+ shell.SeedContent();
+
+ shell.HandleKey(ShiftUp());
+ var items = shell.View.Messages.Items;
+ Assert.True(IndexOfActionBar(items) >= 0);
+ Assert.IsType(items[^2]);
+ Assert.IsType(items[^1]);
+
+ shell.HandleKey(Esc());
+ Assert.Equal(-1, IndexOfActionBar(shell.View.Messages.Items));
+ }
+
+ /// 操作条渲染 — 导航态激活期间帧内含键位提示(enter 展开 …);Esc 退出后不再渲染。
+ [Fact]
+ public void ActionBar_RendersHintsWhileNavigating_DisappearsAfterExit()
+ {
+ var (shell, _) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1")]);
+ shell.SeedContent();
+
+ shell.HandleKey(ShiftUp());
+ shell.RenderAndFlush();
+ shell.RenderAndFlush();
+ Assert.True(ScreenContains(shell.BackBuffer!, "enter 展开"));
+ Assert.True(ScreenContains(shell.BackBuffer!, "c 复制"));
+ Assert.True(ScreenContains(shell.BackBuffer!, "p 复制参数"));
+ Assert.True(ScreenContains(shell.BackBuffer!, "esc 返回"));
+
+ shell.HandleKey(Esc());
+ shell.RenderAndFlush();
+ shell.RenderAndFlush();
+ Assert.False(ScreenContains(shell.BackBuffer!, "enter 展开"));
+ }
+
+ /// 操作条不参与导航 — Ctrl+↓ 跳末条可导航消息不落到操作条(非 ICursorNavigable)。
+ [Fact]
+ public void ActionBar_DoesNotParticipateInNavigation()
+ {
+ var (shell, _) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Up());
+ Assert.Equal(2, shell.CursorNav.SelectedIndex);
+
+ shell.HandleKey(CtrlDown());
+ Assert.Equal(3, shell.CursorNav.SelectedIndex);
+ Assert.IsNotType(shell.View.Messages.Items[shell.CursorNav.SelectedIndex]);
+ }
+
+ /// 两级 Esc — 有展开组时第一次 Esc 仅收组(导航保持、操作条保持);第二次 Esc 退出并卸载操作条。
+ [Fact]
+ public void ActionBar_KeptWhileCollapsingGroup_RemovedOnSecondEscape()
+ {
+ var group = CreateGroup();
+ var (shell, _) = CreateShell([new UserMessageItem("q1"), new SubagentGroupItem(group)]);
+ shell.SeedContent();
+ shell.HandleKey(ShiftUp());
+ shell.HandleKey(Down());
+ shell.HandleKey(Enter());
+ Assert.True(group.IsExpanded);
+
+ shell.HandleKey(Esc());
+ Assert.False(group.IsExpanded);
+ Assert.True(shell.CursorNav.IsCursorActive);
+ Assert.True(IndexOfActionBar(shell.View.Messages.Items) >= 0);
+
+ shell.HandleKey(Esc());
+ Assert.False(shell.CursorNav.IsCursorActive);
+ Assert.Equal(-1, IndexOfActionBar(shell.View.Messages.Items));
+ }
+
+ /// 操作条键位提示文案 — 间隔符 · 与全链键位(对齐基准 messageActions 底部提示条形态)。
+ [Fact]
+ public void ActionBar_HintsText_UsesMiddotSeparatedFullKeySet()
+ {
+ Assert.Equal("enter 展开 · c 复制 · p 复制参数 · ↑↓ 导航 · esc 返回", MessageActionsBar.HintsText);
+ }
+}
From 3b3ac8dbb5b74314ac12ac70e9d602cf77b8362a Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 05:52:07 +0800
Subject: [PATCH 2/4] =?UTF-8?q?docs:=20TASK064=20M8-4/5/6=20=E5=B9=B6?=
=?UTF-8?q?=E8=A1=8C=E6=89=B9=E6=AC=A1=E9=AA=8C=E6=94=B6=E8=A1=A8=E6=94=B6?=
=?UTF-8?q?=E5=8F=A3=20|=20=E5=86=B3=E7=AD=96:=20=E5=B9=B6=E8=A1=8C?=
=?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E6=96=87=E4=BB=B6=E9=9D=A2=E4=BA=92?=
=?UTF-8?q?=E6=96=A5=E5=88=86=E5=B7=A5=E6=A8=A1=E5=BC=8F=E5=AE=9E=E8=AF=81?=
=?UTF-8?q?=E6=9C=89=E6=95=88(=E4=B8=89=E4=BB=A3=E7=90=86=E5=90=8C?=
=?UTF-8?q?=E6=97=B6=E6=8E=A8=E8=BF=9B+=E4=B8=BB=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E4=BD=93=E6=94=B6=E7=BC=96,=E5=8D=95=E6=89=B9=E6=AC=A1?=
=?UTF-8?q?=E4=BA=A4=E4=BB=981509=E8=A1=8C)=E2=80=94=E2=80=94=E5=90=8E?=
=?UTF-8?q?=E7=BB=AD=20M8-7/M8-8=20=E6=B2=BF=E7=94=A8;=20M8-7=20=E5=BC=95?=
=?UTF-8?q?=E6=93=8E=E4=BE=A7=E8=B0=83=E7=A0=94=E7=BB=93=E8=AE=BA=3Dcontex?=
=?UTF-8?q?t=5Fusage=20=E6=96=B0=E4=BA=8B=E4=BB=B6+ChatUsageProcessor=20?=
=?UTF-8?q?=E5=8F=91=E5=B0=84(=E5=B0=8F-=E4=B8=AD),=20M8-8=3Dbackground=5F?=
=?UTF-8?q?task=20=E5=BF=AB=E7=85=A7+=E7=BB=88=E6=80=81=E6=B7=B7=E5=90=88?=
=?UTF-8?q?=E5=8D=8F=E8=AE=AE+=E5=AE=BF=E4=B8=BB=E5=B1=82=E8=AE=A2?=
=?UTF-8?q?=E9=98=85=E7=BB=95=E5=BC=80=E5=9B=9E=E5=90=88=E4=BD=9C=E7=94=A8?=
=?UTF-8?q?=E5=9F=9F=E9=80=9A=E9=81=93(=E4=B8=AD),=20confirm/prompt=20?=
=?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=9B=9E=E8=B0=83=E7=99=BB=E8=AE=B0=E7=8B=AC?=
=?UTF-8?q?=E7=AB=8B=E4=BB=BB=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...45\267\256\350\267\235\350\247\204\345\210\222.md" | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md"
index e61cb47a7..1402a5336 100644
--- "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md"
+++ "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md"
@@ -1,6 +1,6 @@
# TASK064 — TUI 对齐 Claude Code 下一批差距(M8 规划)
-> 状态:**M8-1/2/3(P0 三连)已 implemented**(2026-10-22,提交链 689f830b3 → 423187e9d → aef776136);M8-4..M8-8 + P3 池待做
+> 状态:**M8-1..M8-6 已 implemented**(2026-10-22;P0 三连 689f830b3→423187e9d→aef776136,M8-4/5/6 并行子代理批次 f59a6a99e);M8-7/M8-8 + P3 池待做(引擎侧调研已完成:context_usage 小-中 / background_task 快照+终态 中)
> 状态:proposed(2026-10-22;PR #446 合并后双调研挖掘——native_tui 现状侧 + 基准源码侧)
> 来源:用户指令"挖掘还有什么没对齐 Claude Code TUI 功能的,构建新规划"
> 双调研证据:①native_tui 逐文件实证(InProcessEventConsumer/ReplShell/ReplBridgeDriver 等)②基准源码逐项核实(claude-code-rev-main/src,文件:行号 见各项)
@@ -85,6 +85,15 @@
| / 搜索 | ITextMatchable 七消息项 + Enter 跳匹配(环形)+ Esc 清 | ✅ | ✅ 查询累积/footer 显示/跳转 ScrollTop 断言/Esc 清除/退模态 |
| 语义演进回归 | ctrl+o 单条→全局(旧测试同步全局断言) | ✅ | ✅ Core 853 全绿(含 StructuredMessageTests 更新) |
+**✅ M8-4/5/6 并行子代理批次实施收口(2026-10-22,f59a6a99e)**:三子代理文件面互斥分工 + 主智能体收编 ReplShell 落地件。
+
+| 批次 | 基建实现 | 已实现 | 已验收 |
+|------|---------|--------|--------|
+| M8-4 | CollapsedToolItem 替换式聚合(连续 Read/Grep/Glob 第二次起原位折叠 + 最新操作⎿提示行)+ DiffResultItem ToggleExpand + transcript verbose 态 Diff 全展开 | ✅ | ✅ 20 新测试(聚合 9 + 折叠 4 + 渲染 7)|
+| M8-5 | TuiCommandCallbacks 延迟绑定容器 + clearScreen/onExitRequested 全链 + ReplShell 清屏/退出公开入口 + RenderAndFlush 2J+3J 消费块 + Csi.EraseSavedLines | ✅ | ✅ 6 新测试(回调触发/反馈上屏/降级契约);confirm/prompt 登记遗留(TUI 命令确认模态独立任务)|
+| M8-6 | 导航态 c 复制全文/p 复制主参数 + MessageActionsBar 底部操作条(滚动区尾部常驻项模式)| ✅ | ✅ 16 新测试 |
+| 批次回归 | — | ✅ | ✅ Core 889 + Host.Tests 74 + host.tests 1382 + selftest 真跑 EXIT=0 |
+
### M8-4 工具折叠交互统一 + Read N files 聚合(P1,依赖:M8-3——展开消费 transcript/局部展开双通道)
- Ctrl+O 展开路由统一:thinking/diff/Bash/Write 折叠共享统一展开入口(消息级 ToggleExpand,不再只路由最后一条 thinking)
From 643a400e5a190c97c3a566eeaef4fa5427bc9c1e Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 06:08:49 +0800
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20M8-7=20=E4=B8=8A=E4=B8=8B=E6=96=87?=
=?UTF-8?q?=E4=BD=99=E9=87=8F=20footer=20=E5=91=8A=E8=AD=A6=E5=85=A8?=
=?UTF-8?q?=E9=93=BE=EF=BC=88=E5=BC=95=E6=93=8E+TUI=20=E5=8F=8C=E4=BE=A7?=
=?UTF-8?q?=E5=B9=B6=E8=A1=8C=E5=AD=90=E4=BB=A3=E7=90=86=EF=BC=89=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E5=8F=91=E5=B0=84=E7=82=B9=E8=90=BD=20Se?=
=?UTF-8?q?ssionController.ProcessEvent=20onDone(=E6=9D=83=E5=A8=81=20usag?=
=?UTF-8?q?e+consumer=20=E5=90=8C=E7=82=B9=E5=8F=AF=E8=BE=BE;ChatUsageProc?=
=?UTF-8?q?essor=20=E5=9C=A8=20ChatService=20=E4=B8=AD=E9=97=B4=E4=BB=B6?=
=?UTF-8?q?=E9=93=BE=E5=A4=96=E4=B8=94=20Singleton/=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E7=BA=A7=E4=BD=9C=E7=94=A8=E5=9F=9F=E9=94=99=E9=85=8D);=20IEve?=
=?UTF-8?q?ntConsumer.OnContextUsage=20DIM=20=E4=B8=89=E5=8F=82(promptToke?=
=?UTF-8?q?ns,contextWindowTokens,percentLeft)=E2=80=94=E2=80=94=E9=98=88?=
=?UTF-8?q?=E5=80=BC=E6=A0=87=E5=BF=97=E4=B8=8D=E8=BF=9B=E5=8D=8F=E8=AE=AE?=
=?UTF-8?q?=20UI=20=E8=87=AA=E5=88=A4=E9=A2=9C=E8=89=B2;=20ContextUsage=20?=
=?UTF-8?q?=E5=8D=8F=E8=AE=AE=E4=BA=8B=E4=BB=B6=E5=8F=8C=E9=95=9C=E5=83=8F?=
=?UTF-8?q?(context=5Fusage=20camelCase)+BridgeEventConsumer=20=E6=98=BE?=
=?UTF-8?q?=E5=BC=8F=E5=AE=9E=E7=8E=B0(DIM=20=E4=B8=8D=E5=8F=AF=E8=A7=81?=
=?UTF-8?q?=E5=9D=91=E9=98=B2=E5=BE=A1);=20TUI=20=E4=BE=A7=20ContextWarnin?=
=?UTF-8?q?g=20=E7=8B=AC=E7=AB=8B=E6=A7=BD=E4=BD=8D(=E6=8C=81=E7=BB=AD?=
=?UTF-8?q?=E6=80=81=E5=91=8A=E8=AD=A6=20vs=20Notification=20=E4=B8=B4?=
=?UTF-8?q?=E6=97=B6=E6=80=81=E8=AF=AD=E4=B9=89=E5=88=86=E7=A6=BB,?=
=?UTF-8?q?=E5=85=B1=E5=AD=98=E5=91=8A=E8=AD=A6=E4=BC=98=E5=85=88)+Resolve?=
=?UTF-8?q?ContextWarning=20=E4=B8=89=E8=89=B2(=E2=89=A410=20error=20?=
=?UTF-8?q?=E5=91=8A=E6=80=A5/=E2=89=A425=20warning=20=E4=B8=8D=E8=B6=B3/>?=
=?UTF-8?q?25=20=E6=B8=85=E7=A9=BA=E9=98=B2=E5=99=AA);=20promptTokens=3D0?=
=?UTF-8?q?=20=E4=B8=8D=E5=8F=91=E5=B0=84=E9=98=B2=20mock=20=E5=99=AA?=
=?UTF-8?q?=E9=9F=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../presentation/IEventConsumer.cs | 10 +++
.../engine/adapters/BridgeEventConsumer.cs | 5 ++
.../apps/engine/adapters/SessionController.cs | 23 ++++++
modules/apps/engine/bridge/BridgeProtocol.cs | 8 ++
.../NativeTui.Host/InProcessEventConsumer.cs | 6 ++
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 8 ++
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 11 +++
.../src/NativeTui.Core/repl/FooterBar.cs | 40 +++++++---
.../src/NativeTui.Core/repl/FooterState.cs | 34 ++++++++
.../InProcessEventConsumerTests.cs | 16 ++++
.../bridge/ReplBridgeDriverTests.cs | 70 ++++++++++++++++
.../repl/ReplComponentTests.cs | 66 +++++++++++++++
test/unit/host.tests/GlobalUsings.cs | 1 +
.../adapters/SessionControllerTests.cs | 80 +++++++++++++++++++
.../bridge/BridgeEventConsumerTests.cs | 12 +++
.../bridge/BridgeJsonContractTests.cs | 11 +++
16 files changed, 390 insertions(+), 11 deletions(-)
diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
index 1068bdb32..35b87dbea 100644
--- a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
+++ b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs
@@ -73,6 +73,16 @@ void OnSubagentToolUse(string subagentId, string toolName, string summary, int t
/// 完成态一次性校正优于逐活动增量 — 引擎数据流中 token 仅回合/完成粒度存在(RecordTokenUsage 亦完成时调用);
/// 0 = 无 usage 数据(FinalizePending 等兜底路径),消费方保持原累计值)
void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { }
+
+ ///
+ /// 上下文窗口余量上报(M8-7 — 每回合 Done 后消费侧 footer 告警显示;默认空实现,
+ /// 既有实现方零改动,默认实现须经接口类型调用才可见)
+ ///
+ /// 当前上下文窗口占用(回合 Done 权威 usage.PromptTokens 快照)
+ /// 上下文窗口大小(IContextWindowResolver 实时解析,缺省 200K)
+ /// 剩余百分比 0-100(引擎侧 ICompactService.CalculateWarningState 权威算法;
+ /// 阈值标志不进接口 — TUI 侧只显示百分比+文案,UI 自己判颜色)
+ void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { }
}
///
diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs
index 4ea8fce8b..23d721dc6 100644
--- a/modules/apps/engine/adapters/BridgeEventConsumer.cs
+++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs
@@ -93,6 +93,11 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal)
WriteEvent(new Cli.Bridge.BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal));
}
+ /// 上下文余量 → context_usage(M8-7 — 每回合 Done 后 footer 告警;显式实现:DIM 默认实现经类类型调用不可见)。
+ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) {
+ WriteEvent(new Cli.Bridge.BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft));
+ }
+
/// 思考结束结算(进入产出或回合结束时调用,幂等)。
private void EndThinkingIfActive() {
if (_thinkingStartMs is not long start)
diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs
index 0ce87479d..7398c8667 100644
--- a/modules/apps/engine/adapters/SessionController.cs
+++ b/modules/apps/engine/adapters/SessionController.cs
@@ -223,10 +223,33 @@ private void DrainSubAgentEvents(SubAgentEventChannel channel, StringBuilder ful
onDone: (usage, mid) => {
modelId = mid;
_consumer.OnDone(usage, mid);
+ EmitContextUsage(usage);
});
return modelId;
}
+ /// 缺省上下文窗口兜底(对齐 ChatContextManager.DefaultContextWindowResolver 的 200K)。
+ private const int DefaultContextWindowTokens = 200_000;
+
+ ///
+ /// 发射上下文余量事件(M8-7 — Done 权威 usage 快照口径):PromptTokens 即当前上下文窗口占用;
+ /// 窗口优先 IContextWindowResolver(不可达兜底 200K),percentLeft 走 ICompactService.CalculateWarningState
+ /// 权威阈值算法(不可达时朴素窗口百分比兜底)→ _consumer.OnContextUsage。
+ /// usage 为 null 或 PromptTokens=0(mock/无计量路径)不发射,防消费侧噪音。
+ ///
+ private void EmitContextUsage(TokenUsage? usage) {
+ if (usage is not { PromptTokens: > 0 })
+ return;
+
+ var promptTokens = usage.PromptTokens;
+ var contextWindowTokens = _serviceProvider?.GetService()?.ResolveCurrentContextWindow()
+ ?? DefaultContextWindowTokens;
+ var percentLeft = _serviceProvider?.GetService() is { } compact
+ ? compact.CalculateWarningState(promptTokens, contextWindowTokens).PercentLeft
+ : Math.Max(0, (int)Math.Round((contextWindowTokens - promptTokens) * 100.0 / contextWindowTokens));
+ _consumer.OnContextUsage(promptTokens, contextWindowTokens, percentLeft);
+ }
+
///
/// 子代理活动事件前置分流(DSG041 批1 — AgentId 非 null 即子代理事件,路由到三子代理回调):
/// AgentStarted → OnSubagentStarted(身份三元组)、AgentFinished → OnSubagentFinished(成败)、
diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs
index 5fb52c949..d8738446a 100644
--- a/modules/apps/engine/bridge/BridgeProtocol.cs
+++ b/modules/apps/engine/bridge/BridgeProtocol.cs
@@ -44,6 +44,7 @@ public sealed record Quit() : BridgeRequest;
[JsonDerivedType(typeof(BridgeEvent.SubagentStarted), "subagent_started")]
[JsonDerivedType(typeof(BridgeEvent.SubagentToolUse), "subagent_tool_use")]
[JsonDerivedType(typeof(BridgeEvent.SubagentFinished), "subagent_finished")]
+[JsonDerivedType(typeof(BridgeEvent.ContextUsage), "context_usage")]
[JsonDerivedType(typeof(BridgeEvent.TurnCompleted), "turn_completed")]
[JsonDerivedType(typeof(BridgeEvent.TurnInterrupted), "turn_interrupted")]
[JsonDerivedType(typeof(BridgeEvent.Error), "error")]
@@ -132,6 +133,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string
/// 完成态权威校正 TASK062 P7 — 与 native_tui 侧逐字段镜像)。
public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent;
+ ///
+ /// 上下文窗口余量(M8-7 — 每回合 Done 后 TUI footer 告警显示;与 native_tui 侧镜像)。
+ /// PromptTokens = 回合权威 usage.PromptTokens;PercentLeft 0-100(引擎侧阈值算法口径),
+ /// 阈值标志不进协议 — UI 侧自判颜色文案。
+ ///
+ public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent;
+
/// 完整助手消息(追加滚动区)。
public sealed record AssistantMessage(string Text) : BridgeEvent;
diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
index a896b9f7c..a5deee01e 100644
--- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
+++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
@@ -125,6 +125,12 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal)
_events.Enqueue(new BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal));
}
+ /// 上下文余量 → ContextUsage(M8-7 TASK064 P1 — 引擎回调三参直传内存事件;
+ /// percentLeft 引擎侧算好,ReplBridgeDriver 路由 footer 告警。接口同步由引擎侧 IEventConsumer DIM 完成)。
+ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) {
+ _events.Enqueue(new BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft));
+ }
+
/// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。
public IReadOnlyList DrainEvents() {
List? drained = null;
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index 85cde6f9a..186bccb4b 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -22,6 +22,7 @@ namespace NativeTui.Core.Bridge;
[JsonDerivedType(typeof(SubagentStarted), "subagent_started")]
[JsonDerivedType(typeof(SubagentToolUse), "subagent_tool_use")]
[JsonDerivedType(typeof(SubagentFinished), "subagent_finished")]
+[JsonDerivedType(typeof(ContextUsage), "context_usage")]
[JsonDerivedType(typeof(Error), "error")]
public abstract record BridgeEvent
{
@@ -103,6 +104,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string
/// 子代理完成 — 条目定格(isError 聚合组头 ● 红;基准基准 agent 完成态 Done)。
public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent;
+ ///
+ /// 上下文余量(M8-7 TASK064 P1 — 引擎回合粒度上报,footer 告警数据源):
+ /// PromptTokens 当前回合已耗 / ContextWindowTokens 窗口总量 / PercentLeft 剩余百分比
+ /// (引擎侧算好 — TUI 侧零计算直路由:≤10% 告急 error、≤25% 不足 warning、>25% 清空防噪音)。
+ ///
+ public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent;
+
/// 错误(含诱导说明)。
public sealed record Error(string Message) : BridgeEvent;
}
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index 7bad073e4..a29ebfd6f 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -164,6 +164,9 @@ public void Apply(BridgeEvent bridgeEvent)
_spinner.HasActiveTools = false;
}
+ break;
+ case BridgeEvent.ContextUsage(_, _, var percentLeft):
+ ApplyContextUsage(percentLeft);
break;
case BridgeEvent.Error(var message):
EndToolChain();
@@ -187,6 +190,14 @@ private void ApplySubagentStarted(string subagentId, string agentType, string de
_spinner.HasActiveTools = true;
}
+ /// 上下文余量路由(M8-7)— percentLeft 阈值形态写入 footer 告警槽
+ /// (≤10% 告急 error / ≤25% 不足 warning / >25% 清空防噪音;独立于 Notification 临时通知槽)。
+ private void ApplyContextUsage(int percentLeft)
+ {
+ var (text, level) = FooterState.ResolveContextWarning(percentLeft);
+ _view.Footer = _view.Footer with { ContextWarning = text, ContextWarningLevel = level };
+ }
+
/// ToolCall 路由(M8-4 聚合)— 同名可聚合链上合并(已聚合 Record / 未聚合替换链头),
/// 不同名或非聚合工具断链后独立上屏(单次调用保持 ToolCallItem 类型 — 视觉与断言面零变化)。
private void ApplyToolCall(string name, string args)
diff --git a/native_tui/src/NativeTui.Core/repl/FooterBar.cs b/native_tui/src/NativeTui.Core/repl/FooterBar.cs
index af135da76..a9198e137 100644
--- a/native_tui/src/NativeTui.Core/repl/FooterBar.cs
+++ b/native_tui/src/NativeTui.Core/repl/FooterBar.cs
@@ -6,25 +6,43 @@ namespace NativeTui.Core.Repl;
/// + busy dim ` · esc 打断`(U+00B7)/ 非 busy 且有 agents 提示 dim ` · {AgentsHint}`;
/// 标准模式(引擎默认 auto)dim `? 查看快捷键`(busy 时 `esc 打断`);
/// 行 1 右侧 Notifications 登录错误(error 色右对齐);
-/// 行 2 右侧通知(如 effort `◐ 中等 · /effort`,dim 右对齐)。
+/// 行 2 右侧通知(如 effort `◐ 中等 · /effort`,dim 右对齐)/ 上下文余量告警(M8-7 —
+/// warning/error 色右对齐,与通知共存时告警优先)。
/// 注意:paddingX=2 留白由 REPL 组装层负责(frame.X 从 col 2 起),本组件只渲染内容。
///
public sealed class FooterBar
{
- /// footer 高度:Notification 非空时 2 行(行 2 右对齐通知),否则 1 行。
- public int MeasureHeight(FooterState state) => state.Notification is null ? 1 : 2;
+ /// footer 高度:Notification 或 ContextWarning 非空时 2 行(行 2 右对齐),否则 1 行。
+ public int MeasureHeight(FooterState state) => state.Notification is null && state.ContextWarning is null ? 1 : 2;
/// 渲染状态栏(frame 高度须 ≥ MeasureHeight)。
public void Render(Screen screen, Rect frame, Theme theme, FooterState state)
{
RenderLeftSide(screen, frame with { Height = 1 }, theme, state);
- RenderRightAligned(screen, frame.Y, frame, theme, state.LoginError, error: true);
- if (state.Notification is not null)
+ RenderRightAligned(screen, frame.Y, frame, state.LoginError, theme.Error);
+ RenderSecondRow(screen, frame, theme, state);
+ }
+
+ /// 行 2 右对齐:上下文余量告警(M8-7 三色 — Critical error 色 / Warning warning 色)优先于一般通知(dim);
+ /// 共存时只渲染告警(持续告警语义 — 通知由 12s 超时自然消亡,告警持续到余量回升)。
+ private static void RenderSecondRow(Screen screen, Rect frame, Theme theme, FooterState state)
+ {
+ if (state.ContextWarning is not null)
{
- RenderRightAligned(screen, frame.Y + 1, frame, theme, state.Notification, error: false);
+ RenderRightAligned(screen, frame.Y + 1, frame, state.ContextWarning, ContextWarningColor(theme, state.ContextWarningLevel));
+ return;
}
+
+ RenderRightAligned(screen, frame.Y + 1, frame, state.Notification, null);
}
+ /// 告警级别 → 前景色(None 防御性回落 warning 色 — Driver 保证 text null ⟺ level None)。
+ private static Color ContextWarningColor(Theme theme, ContextAlertLevel level) => level switch
+ {
+ ContextAlertLevel.Critical => theme.Error,
+ _ => theme.Warning,
+ };
+
/// 行 1 左侧:模式指示(标准模式快捷键提示 / 激活模式串 + 尾缀提示)。
private static void RenderLeftSide(Screen screen, Rect frame, Theme theme, FooterState state)
{
@@ -46,17 +64,17 @@ private static void RenderLeftSide(Screen screen, Rect frame, Theme theme, Foote
}
}
- /// 右对齐写入一行文本(error 色或 dim,右端贴 frame.Right)。
- private static void RenderRightAligned(Screen screen, int row, Rect frame, Theme theme, string? text, bool error)
+ /// 右对齐写入一行文本(foreground 前景色 / null = dim,右端贴 frame.Right)。
+ private static void RenderRightAligned(Screen screen, int row, Rect frame, string? text, Color? foreground)
{
if (text is null)
{
return;
}
- var style = error
- ? screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Error })
- : screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ var style = screen.Styles.GetOrAdd(foreground is { } color
+ ? new TextStyle { Foreground = color }
+ : new TextStyle { Dim = true });
var width = RuneWidth.OfText(text);
var col = Math.Max(frame.X, frame.Right - width);
screen.WriteText(row, col, text, style, frame.Right);
diff --git a/native_tui/src/NativeTui.Core/repl/FooterState.cs b/native_tui/src/NativeTui.Core/repl/FooterState.cs
index 7e0f79a27..ae1490e5b 100644
--- a/native_tui/src/NativeTui.Core/repl/FooterState.cs
+++ b/native_tui/src/NativeTui.Core/repl/FooterState.cs
@@ -23,4 +23,38 @@ public sealed record FooterState
/// 右侧通知(行 2 右对齐 dim,如 "◐ medium · /effort";非空时 footer 占 2 行)。
public string? Notification { get; init; }
+
+ ///
+ /// 上下文余量告警文本(M8-7 TASK064 P1 — 行 2 右对齐,色由 ContextWarningLevel 决定;null = 无告警)。
+ /// 独立槽位于 Notification:通知是临时态(effort 12s 超时/transcript 提示,ReplShell 覆盖管理),
+ /// 告警是持续态(直到余量回升 >25% 或 /compact)— 互不覆盖;共存时告警优先渲染。
+ ///
+ public string? ContextWarning { get; init; }
+
+ /// 上下文余量告警级别(决定 ContextWarning 渲染色 — None 时不渲染)。
+ public ContextAlertLevel ContextWarningLevel { get; init; }
+
+ ///
+ /// percentLeft → 告警形态(TokenWarning.tsx:166-177 汉化):≤10% 告急(error 色)/ ≤25% 不足(warning 色)/
+ /// >25% 清空(基准 auto-compact 常态显示 N% 简化为告警区才显示 — 防噪音)。
+ ///
+ public static (string? Text, ContextAlertLevel Level) ResolveContextWarning(int percentLeft) => percentLeft switch
+ {
+ <= 10 => ($"上下文告急 ({percentLeft}%) · 运行 /compact", ContextAlertLevel.Critical),
+ <= 25 => ($"上下文不足 ({percentLeft}%) · 运行 /compact", ContextAlertLevel.Warning),
+ _ => (null, ContextAlertLevel.None),
+ };
+}
+
+/// 上下文余量告警级别(M8-7 — 三色形态选色键:告急 error / 警告 warning / 无 None)。
+public enum ContextAlertLevel
+{
+ /// 无告警(余量 >25% 安全区 — 不显示防噪音)。
+ None = 0,
+
+ /// 警告(余量 ≤25% — warning 色 `上下文不足 (N%) · 运行 /compact`)。
+ Warning = 1,
+
+ /// 告急(余量 ≤10% — error 色 `上下文告急 (N%) · 运行 /compact`)。
+ Critical = 2,
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
index 6495621b4..71b4d8c38 100644
--- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
@@ -302,4 +302,20 @@ public void MessageId_IncrementsAcrossTurns_NoThinkingDefaults()
Assert.All(all, m => Assert.Equal(0, m.ThinkingDurationMs));
Assert.All(all, m => Assert.Null(m.ThinkingContent));
}
+
+ /// M8-7 上下文余量 — OnContextUsage 三参直传 ContextUsage 内存事件
+ /// (percentLeft 引擎侧算好,TUI 侧零计算直路由 footer 告警)。
+ [Fact]
+ public void OnContextUsage_EnqueuesContextUsageEvent()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnContextUsage(180_000, 200_000, 8);
+
+ var events = consumer.DrainEvents();
+ var usage = Assert.IsType(Assert.Single(events));
+ Assert.Equal(180_000, usage.PromptTokens);
+ Assert.Equal(200_000, usage.ContextWindowTokens);
+ Assert.Equal(8, usage.PercentLeft);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
index ef00a91ff..8abe4e9d0 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
@@ -500,4 +500,74 @@ public void Apply_GrepConsecutiveCalls_AggregatesWithSearchVerb()
Assert.Equal("搜索 2 次", collapsed.SummaryText);
Assert.Equal("FIXME", collapsed.LastArgs);
}
+
+ /// M8-7 上下文余量 — percentLeft 阈值路由(TokenWarning.tsx:166-177 汉化):
+ /// ≤10% 告急(Critical + error 文案)、≤25% 不足(Warning + warning 文案)、>25% 清空(防噪音)。
+ [Fact]
+ public void Apply_ContextUsage_CriticalThreshold_SetsCriticalWarning()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8));
+
+ Assert.Equal("上下文告急 (8%) · 运行 /compact", _view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.Critical, _view.Footer.ContextWarningLevel);
+ }
+
+ [Fact]
+ public void Apply_ContextUsage_AtBoundary10_IsCritical()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 10));
+
+ Assert.Equal("上下文告急 (10%) · 运行 /compact", _view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.Critical, _view.Footer.ContextWarningLevel);
+ }
+
+ [Fact]
+ public void Apply_ContextUsage_WarningThreshold_SetsWarning()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(150_000, 200_000, PercentLeft: 20));
+
+ Assert.Equal("上下文不足 (20%) · 运行 /compact", _view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.Warning, _view.Footer.ContextWarningLevel);
+ }
+
+ [Fact]
+ public void Apply_ContextUsage_AtBoundary25_IsWarning()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(150_000, 200_000, PercentLeft: 25));
+
+ Assert.Equal("上下文不足 (25%) · 运行 /compact", _view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.Warning, _view.Footer.ContextWarningLevel);
+ }
+
+ [Fact]
+ public void Apply_ContextUsage_AboveThreshold_ClearsWarning()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8));
+
+ _driver.Apply(new BridgeEvent.ContextUsage(90_000, 200_000, PercentLeft: 55));
+
+ Assert.Null(_view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.None, _view.Footer.ContextWarningLevel);
+ }
+
+ [Fact]
+ public void Apply_ContextUsage_AtBoundary26_IsCleared()
+ {
+ _driver.Apply(new BridgeEvent.ContextUsage(90_000, 200_000, PercentLeft: 26));
+
+ Assert.Null(_view.Footer.ContextWarning);
+ Assert.Equal(ContextAlertLevel.None, _view.Footer.ContextWarningLevel);
+ }
+
+ /// 告警独立槽位 — 不触碰 Notification(临时通知语义由 ReplShell 管理,互不覆盖)。
+ [Fact]
+ public void Apply_ContextUsage_DoesNotTouchNotification()
+ {
+ _view.Footer = _view.Footer with { Notification = "◐ 中等 · /effort" };
+
+ _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8));
+
+ Assert.Equal("◐ 中等 · /effort", _view.Footer.Notification);
+ Assert.Equal("上下文告急 (8%) · 运行 /compact", _view.Footer.ContextWarning);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs
index 4930dc1c0..56781c248 100644
--- a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs
@@ -297,6 +297,72 @@ public void MeasureHeight_TwoRowsOnlyWithNotification()
Assert.Equal(1, footer.MeasureHeight(new FooterState { Mode = PermissionMode.Auto }));
Assert.Equal(2, footer.MeasureHeight(new FooterState { Notification = "◐ medium · /effort" }));
}
+
+ /// M8-7 上下文余量告警 — 行2 右对齐三色形态(TokenWarning.tsx:166-177 汉化):
+ /// Warning 级 warning 色 / Critical 级 error 色;与 Notification 共存时告警优先(持续告警语义 > 临时通知)。
+ [Fact]
+ public void ContextWarning_WarningLevel_SecondRowRightAlignedWarningColor()
+ {
+ using var screen = new Screen(cols: 60, rows: 2);
+ var footer = new FooterBar();
+ footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState
+ {
+ Mode = PermissionMode.Auto,
+ ContextWarning = "上下文不足 (20%) · 运行 /compact",
+ ContextWarningLevel = ContextAlertLevel.Warning,
+ });
+ var warning = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Warning });
+ // 文本 32 屏幕列(中文 7 字 ×2 + "(20%) · " 9 + "/compact" 8 + 空格)→ 起始 col 28(Right=60)
+ Assert.Equal("上下文不足 (20%) · 运行 /compact", RowText.Of(screen, 1, 28, 32));
+ Assert.Equal(warning, screen[1, 28].StyleId);
+ Assert.Equal(warning, screen[1, 59].StyleId);
+ }
+
+ [Fact]
+ public void ContextWarning_CriticalLevel_SecondRowRightAlignedErrorColor()
+ {
+ using var screen = new Screen(cols: 60, rows: 2);
+ var footer = new FooterBar();
+ footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState
+ {
+ Mode = PermissionMode.Auto,
+ ContextWarning = "上下文告急 (8%) · 运行 /compact",
+ ContextWarningLevel = ContextAlertLevel.Critical,
+ });
+ var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error });
+ // 文本 31 屏幕列(中文 7 字 ×2 + "(8%) · " 8 + "运行" 4 + "/compact" 8 + 空格 1)→ 起始 col 29
+ Assert.Equal("上下文告急 (8%) · 运行 /compact", RowText.Of(screen, 1, 29, 31));
+ Assert.Equal(error, screen[1, 29].StyleId);
+ Assert.Equal(error, screen[1, 59].StyleId);
+ }
+
+ /// 告警与通知同为行2 右对齐 — 同时非空时只渲染告警(Notification 由 12s 超时自然消亡,告警持续到余量回升)。
+ [Fact]
+ public void ContextWarning_OverridesNotificationWhenBothSet()
+ {
+ using var screen = new Screen(cols: 60, rows: 2);
+ var footer = new FooterBar();
+ footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState
+ {
+ Mode = PermissionMode.Auto,
+ Notification = "◐ 中等 · /effort",
+ ContextWarning = "上下文告急 (8%) · 运行 /compact",
+ ContextWarningLevel = ContextAlertLevel.Critical,
+ });
+ var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error });
+ Assert.Equal("上下文告急 (8%) · 运行 /compact", RowText.Of(screen, 1, 29, 31));
+ // 通知文本不出现在行2(被告警优先占位)
+ Assert.DoesNotContain("◐", RowText.Of(screen, 1, 0, 29));
+ Assert.Equal(error, screen[1, 29].StyleId);
+ }
+
+ [Fact]
+ public void MeasureHeight_TwoRowsWithContextWarning()
+ {
+ var footer = new FooterBar();
+ Assert.Equal(2, footer.MeasureHeight(new FooterState { ContextWarning = "上下文不足 (20%) · 运行 /compact" }));
+ Assert.Equal(1, footer.MeasureHeight(new FooterState { Mode = PermissionMode.Auto }));
+ }
}
/// 行文本提取辅助(golden 断言用)。
diff --git a/test/unit/host.tests/GlobalUsings.cs b/test/unit/host.tests/GlobalUsings.cs
index e7437fafa..1f9195171 100644
--- a/test/unit/host.tests/GlobalUsings.cs
+++ b/test/unit/host.tests/GlobalUsings.cs
@@ -12,6 +12,7 @@
global using Core.Configuration;
global using Core.Configuration.Providers;
global using Core.Context;
+global using Core.Context.Compact;
global using Core.Context.Modality;
global using Core.CostTracking;
global using Core.DependencyInjection;
diff --git a/test/unit/host.tests/adapters/SessionControllerTests.cs b/test/unit/host.tests/adapters/SessionControllerTests.cs
index b6a20f348..d92a84e80 100644
--- a/test/unit/host.tests/adapters/SessionControllerTests.cs
+++ b/test/unit/host.tests/adapters/SessionControllerTests.cs
@@ -244,6 +244,84 @@ public void Stop_SetsIsRunningFalse() {
Assert.False(controller.IsRunning);
}
+ /// Done 携带真实 usage → 发射 OnContextUsage(M8-7 — 权威算法口径:
+ /// IContextWindowResolver 窗口 + ICompactService.CalculateWarningState.PercentLeft)。
+ [Fact]
+ public async Task StreamResponseAsync_DoneWithUsage_EmitsContextUsageViaCompactAlgorithm() {
+ var compact = new Mock();
+ compact.Setup(c => c.CalculateWarningState(150_000, 200_000))
+ .Returns(new CompactWarningState {
+ PercentLeft = 37,
+ IsAboveWarningThreshold = false,
+ IsAboveErrorThreshold = false,
+ IsAboveAutoCompactThreshold = false,
+ IsAtBlockingLimit = false,
+ IsAboveSoftCompactThreshold = false,
+ });
+ var resolver = new Mock();
+ resolver.Setup(r => r.ResolveCurrentContextWindow()).Returns(200_000);
+ var services = new ServiceCollection();
+ services.AddSingleton(compact.Object);
+ services.AddSingleton(resolver.Object);
+ var sp = services.BuildServiceProvider();
+
+ var events = new List {
+ ChatStreamEvent.Text("ok"),
+ ChatStreamEvent.Done(usage: new TokenUsage(150_000, 500)),
+ };
+ var chatService = CreateMockChatService(events);
+ var consumer = new RecordingEventConsumer();
+ var turnDiffService = new TurnDiffService();
+ var controller = new SessionController(chatService, consumer, turnDiffService, "test-session", sp);
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ var usage = Assert.Single(consumer.ContextUsages);
+ Assert.Equal(150_000, usage.promptTokens);
+ Assert.Equal(200_000, usage.contextWindowTokens);
+ Assert.Equal(37, usage.percentLeft);
+ }
+
+ /// Done 携带 usage 但解析器/压缩服务不可达(无 sp)→ 朴素兜底(200K 窗口 + 窗口百分比)。
+ [Fact]
+ public async Task StreamResponseAsync_DoneWithUsage_NoServices_FallsBackToNaivePercent() {
+ var events = new List {
+ ChatStreamEvent.Text("ok"),
+ ChatStreamEvent.Done(usage: new TokenUsage(100_000, 200)),
+ };
+ var chatService = CreateMockChatService(events);
+ var consumer = new RecordingEventConsumer();
+ var turnDiffService = new TurnDiffService();
+ var controller = new SessionController(chatService, consumer, turnDiffService, "test-session");
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ var usage = Assert.Single(consumer.ContextUsages);
+ Assert.Equal(100_000, usage.promptTokens);
+ Assert.Equal(200_000, usage.contextWindowTokens);
+ Assert.Equal(50, usage.percentLeft);
+ }
+
+ /// Done 无 usage(mock/无计量路径)→ 不发射 OnContextUsage(防噪音)。
+ [Fact]
+ public async Task StreamResponseAsync_DoneWithoutUsage_DoesNotEmitContextUsage() {
+ var events = new List {
+ ChatStreamEvent.Text("ok"),
+ ChatStreamEvent.Done(),
+ };
+ var chatService = CreateMockChatService(events);
+ var consumer = new RecordingEventConsumer();
+ var turnDiffService = new TurnDiffService();
+ var controller = new SessionController(chatService, consumer, turnDiffService, "test-session");
+
+ var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.True(result.Succeeded);
+ Assert.Empty(consumer.ContextUsages);
+ }
+
[Fact]
public async Task StreamResponseAsync_MainAgentPath_CallsSaveContext() {
var queryEngine = CreateMockQueryEngine();
@@ -376,6 +454,7 @@ private sealed class RecordingEventConsumer : IEventConsumer {
public List<(int triggerCount, int loopStartIndex, string? repeatedPattern)> LoopDetectedEvents { get; } = [];
public List TimingSummaryEvents { get; } = [];
public List<(TokenUsage? usage, string? modelId)> DoneEvents { get; } = [];
+ public List<(int promptTokens, int contextWindowTokens, int percentLeft)> ContextUsages { get; } = [];
public void OnText(string content) => TextEvents.Add(content);
public void OnThinking(string thinking) => ThinkingEvents.Add(thinking);
@@ -385,5 +464,6 @@ private sealed class RecordingEventConsumer : IEventConsumer {
public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeatedPattern) => LoopDetectedEvents.Add((triggerCount, loopStartIndex, repeatedPattern));
public void OnTimingSummary(string summary) => TimingSummaryEvents.Add(summary);
public void OnDone(TokenUsage? usage, string? modelId) => DoneEvents.Add((usage, modelId));
+ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) => ContextUsages.Add((promptTokens, contextWindowTokens, percentLeft));
}
}
\ No newline at end of file
diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
index 9039db7cf..92c34bd94 100644
--- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
+++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
@@ -30,6 +30,18 @@ public void SubagentCallbacks_EmitSubagentEventLines() {
@out.ToString());
}
+ /// 上下文余量回调映射(M8-7)— OnContextUsage → context_usage 事件行(与 native_tui 侧字段逐一对齐)。
+ [Fact]
+ public void OnContextUsage_EmitsContextUsageEventLine() {
+ var (consumer, @out) = Create();
+
+ consumer.OnContextUsage(150_000, 200_000, 13);
+
+ Assert.Equal(
+ "{\"type\":\"context_usage\",\"promptTokens\":150000,\"contextWindowTokens\":200000,\"percentLeft\":13}\n",
+ @out.ToString());
+ }
+
[Fact]
public void OnText_EmitsTokenDelta_AndOnDoneEmitsFullAssistantMessage() {
var (consumer, @out) = Create();
diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
index feab945b6..93b803180 100644
--- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
+++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
@@ -103,6 +103,17 @@ public void Deserialize_TuiEmittedRequestLine_ParsesBack() {
Assert.Equal("你好 world", submit.Text);
}
+ /// 上下文余量事件 JSON 形状(M8-7 — 与 native_tui 侧 BridgeEvent.ContextUsage 逐字段镜像:
+ /// context_usage + promptTokens/contextWindowTokens/percentLeft camelCase 字段)。
+ [Fact]
+ public void Serialize_ContextUsage_CarriesCamelCaseFields() {
+ var json = JsonSerializer.Serialize(
+ new CliBridge.BridgeEvent.ContextUsage(150_000, 200_000, 13), CliBridge.BridgeJsonContext.Safe.BridgeEvent);
+ Assert.Equal(
+ "{\"type\":\"context_usage\",\"promptTokens\":150000,\"contextWindowTokens\":200000,\"percentLeft\":13}",
+ json);
+ }
+
/// 子代理三事件 JSON 形状(DSG041 批3 — 与 native_tui 侧 BridgeEvent 逐字段镜像:
/// subagent_started/subagent_tool_use/subagent_finished,camelCase 字段)。
[Fact]
From 78c61cd627784ddccfb63776ccb3647ba3182dec Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Thu, 22 Oct 2026 06:17:23 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix:=20LockDiagnosis=20=E7=AD=89=E5=BE=85?=
=?UTF-8?q?=E5=91=8A=E8=AD=A6=E6=B5=8B=E8=AF=95=E7=A1=AE=E5=AE=9A=E6=80=A7?=
=?UTF-8?q?=E5=8C=96(CI=20=E7=BA=BF=E7=A8=8B=E6=B1=A0=E9=A5=A5=E9=A5=BF=20?=
=?UTF-8?q?flaky)=20|=20=E5=86=B3=E7=AD=96:=20=E6=A0=B9=E5=9B=A0=3D?=
=?UTF-8?q?=E5=9B=BA=E5=AE=9A=20Delay(150)=20=E5=81=87=E8=AE=BE=20Task.Run?=
=?UTF-8?q?=20=E7=AD=89=E5=BE=85=E8=80=85=E5=9C=A8=E7=AA=97=E5=8F=A3?=
=?UTF-8?q?=E5=86=85=E8=BF=9B=E5=85=A5=E6=8E=92=E9=98=9F,CI=20=E8=B0=83?=
=?UTF-8?q?=E5=BA=A6=E5=BB=B6=E8=BF=9F=E4=B8=8B=E7=AD=89=E5=BE=85=E8=80=85?=
=?UTF-8?q?=E6=99=9A=E4=BA=8E=E9=87=8A=E6=94=BE=E6=89=8D=E5=90=AF=E5=8A=A8?=
=?UTF-8?q?(=E7=AD=89=E5=BE=85=200ms=20=E4=B8=8D=E8=B6=85=E9=98=88?=
=?UTF-8?q?=E5=80=BC=E6=BC=8F=20LOCK-WAIT-SLOW);=20=E4=BF=AE=E5=A4=8D=3DSp?=
=?UTF-8?q?inUntil=20=E7=AD=89=20LOCK-WAIT-START=20=E8=AF=8A=E6=96=AD?=
=?UTF-8?q?=E4=BF=A1=E5=8F=B7(AsyncLock.cs:154=20=E6=8E=92=E9=98=9F?=
=?UTF-8?q?=E5=BC=80=E5=A7=8B=E6=97=B6=E5=8F=91=E5=B0=84=E7=9A=84=E7=A1=AE?=
=?UTF-8?q?=E5=AE=9A=E6=80=A7=E9=82=AE=E7=AE=B1=E4=BF=A1=E5=8F=B7)+?=
=?UTF-8?q?=E4=BF=A1=E5=8F=B7=E5=90=8E=20Delay(100)=20=E8=B6=85=E9=98=88?=
=?UTF-8?q?=E5=80=BC=E5=86=8D=E9=87=8A=E6=94=BE=E2=80=94=E2=80=94=E6=8E=92?=
=?UTF-8?q?=E9=98=9F=E8=B5=B7=E6=AD=A2=E6=97=B6=E5=BA=8F=E7=94=B1=E7=BC=96?=
=?UTF-8?q?=E6=8E=92=E4=BF=9D=E8=AF=81=E4=B8=8D=E4=BE=9D=E8=B5=96=E8=B0=83?=
=?UTF-8?q?=E5=BA=A6=E8=BF=90=E6=B0=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../async_lock.tests/lock/AsyncLockDiagnosisTests.cs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs
index 55a66c9a3..c1039464b 100644
--- a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs
+++ b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs
@@ -85,7 +85,14 @@ public async Task 诊断Sink_等待过长时收到告警() {
using var holder = await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时");
// TryLock 同步阻塞, 在另一线程调用以记录等待诊断
var waitTask = Task.Run(async () => await lk.TryLockAsync());
- await Task.Delay(150);
+ // 确定性等待: LOCK-WAIT-START 诊断消息 = 等待者已进入排队的信号(AsyncLock 排队开始时发射)。
+ // 旧形态固定 Delay(150) 假设 Task.Run 在窗口内开始等待 — CI 线程池饥饿下等待者晚于释放才启动
+ // (等待 0ms 不超阈值 → 漏 LOCK-WAIT-SLOW 告警 flaky); 改等信号后排队的起止时序由测试编排保证
+ var startedWaiting = System.Threading.SpinWait.SpinUntil(
+ () => messages.Any(m => m.Contains("LOCK-WAIT-START") && m.Contains("wait-long-test")),
+ TimeSpan.FromSeconds(5));
+ startedWaiting.Should().BeTrue("等待者应在 5s 内进入等待状态(LOCK-WAIT-START 诊断信号,容忍CI调度延迟)");
+ await Task.Delay(100);
holder.Dispose();
var acquired = await waitTask;
acquired.Should().NotBeNull();