diff --git a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ToolProgressRelay.cs b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ToolProgressRelay.cs
new file mode 100644
index 000000000..11587ad32
--- /dev/null
+++ b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ToolProgressRelay.cs
@@ -0,0 +1,34 @@
+namespace JoinCode.Abstractions.LLM.Chat;
+
+///
+/// 工具进度中继(TASK065 P3 — Bash 工具流式进度引擎侧接线)— 工具执行网关 onProgress 回调 →
+/// 主对话事件通道:SubAgentEventChannel.Current 在场时构造闭包,把 ToolProgressData 投影为
+/// ChatStreamEvent.ToolProgress Emit(复用既有排空合流点 — QueryLoop WaitForTaskWithDrainAsync
+/// 50ms 轮询 / SessionController 主代理 chunk 循环逐拍排空,零新管道);
+/// 无通道(CLI 纯文本等无显示消费方场景)返回 null — gateway 走无进度路径行为零变化。
+/// 通道事件 AgentId 为 null → SessionController 主 Switch onToolProgress 分流 → IEventConsumer.OnToolProgress
+/// → bridge 模式 tool_progress 事件行 / 进程内直连内存事件(双消费形态)。
+/// 闭包直接持有通道引用(不经 AsyncLocal 回读)— ShellExecutionMiddleware 的进度 Timer 在
+/// ThreadPool 回调发射时同样可靠(ExecutionContext 捕获链无关)。
+///
+public static class ToolProgressRelay
+{
+ ///
+ /// 构造进度中继回调 — 通道在场时闭包捕获通道引用,进度到达即 Emit ChatStreamEvent.ToolProgress
+ /// (toolName/toolCallId 构造期捕获 — ToolProgressData 不携带工具名,由调用方闭包供给);
+ /// 无通道返回 null(调用方直传 gateway 第四参,gateway 判 null 跳过进度路径)。
+ ///
+ /// 工具名称(进度事件归属 — 闭包捕获)
+ /// 工具调用标识(可选 — 协议侧暂不投影,通道事件携带供后续按调用路由)
+ public static JoinCode.Abstractions.Tools.ToolProgressCallback? Build(string toolName, string? toolCallId = null)
+ {
+ var channel = SubAgentEventChannel.Current;
+ if (channel is null)
+ {
+ return null;
+ }
+
+ return progress => channel.Emit(ChatStreamEvent.ToolProgress(
+ toolName, progress.ProgressType, progress.Message ?? string.Empty, toolCallId));
+ }
+}
diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs
index 36c7ba6df..c96cdbd76 100644
--- a/modules/apps/engine/adapters/BridgeEventConsumer.cs
+++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs
@@ -71,8 +71,10 @@ public void OnToolEnd(string toolName, string? resultText, string? toolCallId, b
WriteEvent(new Cli.Bridge.BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch), rawOutput, hiddenLines));
}
- /// 工具调用进度 — TUI 不消费,忽略。
+ /// 工具调用进度 → tool_progress(TASK065 P3 — Bash/PowerShell 执行中输出快照流式上屏;
+ /// progressType 透传引擎侧既有类型;null message 空串投影 — TUI 侧更新为空尾巴自然收敛)。
public void OnToolProgress(string toolName, string progressType, string? progressMessage) {
+ WriteEvent(new Cli.Bridge.BridgeEvent.ToolProgress(toolName, progressType, progressMessage ?? string.Empty));
}
/// 检测到循环输出 — TUI 不消费,忽略。
diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs
index 2abf464d1..e16d227fb 100644
--- a/modules/apps/engine/bridge/BridgeProtocol.cs
+++ b/modules/apps/engine/bridge/BridgeProtocol.cs
@@ -43,6 +43,7 @@ public sealed record Quit() : BridgeRequest;
[JsonDerivedType(typeof(BridgeEvent.ThinkingStarted), "thinking_started")]
[JsonDerivedType(typeof(BridgeEvent.ThinkingEnded), "thinking_ended")]
[JsonDerivedType(typeof(BridgeEvent.ToolCall), "tool_call")]
+[JsonDerivedType(typeof(BridgeEvent.ToolProgress), "tool_progress")]
[JsonDerivedType(typeof(BridgeEvent.ToolResult), "tool_result")]
[JsonDerivedType(typeof(BridgeEvent.TokenDelta), "token_delta")]
[JsonDerivedType(typeof(BridgeEvent.AssistantMessage), "assistant_message")]
@@ -78,6 +79,15 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// 工具调用消息(bullet + bold 工具名 + args)。
public sealed record ToolCall(string Name, string Args) : BridgeEvent;
+ ///
+ /// 工具执行中进度流式增量(TASK065 P3 — Bash/PowerShell 执行中实时输出,取代"卡住到结束才见结果"):
+ /// ProgressType 透传引擎侧既有类型(bash_progress/ps_progress — ShellExecutionMiddleware 每秒快照;
+ /// query_update/search_results_received — WebSearch 等既有发射方零改动直达);
+ /// Message 为进度文本(bash_progress 为当前 stdout 最近行快照 — 多行文本);
+ /// TUI 侧流式项原地更新,tool_result 到达时原位替换为终态结果项 — 与 native_tui 侧逐字段镜像。
+ ///
+ public sealed record ToolProgress(string ToolName, string ProgressType, string Message) : BridgeEvent;
+
///
/// diff 行种类 — 数值对齐 native_tui DiffLineKind(Context=0/Added=1/Removed=2),
/// 与引擎 PatchLineType 同序可直转 byte;JSON 投影为数字。
diff --git a/modules/cognition/brain.context.tests/core/o_to_t/ToolProgressRelayTests.cs b/modules/cognition/brain.context.tests/core/o_to_t/ToolProgressRelayTests.cs
new file mode 100644
index 000000000..46951c8f2
--- /dev/null
+++ b/modules/cognition/brain.context.tests/core/o_to_t/ToolProgressRelayTests.cs
@@ -0,0 +1,111 @@
+namespace Core.Context;
+
+///
+/// 工具进度中继测试(TASK065 P3 — Bash 工具流式进度引擎侧接线)—
+/// ToolProgressRelay.Build:通道在场闭包 Emit ChatStreamEvent.ToolProgress(toolName/toolCallId
+/// 构造期捕获 — ToolProgressData 不携带工具名);无通道返回 null(CLI 纯文本等场景 gateway
+/// 走无进度路径行为零变化);闭包直持通道引用 — ThreadPool 回调(ShellExecutionMiddleware 进度
+/// Timer)发射不依赖 AsyncLocal 回读,跨线程可靠。
+///
+public sealed class ToolProgressRelayTests
+{
+ /// 无通道 — Build 返回 null(调用方直传 gateway 第四参,gateway 判 null 跳过进度路径)。
+ [Fact]
+ public void Build_WithoutChannel_ReturnsNull()
+ {
+ Assert.Null(ToolProgressRelay.Build("Bash"));
+ }
+
+ /// 通道在场 — 回调 Emit ToolProgress 事件(toolName/toolCallId 构造期捕获 +
+ /// progressType/message 透传 — 字段全量校验)。
+ [Fact]
+ public void Build_WithChannel_EmitsToolProgressEvent()
+ {
+ var channel = new SubAgentEventChannel();
+ using (channel.EnterScope())
+ {
+ var relay = ToolProgressRelay.Build("Bash", "call-1");
+ if (relay is not { } emit)
+ {
+ throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
+ }
+
+ emit(new ToolProgressData {
+ ProgressType = "bash_progress",
+ ToolUseId = "bash_progress-0",
+ Message = "line1\nline2",
+ });
+ }
+
+ var evt = Assert.Single(channel.TryDrain());
+ Assert.Equal(ChatStreamEventType.ToolProgress, evt.Type);
+ Assert.Equal("Bash", evt.ToolName);
+ Assert.Equal("call-1", evt.ToolCallId);
+ Assert.Equal("bash_progress", evt.ProgressType);
+ Assert.Equal("line1\nline2", evt.ProgressMessage);
+ }
+
+ /// null message — 空串投影(消费侧事件形状恒定)。
+ [Fact]
+ public void Build_NullMessage_ProjectsEmptyString()
+ {
+ var channel = new SubAgentEventChannel();
+ using (channel.EnterScope())
+ {
+ var relay = ToolProgressRelay.Build("Bash");
+ if (relay is not { } emit) {
+ throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
+ }
+
+ emit(new ToolProgressData { ProgressType = "bash_progress", ToolUseId = "u" });
+ }
+
+ var evt = Assert.Single(channel.TryDrain());
+ Assert.Equal(string.Empty, evt.ProgressMessage);
+ }
+
+ /// ThreadPool 调用(ShellExecutionMiddleware 进度 Timer 语义)— 闭包直持通道引用,
+ /// 跨线程发射可靠(不依赖 AsyncLocal 回读 — ExecutionContext 捕获链无关)。
+ [Fact]
+ public async Task Relay_FromThreadPoolStillEmits()
+ {
+ var channel = new SubAgentEventChannel();
+ using (channel.EnterScope())
+ {
+ var relay = ToolProgressRelay.Build("Bash", "call-1");
+
+ if (relay is not { } emit) {
+ throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
+ }
+
+ await Task.Run(() => emit(new ToolProgressData {
+ ProgressType = "bash_progress",
+ ToolUseId = "u",
+ Message = "pooled output",
+ }));
+ }
+
+ var evt = Assert.Single(channel.TryDrain());
+ Assert.Equal(ChatStreamEventType.ToolProgress, evt.Type);
+ Assert.Equal("pooled output", evt.ProgressMessage);
+ }
+
+ /// 通道完成后 Emit 静默丢弃 — 迟到进度不泄漏(通道既有语义,relay 调用不抛)。
+ [Fact]
+ public void Relay_AfterChannelComplete_SilentlyDropped()
+ {
+ var channel = new SubAgentEventChannel();
+ ToolProgressCallback? relay;
+ using (channel.EnterScope())
+ {
+ relay = ToolProgressRelay.Build("Bash");
+ }
+
+ channel.Complete();
+
+ if (relay is { } emit) {
+ emit(new ToolProgressData { ProgressType = "bash_progress", ToolUseId = "u", Message = "late" });
+ }
+ Assert.Empty(channel.TryDrain());
+ }
+}
diff --git a/modules/cognition/brain/context/services/chat/core/ChatToolOrchestrator.cs b/modules/cognition/brain/context/services/chat/core/ChatToolOrchestrator.cs
index fd410ccca..6a5db3407 100644
--- a/modules/cognition/brain/context/services/chat/core/ChatToolOrchestrator.cs
+++ b/modules/cognition/brain/context/services/chat/core/ChatToolOrchestrator.cs
@@ -141,7 +141,11 @@ public async Task ExecuteToolCallAsync(
};
}
- var toolResult = await _toolExecutionGateway.ExecuteAsync(toolCallName, arguments ?? new Dictionary(), ct).ConfigureAwait(false);
+ // 工具进度中继(TASK065 P3 — Bash 执行中输出流式上屏):通道在场时 gateway onProgress
+ // → ChatStreamEvent.ToolProgress Emit → QueryLoop 排空合流点实时 yield;无通道 null 零影响
+ var toolResult = await _toolExecutionGateway.ExecuteAsync(
+ toolCallName, arguments ?? new Dictionary(), ct,
+ JoinCode.Abstractions.LLM.Chat.ToolProgressRelay.Build(toolCallName, toolCallId)).ConfigureAwait(false);
// 构建结果文本
string resultText;
diff --git a/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs b/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs
index fdc884dcb..d2c9259bd 100644
--- a/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs
+++ b/modules/cognition/brain/query/query2/token_budget/core/QueryEngine.cs
@@ -465,8 +465,13 @@ private async Task ExecuteToolAsync(ToolCallRequest request, QueryOp
}
try {
+ // 工具进度中继(TASK065 P3 — 主代理路径 Bash 执行中输出流式上屏):SessionController
+ // 回合级 SubAgentEventChannel 作用域在场时 Emit 进度事件 → chunk 循环逐拍排空直投消费方;
+ // 无作用域(CLI 纯文本/独立调用)null 零影响。registry 兜底路径无 onProgress 形参 — 遗留不发射
var result = _toolExecutionGateway is not null
- ? await _toolExecutionGateway.ExecuteAsync(request.ToolName, request.Arguments, cancellationToken).ConfigureAwait(false)
+ ? await _toolExecutionGateway.ExecuteAsync(
+ request.ToolName, request.Arguments, cancellationToken,
+ JoinCode.Abstractions.LLM.Chat.ToolProgressRelay.Build(request.ToolName)).ConfigureAwait(false)
: await _toolRegistry.ExecuteToolAsync(request.ToolName, request.Arguments, cancellationToken).ConfigureAwait(false);
options?.ProgressTracker?.RecordToolUse(request.ToolName);
return result;
diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
index 75d6770b3..9413215fa 100644
--- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
+++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
@@ -8,8 +8,9 @@ namespace NativeTui.Host;
/// OnText → token_delta;OnThinking 回合内单次信号 + 墙钟差结算 thinking_ended;
/// OnToolStart/OnToolEnd → tool_call/tool_result(摘要截断 200 + error 前缀 + StructuredPatch → DiffHunk 投影 +
-/// 原始输出预截断投影 raw_output/hidden_lines);OnDone → assistant_message(完整文本) + token_usage(用量汇总);
-/// OnBackgroundTask → background_task(M8-8 常驻面板 upsert)。/// OnToolProgress/OnLoopDetected/OnTimingSummary 不消费。
+/// 原始输出预截断投影 raw_output/hidden_lines);OnToolProgress → tool_progress(TASK065 P3 —
+/// Bash 执行中输出快照流式上屏);OnDone → assistant_message(完整文本) + token_usage(用量汇总);
+/// OnBackgroundTask → background_task(M8-8 常驻面板 upsert)。/// OnLoopDetected/OnTimingSummary 不消费。
///
public sealed class InProcessEventConsumer : IResettableEventConsumer {
/// 工具结果摘要截断上限(对齐 engine BridgeEventConsumer 的 200 字符显示截断)。
@@ -103,8 +104,10 @@ public void OnToolEnd(string toolName, string? resultText, string? toolCallId, b
_events.Enqueue(new BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch), rawOutput, hiddenLines));
}
- /// 工具调用进度 — TUI 不消费,忽略(对齐 bridge 形态)。
+ /// 工具调用进度 → ToolProgress(TASK065 P3 — Bash/PowerShell 执行中输出快照流式上屏;
+ /// progressType 透传引擎侧既有类型,null message 空串投影 — 对齐 bridge 形态逐行为镜像)。
public void OnToolProgress(string toolName, string progressType, string? progressMessage) {
+ _events.Enqueue(new BridgeEvent.ToolProgress(toolName, progressType, progressMessage ?? string.Empty));
}
/// 检测到循环输出 — TUI 不消费,忽略(对齐 bridge 形态)。
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index 5dd383665..bc2d574db 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -15,6 +15,7 @@ namespace NativeTui.Core.Bridge;
[JsonDerivedType(typeof(ThinkingStarted), "thinking_started")]
[JsonDerivedType(typeof(ThinkingEnded), "thinking_ended")]
[JsonDerivedType(typeof(ToolCall), "tool_call")]
+[JsonDerivedType(typeof(ToolProgress), "tool_progress")]
[JsonDerivedType(typeof(ToolResult), "tool_result")]
[JsonDerivedType(typeof(PermissionRequest), "permission_request")]
[JsonDerivedType(typeof(TokenDelta), "token_delta")]
@@ -53,6 +54,15 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// 工具调用消息(bullet + bold 工具名 + args)。
public sealed record ToolCall(string Name, string Args) : BridgeEvent;
+ ///
+ /// 工具执行中进度流式增量(TASK065 P3 — Bash/PowerShell 执行中实时输出取代"卡住到结束才见结果"):
+ /// ProgressType 透传引擎侧既有类型(bash_progress/ps_progress/query_update/...);
+ /// Message 为进度文本(bash_progress 为当前 stdout 最近行快照 — 多行文本)。
+ /// Driver 路由流式项原地更新(ToolCallItem 之后的输出尾巴),tool_result 到达时原位替换为
+ /// ToolResultItem(对齐 StreamingTextItem 替换先例)— 与引擎侧 ToolProgress 逐字段镜像。
+ ///
+ public sealed record ToolProgress(string ToolName, string ProgressType, string Message) : BridgeEvent;
+
///
/// 工具结果摘要(⎿ 缩进项);Patch 非空时渲染为结构化 diff 块(M7-2 StructuredDiff)。
/// Patch null 不输出字段(WhenWritingNull — 与引擎侧 ToolResult 契约逐字节一致,旧 JSON 兼容)。
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index e4e73a970..34b0634c1 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -11,7 +11,9 @@ namespace NativeTui.Core.Bridge;
/// 单行摘要(组状态归 ToolGroupAggregator),非 Accepts 工具/其他消息事件/回合边界收组定格;
/// 小终端塌缩(ShouldCondense)时 CondensedToolProgressItem 单行替代逐条。
/// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态);
-/// 后台任务(background_task)路由常驻面板 upsert(M8-8 — (kind,taskId) 更新/插入,终态驻留)。
+/// 后台任务(background_task)路由常驻面板 upsert(M8-8 — (kind,taskId) 更新/插入,终态驻留);
+/// 工具进度(tool_progress)路由流式输出项原地更新(TASK065 P3 — Bash 执行中输出实时显示,
+/// tool_result 到达原位替换为 ToolResultItem)。
/// 模拟路径与真实 bridge 路径共用同一套回合视图状态。
///
public sealed class ReplBridgeDriver
@@ -56,6 +58,12 @@ public sealed class ReplBridgeDriver
/// 后续增量原地追加;AssistantMessage 原位替换为完整消息项;EndTurn 兜底定格/清理。
private StreamingTextItem? _streamingItem;
+ /// 活动工具流式输出项(TASK065 P3 — Bash 执行中输出实时显示)— 首个 tool_progress
+ /// 惰性创建落位 ToolCallItem 之后(spinner 前),后续快照原地 Update;tool_result 到达原位替换为
+ /// ToolResultItem(对齐 StreamingTextItem 替换先例);新 tool_call 到达旧项定格;EndTurn 清引用
+ /// 项留存(打断场景已见输出不丢)。
+ private ToolStreamingItem? _toolStreamingItem;
+
/// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项;
/// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景);
/// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用;
@@ -122,6 +130,11 @@ public void Apply(BridgeEvent bridgeEvent)
_spinner.Mode = SpinnerMode.ToolUse;
_spinner.HasActiveTools = true;
break;
+ case BridgeEvent.ToolProgress(_, _, var progressMessage):
+ // TASK065 P3 — Bash/PowerShell 执行中输出快照流式上屏(progressType/工具名不路由 —
+ // 流式项即输出尾巴,类型差异由终态 ToolResultItem 表达)
+ ApplyToolProgress(progressMessage);
+ break;
case BridgeEvent.ToolResult(var summary, var patch, var rawOutput, var hiddenLines):
// S2 契约统一结果项:ToolResultItem(Summary, Patch, RawOutput, HiddenLines) —
// patch/rawOutput/hiddenLines 全量透传(折叠/展开渲染归 item,Driver 只路由不解释)
@@ -202,6 +215,8 @@ public void Apply(BridgeEvent bridgeEvent)
/// ④ 非 Accepts 工具 → 收组(连续同类中断 — 聚合项定格为普通项)+ 逐条落位。
private void ApplyToolCall(string name, string args)
{
+ // 上一个工具的流式尾巴定格(引用清空不删项 — 引擎漏发 tool_result 时不串新工具输出)
+ _toolStreamingItem = null;
_inProgressToolCallCount++;
if (_condensedProgressItem is not null)
{
@@ -235,7 +250,8 @@ private void ApplyToolCall(string name, string args)
/// 工具结果路由(S6 任务 7)— 计数递减;塌缩态计数归零移除塌缩项;
/// 活动聚合组吸收(不落逐条 ToolResultItem — 折叠形态结果数据不逐条上屏);
- /// 其余逐条落位(统一四段契约构造)。
+ /// 其余逐条落位(统一四段契约构造);流式项在场时原位替换(TASK065 P3 —
+ /// 输出尾巴位置落终态结果项,视觉无跳变;塌缩/聚合吸收路径移除流式项)。
private void ApplyToolResult(string summary, DiffHunk[]? patch, string? rawOutput, int hiddenLines)
{
_inProgressToolCallCount = Math.Max(0, _inProgressToolCallCount - 1);
@@ -248,17 +264,39 @@ private void ApplyToolResult(string summary, DiffHunk[]? patch, string? rawOutpu
_condensedProgressItem = null;
}
+ RemoveToolStreamingItem();
_activeToolGroup?.OnToolResult();
return;
}
if (_activeToolGroup is { } group)
{
+ RemoveToolStreamingItem();
group.OnToolResult();
return;
}
- AddMessage(new ToolResultItem(summary, patch, rawOutput, hiddenLines));
+ ReplaceToolStreamingWithResult(new ToolResultItem(summary, patch, rawOutput, hiddenLines));
+ }
+
+ /// 工具进度路由(TASK065 P3)— 回合内惰性建流式项(spinner 前落位 — 紧随 ToolCallItem)
+ /// + 快照原地 Update;塌缩态忽略(CondensedToolProgressItem 是唯一进度展示形态 — 既有语义);
+ /// 回合外迟到事件丢弃(IsBusy 防御 — 引擎漏发收尾时不跨回合泄漏)。progressType 不路由 —
+ /// 全类型统一输出尾巴形态(bash_progress/ps_progress/query_update 等都受益)。
+ private void ApplyToolProgress(string message)
+ {
+ if (!IsBusy || _condensedProgressItem is not null)
+ {
+ return;
+ }
+
+ if (_toolStreamingItem is null)
+ {
+ _toolStreamingItem = new ToolStreamingItem();
+ AddMessage(_toolStreamingItem);
+ }
+
+ _toolStreamingItem.Update(message);
}
/// 聚合组路由 — 非 Accepts 工具到达时收组(连续同类中断);Accepts 且无活动组时
@@ -362,6 +400,7 @@ public void BeginTurn()
_activeSubagentGroup = null;
CloseActiveToolGroup();
DiscardStreamingItem();
+ _toolStreamingItem = null;
_view.Footer = _view.Footer with { Busy = true };
_spinner.Message = string.Empty;
_spinner.ResetVerb();
@@ -385,6 +424,7 @@ public void EndTurn()
_activeSubagentGroup = null;
CloseActiveToolGroup();
FinalizeStreamingItem();
+ _toolStreamingItem = null;
_view.Footer = _view.Footer with { Busy = false };
_spinner.Message = string.Empty;
_spinner.ResponseLength = 0;
@@ -448,4 +488,36 @@ private void DiscardStreamingItem()
_view.Messages.RemoveAt(index);
}
}
+
+ /// 流式项原位替换为终态结果项(tool_result 路径 — TASK065 P3)— 流式项位置不变
+ ///(输出尾巴位置直接落 ToolResultItem,视觉无跳变),无流式项时退化为普通 AddMessage
+ ///(对齐 ReplaceStreamingWithFinal 先例)。
+ private void ReplaceToolStreamingWithResult(IScrollItem final)
+ {
+ if (_toolStreamingItem is null) {
+ AddMessage(final);
+ return;
+ }
+
+ var index = _view.Messages.IndexOf(_toolStreamingItem);
+ _toolStreamingItem = null;
+ if (index < 0) {
+ AddMessage(final);
+ return;
+ }
+
+ _view.Messages.RemoveAt(index);
+ _view.Messages.Insert(index, final);
+ }
+
+ /// 移除活动工具流式项(塌缩/聚合吸收路径 — 工具完成时尾巴退场;不在场 no-op)。
+ private void RemoveToolStreamingItem()
+ {
+ if (_toolStreamingItem is null) {
+ return;
+ }
+
+ RemoveMessageItem(_toolStreamingItem);
+ _toolStreamingItem = null;
+ }
}
diff --git a/native_tui/src/NativeTui.Core/markdown/InlineMarkdownParser.cs b/native_tui/src/NativeTui.Core/markdown/InlineMarkdownParser.cs
new file mode 100644
index 000000000..854629745
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/markdown/InlineMarkdownParser.cs
@@ -0,0 +1,107 @@
+namespace NativeTui.Core.Markdown;
+
+///
+/// 行内 Markdown 轻量解析器(TASK064/TASK065 P3 — CoT 展开态渲染专用):
+/// Core 零依赖铁律下不引 Markdig 卫星,手写行级扫描 **bold** / *italic* / `code` 三件套,
+/// 产出 Core 段模型(MarkdownSegment — 渲染层经 MapMarkdownStyle 映射后叠加 dim 基调)。
+/// 平级扫描不递归(粗体内嵌套标记字面保留);_italic_ 不支持(snake_case 误判防护);
+/// 未闭合标记字面降级。契约:空列表 = 无行内语法(整行纯文本降级渲染);
+/// 非空 = 至少含一个带样式段(plain 段夹于其间,扫描顺序天然连续无需合并)。
+///
+public static class InlineMarkdownParser
+{
+ /// 解析单行 → 行内段序列(空列表 = 纯文本无行内语法 — 渲染降级信号)。
+ public static IReadOnlyList ParseLine(ReadOnlySpan line)
+ {
+ var segments = new List();
+ var plainStart = 0;
+ var index = 0;
+ while (index < line.Length)
+ {
+ var marker = ScanMarker(line, index);
+ if (marker is null)
+ {
+ index++;
+ continue;
+ }
+
+ AppendPlain(segments, line[plainStart..index]);
+ segments.Add(new MarkdownSegment(line[(index + marker.Value.MarkerLength)..marker.Value.ContentEnd].ToString(), marker.Value.Style));
+ index = marker.Value.ContentEnd + marker.Value.MarkerLength;
+ plainStart = index;
+ }
+
+ // 有带样式段才追加尾部 plain(无样式整行 → 空列表降级契约)
+ if (segments.Count > 0)
+ {
+ AppendPlain(segments, line[plainStart..]);
+ }
+
+ return segments;
+ }
+
+ /// 行首标记扫描分派 — 反引号走 code、星号走强调(无标记字符返回 null)。
+ private static MarkerMatch? ScanMarker(ReadOnlySpan line, int index) => line[index] switch
+ {
+ '`' => ScanDelimited(line, index, "`", MarkdownStyle.Code),
+ '*' => ScanAsterisk(line, index),
+ _ => null,
+ };
+
+ /// 星号强调扫描 — *** 三连优先(Bold|Italic),未闭合退化为 ** 粗体,最后单 * 斜体。
+ private static MarkerMatch? ScanAsterisk(ReadOnlySpan line, int index)
+ {
+ var triple = HasRun(line, index, 3)
+ ? ScanDelimited(line, index, "***", MarkdownStyle.Bold | MarkdownStyle.Italic)
+ : null;
+ if (triple is not null)
+ {
+ return triple;
+ }
+
+ return HasRun(line, index, 2)
+ ? ScanDelimited(line, index, "**", MarkdownStyle.Bold)
+ : ScanDelimited(line, index, "*", MarkdownStyle.Italic);
+ }
+
+ /// 定界符闭合扫描 — 内容起点后找闭合定界符(未闭合或空内容返回 null — 字面降级)。
+ private static MarkerMatch? ScanDelimited(ReadOnlySpan line, int index, string delimiter, MarkdownStyle style)
+ {
+ var contentStart = index + delimiter.Length;
+ var closing = line[contentStart..].IndexOf(delimiter.AsSpan(), StringComparison.Ordinal);
+ return closing > 0
+ ? new MarkerMatch(style, delimiter.Length, contentStart + closing)
+ : null;
+ }
+
+ /// 位置起连续 markerLength 个星号检测。
+ private static bool HasRun(ReadOnlySpan line, int index, int markerLength)
+ {
+ if (index + markerLength > line.Length)
+ {
+ return false;
+ }
+
+ for (var offset = 0; offset < markerLength; offset++)
+ {
+ if (line[index + offset] != '*')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// plain 文本段追加(空文本跳过 — 空段不产出)。
+ private static void AppendPlain(List segments, ReadOnlySpan text)
+ {
+ if (text.Length > 0)
+ {
+ segments.Add(new MarkdownSegment(text.ToString(), MarkdownStyle.None));
+ }
+ }
+
+ /// 行内标记匹配结果 — 样式 + 定界符长度 + 内容结束位置(exclusive)。
+ private readonly record struct MarkerMatch(MarkdownStyle Style, int MarkerLength, int ContentEnd);
+}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
index 1e79ba400..d48abd997 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
@@ -78,7 +78,10 @@ public static void RenderThinkingSummary(Screen screen, Rect frame, Theme theme,
screen.WriteText(frame.Y, frame.X, $"{Figures.ThereforeSign} 思考{suffix}", dim, frame.Right);
}
- /// 渲染思考内容行(展开态 CoT 全文):⎿ 前缀 + 2 空格缩进 dim 正文(逐行 — 换行拆分多行渲染)。
+ /// 渲染思考内容行(展开态 CoT 全文):⎿ + 2 空格缩进,dim 基调 Markdown 行内渲染
+ ///(TASK064/TASK065 P3)— 行内语法经 InlineMarkdownParser 轻量解析(**bold**/*italic*/`code` 三件套),
+ /// 段样式叠 Dim 基调(基准思考块 = dim Markdown);无行内语法行/列表/引用/标题等块级行 = 纯文本 dim
+ ///(现行为完整保留);截断守卫在消息项 TruncateThinking — 行数按 \n 计与段渲染无关。
public static void RenderThinkingContent(Screen screen, Rect frame, Theme theme, string content)
{
var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
@@ -87,12 +90,31 @@ public static void RenderThinkingContent(Screen screen, Rect frame, Theme theme,
foreach (var line in content.Split('\n')) {
var text = line.TrimEnd('\r');
if (text.Length > 0) {
- screen.WriteText(row, frame.X, $"{indent}{text}", dim, frame.Right);
+ screen.WriteText(row, frame.X, indent, dim, frame.Right);
+ RenderThinkingInline(screen, row, frame.X + indent.Length, frame.Right, theme, text, dim);
}
row++;
}
}
+ /// CoT 行内段渲染 — 有行内语法走段写(样式经 MapMarkdownStyle 映射后叠 Dim 基调:
+ /// bold/italic 保持 dim、code permission 前景 + dim);无语法纯文本 dim 单写(降级现行为)。
+ private static void RenderThinkingInline(Screen screen, int row, int col, int right, Theme theme, string text, int dimStyleId)
+ {
+ var segments = InlineMarkdownParser.ParseLine(text);
+ if (segments.Count == 0)
+ {
+ screen.WriteText(row, col, text, dimStyleId, right);
+ return;
+ }
+
+ foreach (var segment in segments)
+ {
+ var style = screen.Styles.GetOrAdd(MapMarkdownStyle(segment.Style, theme) with { Dim = true });
+ col = screen.WriteText(row, col, segment.Text, style, right);
+ }
+ }
+
/// 渲染错误消息:● + 空格 + 正文,全部 theme.Error 红色(bridge error 事件)。
public static void RenderError(Screen screen, Rect frame, Theme theme, string text)
{
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index b2fbc7a06..0f718fbbb 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -124,6 +124,14 @@ private void HandleExitCtrlD()
/// 最近一次直发提交的用户消息项位置(M8-2 打断恢复 — 无有效响应时移除该条上屏)。
private int _lastSubmittedIndex = -1;
+ /// ctrl+s 输入暂存单槽(TASK064 P3 — 基准 defaultBindings.ts:85 chat:stash +
+ /// PromptInputStashNotice.tsx:18 单槽语义):覆盖式收起当前输入腾出输入框(忙/闲均可用),
+ /// 下次 Submit 提交动作完成后自动回填输入框;空输入无操作。
+ private string _stashedInput = string.Empty;
+
+ /// 暂存单槽内容(internal — 测试断言面;空串 = 无暂存)。
+ internal string StashedInput => _stashedInput;
+
/// 帮助面板开关(S6 任务 5 — 空闲态 '?' 弹出;分派链插在 suggestion/transcript 之后)。
private bool _helpOpen;
@@ -981,6 +989,10 @@ private void HandleCtrlKey(string name)
case "r":
BeginOrCycleSearch();
break;
+ case "s":
+ // TASK064 P3 — 基准 chat:stash:收起当前输入(忙/闲均可用),提交后自动恢复
+ StashInput();
+ break;
case "o":
_transcript.Toggle();
SyncTranscriptView();
@@ -1961,7 +1973,9 @@ internal void CyclePermissionMode()
/// **不上滚动区**(输入栏上方 queued 面板渲染 — TASK062 用户验收反馈:滚动区排队提示破坏消息流),
/// 入历史环,回合结束自动按序发送(DrainEngine 队列泵 — 发送时才上屏用户消息);闲时正常直发。
/// S7 提交全文:显示层用折叠形态([Pasted text #N]),发引擎取 ExpandedText(粘贴引用展开回原文)—
- /// 上屏/历史/打断恢复保留显示形态(折叠可见语义)。
+ /// 上屏/历史/打断恢复保留显示形态(折叠可见语义)。
+ /// TASK064 P3 — ctrl+s 暂存恢复:提交动作发生后(输入框已清空,含排队/本地拦截/登录拒绝全部路径)
+ /// 暂存单槽文本自动回填输入框(外壳捕获 + 主流程统一收尾 — 见 SubmitCore/RestoreStashedInput)。
internal void Submit()
{
var text = _input.Text;
@@ -1972,6 +1986,17 @@ internal void Submit()
var expanded = _input.ExpandedText;
_input.Clear();
+ // TASK064 P3 — 捕获并清空暂存单槽(提交即消费:主流程结束后统一回填,空文本早退不消费)
+ var stashed = _stashedInput;
+ _stashedInput = string.Empty;
+ SubmitCore(text, expanded);
+ RestoreStashedInput(stashed);
+ }
+
+ /// 提交主流程(Submit 外壳剥离暂存收尾后的全部路径 — bash 直通/主题拦截/使用记账/
+ /// 登录门/忙时排队/闲时直发;各 return 路径均为"提交动作已发生",外壳统一恢复暂存)。
+ private void SubmitCore(string text, string expanded)
+ {
_history.Add(text);
_history.Reset();
// S10 接线 — `!` 前缀 bash 直通(基准 inputModes bash 模式:剥前缀本地执行不进引擎;
@@ -2412,4 +2437,32 @@ internal void ForceFullRepaint()
_prevOverride = null;
_forceFullReset = true;
}
+
+ /// ctrl+s 输入暂存(TASK064 P3 — 基准 defaultBindings.ts:85 chat:stash +
+ /// PromptInputStashNotice.tsx:18):收起当前输入腾出输入框(忙/闲均可用 — 暂存非忙时专属),
+ /// 单槽覆盖式(重复暂存覆盖旧槽,非栈);反馈消息上屏(PostInfo 先例 — 滚动区 dim 消息,
+ /// 首版不做 footer 通知队列);空输入无操作(不覆盖已有暂存)。
+ /// 恢复条件 = 下次 Submit 提交动作完成后自动回填(见 Submit/RestoreStashedInput)。
+ private void StashInput()
+ {
+ var text = _input.Text;
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ _stashedInput = text;
+ _input.Clear();
+ PostInfo("已暂存(提交后自动恢复)");
+ }
+
+ /// 提交后暂存回填(TASK064 P3 — Submit 外壳统一收尾:提交动作发生
+ ///(含忙时排队/本地拦截/登录拒绝全部路径)后暂存文本填回输入框,光标落文本尾;空槽无操作)。
+ private void RestoreStashedInput(string stashed)
+ {
+ if (stashed.Length > 0)
+ {
+ FillInput(stashed, cursorToStart: false);
+ }
+ }
}
diff --git a/native_tui/src/NativeTui.Core/repl/ToolStreamingItem.cs b/native_tui/src/NativeTui.Core/repl/ToolStreamingItem.cs
new file mode 100644
index 000000000..94f678e11
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/ToolStreamingItem.cs
@@ -0,0 +1,108 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 工具流式输出项(TASK065 P3 — Bash 工具运行中输出实时显示):
+/// 工具执行期间输出尾巴上屏(ToolCallItem 之后落位),对齐基准 Bash 流式 tail 形态 —
+/// 最近 行 dim 输出环形保留(超出行数
+/// "… +N lines" 溢出提示行前置 — 上方被截语义,尾部行恒可见),消息为引擎侧每秒 stdout
+/// 快照(非增量)→ Update 整体替换尾部行;单行超长截断(对齐 ToolResultSummaryLimit=200 先例)。
+/// tool_result 到达时 Driver 原位替换为 ToolResultItem(对齐 StreamingTextItem 替换先例 —
+/// 位置不变视觉无跳变);打断(无 tool_result)时项残留定格为已见输出(EndTurn 清引用不删项)。
+/// 首行 " ⎿ " 前缀 + 续行等宽缩进(对齐 ToolResultItem 输出块形态),整体 dim(流态弱化 —
+/// 终态 ToolResultItem 替换后恢复正式形态)。ToolCallItem 专属(MessageItems.cs 并行独占禁改)
+/// — 工具标题行由既有 ToolCallItem 渲染,本项只承载输出尾巴,不重复工具行。
+///
+public sealed class ToolStreamingItem : IScrollItem, ITextMatchable
+{
+ /// 单行输出字符截断上限(对齐 ToolResultSummaryLimit=200 显示截断先例 — 防单行 dump 刷宽)。
+ private const int LineCharLimit = 200;
+
+ /// 溢出提示前缀(… 表示上方还有被截的输出行)。
+ private const string OverflowPrefix = "… +";
+
+ /// 尾部显示行(最近行在前 — 溢出提示行 + 尾行;Update 整体重建)。
+ private string[] _displayLines = [];
+
+ /// 上方被截的输出行数(0 = 无溢出不渲染提示行)。
+ private int _hiddenLines;
+
+ /// 更新输出快照(引擎侧每秒发的当前 stdout 最近行 — 整体替换非增量;空串清空尾巴)。
+ public void Update(string message)
+ {
+ var lines = SplitLines(message);
+ _hiddenLines = Math.Max(0, lines.Length - ToolFoldingPolicy.MaxToolOutputLines);
+ var tail = lines.Length > ToolFoldingPolicy.MaxToolOutputLines
+ ? lines[^ToolFoldingPolicy.MaxToolOutputLines..]
+ : lines;
+ var display = new List(tail.Length + 1);
+ if (_hiddenLines > 0)
+ {
+ display.Add($"{OverflowPrefix}{_hiddenLines} lines");
+ }
+
+ foreach (var line in tail)
+ {
+ display.Add(TruncateLine(line));
+ }
+
+ _displayLines = [.. display];
+ }
+
+ /// 上方被截的输出行数(测试断言面 — 溢出提示数据源)。
+ public int HiddenLines => _hiddenLines;
+
+ /// 当前尾部显示行(测试断言面 — 溢出提示行 + 尾行原序)。
+ public IReadOnlyList DisplayLines => _displayLines;
+
+ /// 内容高度 = margin 1 + 显示行数(空态仅 margin — 无尾巴零占屏)。
+ public int MeasureHeight(int width) => 1 + _displayLines.Length;
+
+ /// 渲染:margin 行留空 → 首行 " ⎿ " dim 前缀 → 各行从内容列起写
+ ///(续行 ToolResultIndent 等宽空格缩进对齐内容列 — 对齐 ToolResultItem 输出续行形态;
+ /// 整行 dim — 流态弱化语义)。
+ public void Render(Screen screen, Rect frame, Theme theme)
+ {
+ if (_displayLines.Length == 0)
+ {
+ return;
+ }
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ var continuation = new string(' ', Figures.ToolResultIndent.Length);
+ var row = frame.Y + 1;
+ var first = true;
+ foreach (var line in _displayLines)
+ {
+ var prefix = first ? Figures.ToolResultIndent : continuation;
+ screen.WriteText(row, frame.X, prefix, dim, frame.Right);
+ if (line.Length > 0)
+ {
+ screen.WriteText(row, frame.X + prefix.Length, line, dim, frame.Right);
+ }
+
+ first = false;
+ row++;
+ }
+ }
+
+ /// transcript 搜索匹配 — 尾部显示行内容。
+ public bool ContainsText(string query) =>
+ _displayLines.Any(line => line.Contains(query, StringComparison.OrdinalIgnoreCase));
+
+ /// 快照拆行(\n 分行 + \r 清洗;空快照明行保留 — 空行 reset 语义)。
+ private static string[] SplitLines(string message)
+ {
+ if (message.Length == 0)
+ {
+ return [];
+ }
+
+ return message.Split('\n').Select(static line => line.TrimEnd('\r')).ToArray();
+ }
+
+ /// 单行超长截断(200 上限 + … 尾 — 对齐结果摘要截断先例)。
+ private static string TruncateLine(string line) =>
+ line.Length > LineCharLimit
+ ? string.Concat(line.AsSpan(0, LineCharLimit - 1), "…")
+ : line;
+}
diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerExtensionTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerExtensionTests.cs
index aa4c77dea..f02d30da5 100644
--- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerExtensionTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerExtensionTests.cs
@@ -111,4 +111,50 @@ public void OnDone_EmptyTextWithUsage_StillEmitsTokenUsage()
Assert.Equal(7L, usage.InputTokens);
Assert.Equal(3L, usage.OutputTokens);
}
+
+ /// 工具进度映射(TASK065 P3 — 与 engine BridgeEventConsumerExtensionTests 逐行为镜像)
+ /// — OnToolProgress → ToolProgress 内存事件(progressType 透传;多行 message 保真 —
+ /// Bash 执行中输出快照,队列 FIFO 保序)。
+ [Fact]
+ public void OnToolProgress_EnqueuesToolProgressEvent()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnToolProgress("Bash", "bash_progress", "line1\nline2");
+
+ var progress = Assert.IsType(Assert.Single(consumer.DrainEvents()));
+ Assert.Equal("Bash", progress.ToolName);
+ Assert.Equal("bash_progress", progress.ProgressType);
+ Assert.Equal("line1\nline2", progress.Message);
+ }
+
+ /// 工具进度 null message — 空串投影(与 bridge 形态逐行为镜像 — 事件形状恒定)。
+ [Fact]
+ public void OnToolProgress_NullMessage_ProjectsEmptyString()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnToolProgress("Bash", "bash_progress", null);
+
+ var progress = Assert.IsType(Assert.Single(consumer.DrainEvents()));
+ Assert.Equal(string.Empty, progress.Message);
+ }
+
+ /// 工具执行序列事件序(TASK065 P3)— OnToolStart → OnToolProgress → OnToolEnd
+ /// 产出 ToolCall → ToolProgress → ToolResult 队列序(流式项建项/替换时序的数据源契约)。
+ [Fact]
+ public void OnToolSequence_ProgressFlowsBetweenCallAndResult()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnToolStart("Bash", "call-1", "{\"command\":\"ls\"}");
+ consumer.OnToolProgress("Bash", "bash_progress", "file.txt");
+ consumer.OnToolEnd("Bash", "1 file", "call-1", isError: false, patch: null);
+
+ var events = consumer.DrainEvents();
+ Assert.Equal(3, events.Count);
+ Assert.IsType(events[0]);
+ Assert.IsType(events[1]);
+ Assert.IsType(events[2]);
+ }
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
index 8e9254339..179e59fc2 100644
--- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
@@ -226,7 +226,7 @@ public void IgnoredCallbacks_EmitNothing()
{
var consumer = new InProcessEventConsumer();
- consumer.OnToolProgress("WebSearch", "query_update", "搜索中");
+ // OnToolProgress 已消费(TASK065 P3 bash 流式进度 — tool_progress 事件映射,见扩展映射测试)
consumer.OnLoopDetected(3, 0, "重复模式");
consumer.OnTimingSummary("耗时 1s");
diff --git a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
index e280cb106..1c8347543 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
@@ -334,6 +334,7 @@ public void Roundtrip_AllEvents_SurviveSerializeDeserialize()
new BridgeEvent.ThinkingStarted(),
new BridgeEvent.ThinkingEnded(4321),
new BridgeEvent.ToolCall("Bash", "ls -la"),
+ new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1\nline2"),
new BridgeEvent.ToolResult("3 files"),
new BridgeEvent.TokenDelta(12),
new BridgeEvent.AssistantMessage("完整回复 — 包含中文与 \"引号\""),
@@ -358,4 +359,31 @@ public void Roundtrip_AllEvents_SurviveSerializeDeserialize()
Assert.Null(diagnostic);
}
}
+
+ /// 工具进度事件 JSON 形状(TASK065 P3 — 与引擎侧 BridgeEvent.ToolProgress 逐字段镜像:
+ /// tool_progress + toolName/progressType/message camelCase 字段;message 多行经 \n 转义保真)。
+ [Fact]
+ public void SerializeEvent_ToolProgress_CarriesCamelCaseFields()
+ {
+ var line = BridgeProtocol.SerializeEvent(new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1\nline2"));
+
+ Assert.Equal(
+ "{\"type\":\"tool_progress\",\"toolName\":\"Bash\",\"progressType\":\"bash_progress\",\"message\":\"line1\\nline2\"}",
+ line);
+ }
+
+ /// 旧引擎(tool_progress 前)不发射该类型 — 新 TUI 反序列化兼容面不受影响;
+ /// 引擎产出的进度行反解析回读(record 位置字段全量校验)。
+ [Fact]
+ public void ReadEvent_ToolProgressLine_ParsesAllFields()
+ {
+ var reader = new StringReader("{\"type\":\"tool_progress\",\"toolName\":\"PowerShell\",\"progressType\":\"ps_progress\",\"message\":\"ok\"}\n");
+
+ var result = BridgeProtocol.TryReadEvent(reader, out var actual, out var diagnostic);
+
+ Assert.Equal(BridgeReadResult.Event, result);
+ var progress = Assert.IsType(actual);
+ Assert.Equal(("PowerShell", "ps_progress", "ok"), (progress.ToolName, progress.ProgressType, progress.Message));
+ Assert.Null(diagnostic);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
index 74850112b..1d573befe 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
@@ -513,4 +513,87 @@ public void Apply_BackgroundTaskFromJson_ParsesAndRoutesToPanel()
Assert.Equal("检查README", task.Description);
Assert.Equal("running", task.State);
}
+
+ /// TASK065 P3 工具流式进度 — 首个 tool_progress 惰性建流式项落位 ToolCallItem 之后
+ ///(spinner 前),后续快照原地 Update(同一项不重复落位)。
+ [Fact]
+ public void Apply_ToolProgress_CreatesStreamingItemAfterToolCallAndUpdatesInPlace()
+ {
+ _driver.Apply(new BridgeEvent.TurnStarted());
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls -la"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1"));
+ var streamingItem = Assert.IsType(_view.Messages.Items[^2]);
+
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1\nline2"));
+
+ Assert.Same(streamingItem, _view.Messages.Items[^2]);
+ Assert.Equal(["line1", "line2"], streamingItem.DisplayLines);
+ // spinner 恒为滚动区末项 — 流式项落位其前
+ Assert.Same(_spinner, _view.Messages.Items[^1]);
+ }
+
+ /// TASK065 P3 — tool_result 到达时流式项原位替换为 ToolResultItem
+ ///(对齐 StreamingTextItem 替换先例 — 输出尾巴位置落终态项,视觉无跳变)。
+ [Fact]
+ public void Apply_ToolResult_ReplacesStreamingItemInPlaceWithToolResultItem()
+ {
+ _driver.Apply(new BridgeEvent.TurnStarted());
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls -la"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1"));
+ var streamingIndex = _view.Messages.IndexOf(_view.Messages.Items.OfType().Single());
+
+ _driver.Apply(new BridgeEvent.ToolResult("3 files"));
+
+ var resultItem = Assert.IsType(_view.Messages.Items[streamingIndex]);
+ Assert.Equal("3 files", resultItem.Summary);
+ Assert.DoesNotContain(_view.Messages.Items, static item => item is ToolStreamingItem);
+ }
+
+ /// TASK065 P3 — 回合外(EndTurn 后)迟到 progress 丢弃(IsBusy 防御 —
+ /// 引擎漏发收尾事件时不跨回合泄漏新流式项)。
+ [Fact]
+ public void Apply_ToolProgress_AfterTurnCompleted_IsDropped()
+ {
+ _driver.Apply(new BridgeEvent.TurnStarted());
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "line1"));
+ _driver.Apply(new BridgeEvent.TurnCompleted());
+
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "late"));
+
+ Assert.DoesNotContain(_view.Messages.Items, static i => i is ToolStreamingItem item && item.DisplayLines.Contains("late"));
+ }
+
+ /// TASK065 P3 — 打断场景(无 tool_result)流式项残留定格:EndTurn 清引用不删项
+ ///(已见输出不丢 — 用户看到中断前输出)。
+ [Fact]
+ public void Apply_TurnInterrupted_StreamingItemStaysAsSeenOutput()
+ {
+ _driver.Apply(new BridgeEvent.TurnStarted());
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "partial output"));
+
+ _driver.Apply(new BridgeEvent.TurnInterrupted());
+
+ var streamingItem = Assert.Single(_view.Messages.Items.OfType());
+ Assert.Equal(["partial output"], streamingItem.DisplayLines);
+ }
+
+ /// TASK065 P3 — 新 tool_call 到达旧流式项定格(引用清空不删项 —
+ /// 引擎漏发上一工具 tool_result 时不串新工具输出到旧尾巴)。
+ [Fact]
+ public void Apply_NewToolCall_FinalizesPreviousStreamingItem()
+ {
+ _driver.Apply(new BridgeEvent.TurnStarted());
+ _driver.Apply(new BridgeEvent.ToolCall("Bash", "first"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Bash", "bash_progress", "old output"));
+
+ _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs"));
+ _driver.Apply(new BridgeEvent.ToolProgress("Read", "query_update", "searching"));
+
+ var streamingItems = _view.Messages.Items.OfType().ToList();
+ Assert.Equal(2, streamingItems.Count);
+ Assert.Equal(["old output"], streamingItems[0].DisplayLines);
+ Assert.Equal(["searching"], streamingItems[1].DisplayLines);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/markdown/InlineMarkdownParserTests.cs b/native_tui/tests/NativeTui.Tests/markdown/InlineMarkdownParserTests.cs
new file mode 100644
index 000000000..01246c5c6
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/markdown/InlineMarkdownParserTests.cs
@@ -0,0 +1,112 @@
+namespace NativeTui.Tests.Markdown;
+
+///
+/// 行内 Markdown 轻量解析器测试(TASK064/TASK065 P3)—
+/// 三件套标记(**bold** / *italic* / `code` / ***粗斜体***)、字面降级契约
+///(未闭合标记 / snake_case 下划线 / 纯文本 → 空列表)、多段顺序与平级不递归语义。
+///
+public sealed class InlineMarkdownParserTests
+{
+ /// 解析辅助(string → span 入口)。
+ private static IReadOnlyList Parse(string line) => InlineMarkdownParser.ParseLine(line);
+
+ /// 纯文本 — 空列表(降级信号)。
+ [Fact]
+ public void ParseLine_PlainText_EmptySegments()
+ {
+ Assert.Empty(Parse("hello world"));
+ }
+
+ /// 空行 — 空列表。
+ [Fact]
+ public void ParseLine_Empty_EmptySegments()
+ {
+ Assert.Empty(Parse(string.Empty));
+ }
+
+ /// 粗体 — plain/bold/plain 三段顺序。
+ [Fact]
+ public void ParseLine_BoldDelimited_ThreeOrderedSegments()
+ {
+ var segments = Parse("a **b** c");
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new MarkdownSegment("a ", MarkdownStyle.None), segments[0]);
+ Assert.Equal(new MarkdownSegment("b", MarkdownStyle.Bold), segments[1]);
+ Assert.Equal(new MarkdownSegment(" c", MarkdownStyle.None), segments[2]);
+ }
+
+ /// 斜体 — 单星定界中段 italic。
+ [Fact]
+ public void ParseLine_ItalicDelimited_ItalicSegment()
+ {
+ var segments = Parse("a *b* c");
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new MarkdownSegment("b", MarkdownStyle.Italic), segments[1]);
+ }
+
+ /// 行内代码 — 反引号定界中段 code。
+ [Fact]
+ public void ParseLine_CodeDelimited_CodeSegment()
+ {
+ var segments = Parse("run `x` now");
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new MarkdownSegment("x", MarkdownStyle.Code), segments[1]);
+ }
+
+ /// 三连星 — Bold|Italic 单段。
+ [Fact]
+ public void ParseLine_TripleDelimiters_BoldItalicSegment()
+ {
+ var segments = Parse("***x***");
+
+ var segment = Assert.Single(segments);
+ Assert.Equal(new MarkdownSegment("x", MarkdownStyle.Bold | MarkdownStyle.Italic), segment);
+ }
+
+ /// 未闭合星号(数学乘号)— 空列表字面降级。
+ [Fact]
+ public void ParseLine_UnclosedAsterisk_EmptySegments()
+ {
+ Assert.Empty(Parse("2 * 3 = 6"));
+ }
+
+ /// 未闭合反引号 — 空列表字面降级。
+ [Fact]
+ public void ParseLine_UnclosedBacktick_EmptySegments()
+ {
+ Assert.Empty(Parse("a ` b"));
+ }
+
+ /// 下划线不支持斜体 — snake_case 误判防护。
+ [Fact]
+ public void ParseLine_Underscore_NotDelimited_EmptySegments()
+ {
+ Assert.Empty(Parse("_var_name_"));
+ }
+
+ /// 多 code 段 — 顺序五段(plain 夹于其间)。
+ [Fact]
+ public void ParseLine_MultipleCodes_FiveOrderedSegments()
+ {
+ var segments = Parse("a `x` b `y` c");
+
+ Assert.Equal(5, segments.Count);
+ Assert.Equal(new MarkdownSegment("x", MarkdownStyle.Code), segments[1]);
+ Assert.Equal(new MarkdownSegment(" b ", MarkdownStyle.None), segments[2]);
+ Assert.Equal(new MarkdownSegment("y", MarkdownStyle.Code), segments[3]);
+ Assert.Equal(new MarkdownSegment(" c", MarkdownStyle.None), segments[4]);
+ }
+
+ /// 平级不递归 — 粗体内反引号字面保留(bold 段含 ` 字符)。
+ [Fact]
+ public void ParseLine_BoldWrappingCode_NoNesting_LiteralPreserved()
+ {
+ var segments = Parse("a **`x`** b");
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new MarkdownSegment("`x`", MarkdownStyle.Bold), segments[1]);
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/StashInputTests.cs b/native_tui/tests/NativeTui.Tests/repl/StashInputTests.cs
new file mode 100644
index 000000000..03368c83f
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/StashInputTests.cs
@@ -0,0 +1,159 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// ReplShell ctrl+s 输入暂存测试(TASK064 P3 — 基准 defaultBindings.ts:85 ctrl+s → chat:stash +
+/// PromptInputStashNotice.tsx:18 单槽暂存语义):忙/闲时暂存(输入清空 + dim 反馈消息上屏 +
+/// 单槽记录)、重复暂存覆盖旧槽、空输入无操作(不覆盖已有暂存)、提交后自动恢复
+///(排队路径 + 直发路径 — 提交动作发生后暂存文本回填输入框)、无暂存时提交不恢复。
+/// 构造形态对齐 ReplShellPanelNavigationTests / ReplShellIntegrationStopTaskTests。
+///
+public sealed class StashInputTests
+{
+ /// 构造测试 shell(默认登录门开 — 直发路径可达)。
+ private static (ReplShell Shell, FakeReplEngine Engine) CreateShell()
+ {
+ var engine = new FakeReplEngine();
+ var shell = new ReplShell(
+ engine,
+ new BannerItem("1.0.0", "m", "b", "/tmp"),
+ new StringWriter(),
+ sizeSource: new FakeTerminalSizeSource(80, 24));
+ shell.SeedContent();
+ return (shell, engine);
+ }
+
+ /// ctrl+s 注入(ConsoleKeyInfo 形态 — ConsoleKeyAdapter 归一 Ctrl+字母)。
+ private static void CtrlS(ReplShell shell) =>
+ shell.HandleKey(new ConsoleKeyInfo('s', ConsoleKey.S, shift: false, alt: false, control: true));
+
+ /// Enter 提交注入。
+ private static void Enter(ReplShell shell) =>
+ shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false));
+
+ /// 逐字符打字注入(形态对齐 ReplShellIntegrationStopTaskTests.Char)。
+ private static void Type(ReplShell shell, string text)
+ {
+ foreach (var c in text)
+ {
+ shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.A, shift: false, alt: false, control: false));
+ }
+ }
+
+ /// 驱动进入忙时回合(TurnStarted 事件排空投影)。
+ private static void MakeBusy(ReplShell shell, FakeReplEngine engine)
+ {
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+ }
+
+ /// 忙时 ctrl+s — 输入清空 + dim 反馈消息上屏 + 暂存单槽记录。
+ [Fact]
+ public void Stash_WhileBusy_ClearsInputPostsFeedbackAndRecords()
+ {
+ var (shell, engine) = CreateShell();
+ MakeBusy(shell, engine);
+ Type(shell, "abc");
+
+ CtrlS(shell);
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ Assert.Equal("abc", shell.StashedInput);
+ var feedback = shell.View.Messages.Items.OfType().Single();
+ Assert.Equal("已暂存(提交后自动恢复)", feedback.Text);
+ }
+
+ /// 闲时 ctrl+s 同样暂存(暂存非忙时专属 — 语义=收起当前输入腾出输入框)。
+ [Fact]
+ public void Stash_WhileIdle_ClearsInputAndRecords()
+ {
+ var (shell, _) = CreateShell();
+ Type(shell, "draft");
+
+ CtrlS(shell);
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ Assert.Equal("draft", shell.StashedInput);
+ Assert.Contains(shell.View.Messages.Items.OfType(), m => m.Text == "已暂存(提交后自动恢复)");
+ }
+
+ /// 重复 ctrl+s 覆盖旧暂存(单槽语义 — 非栈)。
+ [Fact]
+ public void Stash_Twice_OverwritesSingleSlot()
+ {
+ var (shell, _) = CreateShell();
+ Type(shell, "first");
+ CtrlS(shell);
+ Type(shell, "second");
+
+ CtrlS(shell);
+
+ Assert.Equal("second", shell.StashedInput);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// 空输入 ctrl+s — 无操作(无反馈消息 + 不覆盖已有暂存槽)。
+ [Fact]
+ public void Stash_EmptyInput_IsNoOpAndKeepsExistingSlot()
+ {
+ var (shell, _) = CreateShell();
+ Type(shell, "abc");
+ CtrlS(shell);
+
+ CtrlS(shell);
+
+ Assert.Equal("abc", shell.StashedInput);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ // 空输入无操作 — 反馈消息只此一条(第二次 ctrl+s 未追加)
+ Assert.Equal(1, shell.View.Messages.Items.OfType().Count(m => m.Text == "已暂存(提交后自动恢复)"));
+ }
+
+ /// 忙时暂存 → 忙时提交(排队路径)— 排队文本入队 + 暂存文本回填输入框 + 单槽清空。
+ [Fact]
+ public void Submit_WhileBusy_RestoresStashedAfterQueueing()
+ {
+ var (shell, engine) = CreateShell();
+ MakeBusy(shell, engine);
+ Type(shell, "abc");
+ CtrlS(shell);
+ Type(shell, "def");
+
+ Enter(shell);
+
+ // 排队路径 — 引擎零直发,def 入队
+ Assert.Empty(engine.Submits);
+ Assert.Contains("def", shell.View.QueuedInputs);
+ // 提交动作已发生 — 暂存回填 + 单槽清空
+ Assert.Equal("abc", shell.Input.Text);
+ Assert.Equal(string.Empty, shell.StashedInput);
+ }
+
+ /// 暂存 → 闲时提交(直发路径)— 引擎收到提交 + 暂存文本回填输入框 + 单槽清空。
+ [Fact]
+ public void Submit_WhileIdle_RestoresStashedAfterDirectSend()
+ {
+ var (shell, engine) = CreateShell();
+ Type(shell, "abc");
+ CtrlS(shell);
+ Type(shell, "def");
+
+ Enter(shell);
+
+ Assert.Equal(["def"], engine.Submits);
+ Assert.Equal("abc", shell.Input.Text);
+ Assert.Equal(string.Empty, shell.StashedInput);
+ }
+
+ /// 无暂存时提交 — 输入框保持清空(不恢复不误填)。
+ [Fact]
+ public void Submit_WithoutStash_KeepsInputCleared()
+ {
+ var (shell, engine) = CreateShell();
+ Type(shell, "def");
+
+ Enter(shell);
+
+ Assert.Equal(["def"], engine.Submits);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ Assert.Equal(string.Empty, shell.StashedInput);
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs
index 9d378b86a..77fbf5319 100644
--- a/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs
@@ -111,6 +111,121 @@ public void Toggle_NoContent_NoOp()
Assert.False(item.IsThinkingExpanded);
}
+ // ---- CoT Markdown 渲染(TASK064/TASK065 P3 — dim 基调行内三件套) ----
+
+ /// 展开态 CoT 粗体段 — Bold+Dim 叠加(基准思考块 = dim Markdown),标记字符 ** 吃掉不显示。
+ [Fact]
+ public void Render_ExpandedCoT_BoldSegment_DimBoldStyle()
+ {
+ using var screen = new Screen(cols: 60, rows: 16);
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "see **bold** here", messageId: 1);
+
+ item.ToggleThinking();
+ item.Render(screen, new Rect(FrameX, 2, 60, 5), Theme.Dark);
+
+ // CoT 行 row4(margin 1 + 摘要 1);indent 7 列(⎿ 前缀 5 + 2 空格)+ "see " 4 列 → bold 段起 col11
+ var dimBold = screen.Styles.GetOrAdd(new TextStyle { Bold = true, Dim = true });
+ Assert.Equal(new Rune('b'), screen[4, 11].Value);
+ Assert.Equal(dimBold, screen[4, 11].StyleId);
+ Assert.Equal(dimBold, screen[4, 14].StyleId);
+ Assert.DoesNotContain("**", screen.RowText(4));
+ }
+
+ /// 展开态 CoT 行内代码段 — permission 前景 + Dim 叠加(基准 codespan 色在 dim 块内)。
+ [Fact]
+ public void Render_ExpandedCoT_InlineCode_DimPermissionStyle()
+ {
+ using var screen = new Screen(cols: 60, rows: 16);
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "run `dotnet` now", messageId: 1);
+
+ item.ToggleThinking();
+ item.Render(screen, new Rect(FrameX, 2, 60, 5), Theme.Dark);
+
+ // indent 7 列 + "run " 4 列 → code 段起 col11
+ var dimCode = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Permission, Dim = true });
+ Assert.Equal(new Rune('d'), screen[4, 11].Value);
+ Assert.Equal(dimCode, screen[4, 11].StyleId);
+ Assert.DoesNotContain("`", screen.RowText(4));
+ }
+
+ /// 无行内语法(含未闭合 * 字面)— 整行纯文本 dim 现行为完整保留。
+ [Fact]
+ public void Render_ExpandedCoT_NoSyntax_PlainDimPreserved()
+ {
+ using var screen = new Screen(cols: 60, rows: 16);
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "2 * 3 = 6", messageId: 1);
+
+ item.ToggleThinking();
+ item.Render(screen, new Rect(FrameX, 2, 60, 5), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Contains("2 * 3 = 6", screen.RowText(4));
+ for (var col = 7; col < 7 + "2 * 3 = 6".Length; col++)
+ {
+ Assert.Equal(dim, screen[4, col].StyleId);
+ }
+ }
+
+ /// 块级特征行(标题/列表/引用)— 不做块级解析,整行纯文本 dim。
+ [Fact]
+ public void Render_ExpandedCoT_BlockFeatureLines_StayPlainDim()
+ {
+ using var screen = new Screen(cols: 60, rows: 16);
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "# 标题\n- 列表项\n> 引用", messageId: 1);
+
+ item.ToggleThinking();
+ item.Render(screen, new Rect(FrameX, 2, 60, 6), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(dim, screen[4, 7].StyleId);
+ Assert.Equal(dim, screen[5, 7].StyleId);
+ Assert.Equal(dim, screen[6, 7].StyleId);
+ }
+
+ /// 截断守卫语义不变 — 超限 200 行截断 + 提示行(行数按 \n 计与段渲染无关)。
+ [Fact]
+ public void Render_ExpandedCoT_TruncationGuard_Unchanged()
+ {
+ using var screen = new Screen(cols: 60, rows: 210);
+ var cot = string.Join("\n", Enumerable.Range(1, 201).Select(static i => $"行{i}"));
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: cot, messageId: 1);
+
+ item.ToggleThinking();
+ var expected = 2 + 1 + 200 + 1;
+ Assert.Equal(expected, item.MeasureHeight(60));
+ item.Render(screen, new Rect(FrameX, 2, 60, expected), Theme.Dark);
+
+ Assert.Contains("行200", screen.RowText(2 + 1 + 200));
+ Assert.Contains("已截断", screen.RowText(2 + 1 + 200 + 1));
+ }
+
+ /// 段渲染不改行高 — 多行含语法 CoT 高度 = Split('\n') 行数语义。
+ [Fact]
+ public void Render_ExpandedCoT_MarkdownHeight_MatchesLineCount()
+ {
+ var item = new AssistantMessageItem("正文", thinkingDurationMs: 500, thinkingContent: "先看 **上下文**\n再算 `1+1`\n最后归纳", messageId: 1);
+
+ item.ToggleThinking();
+
+ Assert.Equal(2 + 1 + 3, item.MeasureHeight(60));
+ }
+
+ /// MarkdownMessageItem 展开态 CoT — 同 AssistantMessageItem 的 dim Markdown 行内渲染路径。
+ [Fact]
+ public void Render_MarkdownItem_ExpandedCoT_BoldSegment_DimBoldStyle()
+ {
+ using var screen = new Screen(cols: 60, rows: 16);
+ var item = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([new MarkdownSegment("正文", MarkdownStyle.None)])]),
+ thinkingDurationMs: 500, thinkingContent: "see **bold** here", messageId: 1);
+
+ item.ToggleThinking();
+ item.Render(screen, new Rect(FrameX, 2, 60, 5), Theme.Dark);
+
+ var dimBold = screen.Styles.GetOrAdd(new TextStyle { Bold = true, Dim = true });
+ Assert.Equal(new Rune('b'), screen[4, 11].Value);
+ Assert.Equal(dimBold, screen[4, 11].StyleId);
+ }
+
// ---- 交互(ReplShell Ctrl+O) ----
/// Ctrl+O — 进入 transcript 全局模式(M8-3/S6 收敛语义演进:单条切换 → Controller
diff --git a/native_tui/tests/NativeTui.Tests/repl/ToolStreamingItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/ToolStreamingItemTests.cs
new file mode 100644
index 000000000..68ddf0487
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/ToolStreamingItemTests.cs
@@ -0,0 +1,137 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// 工具流式输出项渲染测试(TASK065 P3 — Bash 工具运行中输出实时显示)— 对齐基准 Bash
+/// 流式 tail 形态:最近 3 行 dim 输出环形(消息为引擎侧每秒 stdout 快照 → Update 整体替换),
+/// 超出 3 行 "… +N lines" 溢出提示行前置(上方被截语义 — 尾部行恒可见),
+/// 单行超长截断(200 上限 — 对齐结果摘要截断先例);首行 " ⎿ " 前缀 + 续行等宽缩进
+///(对齐 ToolResultItem 输出块形态)整行 dim(流态弱化 — tool_result 替换后恢复正式形态)。
+///
+public sealed class ToolStreamingItemTests
+{
+ /// 超出尾部上限(5 行快照)— 环形保留最近 3 行 + "… +2 lines" 溢出提示行前置。
+ [Fact]
+ public void Update_KeepsLastThreeLinesWithOverflowHint()
+ {
+ var item = new ToolStreamingItem();
+
+ item.Update("line1\nline2\nline3\nline4\nline5");
+
+ Assert.Equal(["… +2 lines", "line3", "line4", "line5"], item.DisplayLines);
+ Assert.Equal(2, item.HiddenLines);
+ }
+
+ /// 快照内行数不超上限 — 无溢出提示行直出全量(快照本身已是引擎侧最近行)。
+ [Fact]
+ public void Update_WithinTailLimit_NoOverflowHint()
+ {
+ var item = new ToolStreamingItem();
+
+ item.Update("only\ntwo lines");
+
+ Assert.Equal(["only", "two lines"], item.DisplayLines);
+ Assert.Equal(0, item.HiddenLines);
+ }
+
+ /// 快照整体替换语义 — 第二次 Update 覆盖第一次(引擎发的是当前 stdout 最近行快照非增量)。
+ [Fact]
+ public void Update_ReplacesPreviousSnapshotEntirely()
+ {
+ var item = new ToolStreamingItem();
+ item.Update("old1\nold2\nold3\nold4");
+
+ item.Update("new1");
+
+ Assert.Equal(["new1"], item.DisplayLines);
+ Assert.Equal(0, item.HiddenLines);
+ }
+
+ /// 单行超长截断 — 200 字符上限 + … 尾(对齐 ToolResultSummaryLimit=200 显示截断先例)。
+ [Fact]
+ public void Update_TruncatesSingleLongLineToLimit()
+ {
+ var item = new ToolStreamingItem();
+ var longLine = new string('x', 500);
+
+ item.Update(longLine);
+
+ var line = Assert.Single(item.DisplayLines);
+ Assert.Equal(200, line.Length);
+ Assert.Equal("…", line[^1].ToString());
+ }
+
+ /// 空快照清空尾巴 — 高度回落仅 margin(无尾巴零占屏 — 流式项空态不渲染行)。
+ [Fact]
+ public void Update_EmptyMessageClearsTail()
+ {
+ var item = new ToolStreamingItem();
+ item.Update("some output");
+
+ item.Update(string.Empty);
+
+ Assert.Empty(item.DisplayLines);
+ Assert.Equal(0, item.HiddenLines);
+ Assert.Equal(1, item.MeasureHeight(40));
+ }
+
+ /// 内容高度 = margin 1 + 显示行数(溢出提示行计入)。
+ [Fact]
+ public void MeasureHeight_MarginPlusDisplayLineCount()
+ {
+ var item = new ToolStreamingItem();
+ Assert.Equal(1, item.MeasureHeight(40));
+
+ item.Update("line1\nline2\nline3\nline4\nline5");
+ Assert.Equal(5, item.MeasureHeight(40));
+ }
+
+ /// 渲染形态 — margin 行留空;首行 " ⎿ " dim 前缀 + 内容 dim(流态整体弱化);
+ /// 续行 ToolResultIndent 等宽空格缩进(5 列 — 对齐 ToolResultItem 输出续行形态)。
+ [Fact]
+ public void Render_DimIndentPrefixWithContinuationIndent()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new ToolStreamingItem();
+ item.Update("line1\nline2");
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ // row0 margin 留空;row1 = " ⎿ " dim 前缀 + dim 正文
+ Assert.Equal(" ⎿ ", RowText.Of(screen, 1, 0, 5));
+ Assert.Equal(dim, screen[1, 0].StyleId);
+ Assert.Equal("line1", RowText.Of(screen, 1, 5, 5));
+ Assert.Equal(dim, screen[1, 5].StyleId);
+ // 续行 5 空格等宽缩进 + dim 正文
+ Assert.Equal(" line2", RowText.Of(screen, 2, 0, 10));
+ Assert.Equal(dim, screen[2, 5].StyleId);
+ }
+
+ /// 溢出提示行渲染 — "… +N lines" 整行 dim 前置(上方被截语义 — 尾部行恒可见)。
+ [Fact]
+ public void Render_OverflowHintPrecedesTailLines()
+ {
+ using var screen = new Screen(cols: 40, rows: 6);
+ var item = new ToolStreamingItem();
+ item.Update("l1\nl2\nl3\nl4\nl5");
+
+ item.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark);
+
+ Assert.Equal(" ⎿ … +2 lines", RowText.Of(screen, 1, 0, 15));
+ Assert.Equal(" l3", RowText.Of(screen, 2, 0, 7));
+ Assert.Equal(" l4", RowText.Of(screen, 3, 0, 7));
+ Assert.Equal(" l5", RowText.Of(screen, 4, 0, 7));
+ }
+
+ /// transcript 搜索匹配 — 显示行内容(含溢出提示行)。
+ [Fact]
+ public void ContainsText_MatchesDisplayLines()
+ {
+ var item = new ToolStreamingItem();
+ item.Update("compiling module\nlinking");
+
+ Assert.True(item.ContainsText("linking"));
+ Assert.True(item.ContainsText("MODULE"));
+ Assert.False(item.ContainsText("absent"));
+ }
+}
diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerExtensionTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerExtensionTests.cs
index eee286af1..95ef8dda0 100644
--- a/test/unit/host.tests/bridge/BridgeEventConsumerExtensionTests.cs
+++ b/test/unit/host.tests/bridge/BridgeEventConsumerExtensionTests.cs
@@ -124,4 +124,50 @@ public void OnDone_AcrossReset_EmitsTokenUsageEachTurn()
Assert.Equal(2, @out.ToString().Split('\n').Count(static line => line.Contains("\"token_usage\"")));
}
+
+ /// 工具进度映射(TASK065 P3)— OnToolProgress → tool_progress 事件行
+ ///(progressType 透传引擎侧既有类型;多行 message 经 JSON 转义保真 — Bash 执行中输出快照)。
+ [Fact]
+ public void OnToolProgress_EmitsToolProgressEventLine()
+ {
+ var (consumer, @out) = Create();
+
+ consumer.OnToolProgress("Bash", "bash_progress", "line1\nline2");
+
+ Assert.Equal(
+ "{\"type\":\"tool_progress\",\"toolName\":\"Bash\",\"progressType\":\"bash_progress\",\"message\":\"line1\\nline2\"}\n",
+ @out.ToString());
+ }
+
+ /// 工具进度 null message — 空串投影(TUI 侧更新为空尾巴自然收敛,事件形状恒定不带 null 字段)。
+ [Fact]
+ public void OnToolProgress_NullMessage_ProjectsEmptyString()
+ {
+ var (consumer, @out) = Create();
+
+ consumer.OnToolProgress("Bash", "bash_progress", null);
+
+ Assert.Equal(
+ "{\"type\":\"tool_progress\",\"toolName\":\"Bash\",\"progressType\":\"bash_progress\",\"message\":\"\"}\n",
+ @out.ToString());
+ }
+
+ /// 工具执行序列行序(TASK065 P3)— OnToolStart → OnToolProgress → OnToolEnd
+ /// 产出 tool_call → tool_progress → tool_result 行序(流式进度夹在调用与结果之间 — TUI 侧
+ /// 流式项建项/替换时序的数据源契约)。
+ [Fact]
+ public void OnToolSequence_ProgressFlowsBetweenCallAndResult()
+ {
+ var (consumer, @out) = Create();
+
+ consumer.OnToolStart("Bash", "call-1", "{\"command\":\"ls\"}");
+ consumer.OnToolProgress("Bash", "bash_progress", "file.txt");
+ consumer.OnToolEnd("Bash", "1 file", "call-1", isError: false, patch: null);
+
+ Assert.Equal(
+ "{\"type\":\"tool_call\",\"name\":\"Bash\",\"args\":\"{\\\"command\\\":\\\"ls\\\"}\"}\n" +
+ "{\"type\":\"tool_progress\",\"toolName\":\"Bash\",\"progressType\":\"bash_progress\",\"message\":\"file.txt\"}\n" +
+ "{\"type\":\"tool_result\",\"summary\":\"1 file\"}\n",
+ @out.ToString());
+ }
}
diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
index 0b1cad2e9..f25702772 100644
--- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
+++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs
@@ -160,4 +160,24 @@ public void Serialize_SubagentEvents_CarryCamelCaseFields() {
Assert.Equal(expected[i], JsonSerializer.Serialize(events[i], CliBridge.BridgeJsonContext.Safe.BridgeEvent));
}
}
+
+ /// 工具进度事件 JSON 形状(TASK065 P3 — 与 native_tui 侧 BridgeEvent.ToolProgress
+ /// 逐字段镜像:tool_progress + toolName/progressType/message camelCase 字段;
+ /// message 多行文本经 JSON 转义 \n 保真 — Bash 执行中输出快照流式上屏数据源)。
+ [Fact]
+ public void Serialize_ToolProgress_CarriesCamelCaseFields() {
+ var json = JsonSerializer.Serialize(
+ new CliBridge.BridgeEvent.ToolProgress("Bash", "bash_progress", "line1\nline2"),
+ CliBridge.BridgeJsonContext.Safe.BridgeEvent);
+ Assert.Equal(
+ "{\"type\":\"tool_progress\",\"toolName\":\"Bash\",\"progressType\":\"bash_progress\",\"message\":\"line1\\nline2\"}",
+ json);
+
+ // native_tui 侧引擎产出的行反解析回读(双镜像 roundtrip)
+ var request = JsonSerializer.Deserialize(
+ "{\"type\":\"tool_progress\",\"toolName\":\"PowerShell\",\"progressType\":\"ps_progress\",\"message\":\"ok\"}",
+ CliBridge.BridgeJsonContext.Safe.BridgeEvent);
+ var progress = Assert.IsType(request);
+ Assert.Equal(("PowerShell", "ps_progress", "ok"), (progress.ToolName, progress.ProgressType, progress.Message));
+ }
}