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)); + } }