diff --git a/README.md b/README.md index c576116..09ab581 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # AX Commander +- 업데이트: 2026-04-15 18:30 (KST) +- Code 탭에서 동일 도구 호출이 같은 시그니처로 반복될 때 빠져나오지 못하던 루프를 보강했습니다. `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`에 일반 반복 시그니처 가드를 추가하고, `src/AxCopilot/Services/Agent/AgentLoopService.cs`가 읽기 전용 도구뿐 아니라 `build_run` 같은 실행 도구 반복도 감지해 다른 접근으로 전환하도록 정리했습니다. +- `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`는 상단 라이브 진행 영역을 1~2줄 요약 카드로 축소하고, 실제 ToolCall/ToolResult 이력은 채팅 본문 타임라인에 계속 누적되도록 바꿨습니다. 내부 대기성 Thinking/LLM 대기 문구는 본문에서 더 공격적으로 숨겨 실행 이력이 덜 지저분하게 보이도록 조정했습니다. +- `src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs`는 진행 문구를 다시 정리해 “무엇을 확인 중인지, 왜 이 단계를 거치는지”가 짧고 친절하게 보이도록 다듬었고, `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`와 `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`의 반복 대기 로그는 `Info` 대신 `Debug` 중심으로 낮춰 로그 소음을 줄였습니다. +- `src/AxCopilot/Services/MarkdownRenderer.cs`와 채팅 메시지 렌더 경로(`src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`, `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`)는 선택 가능한 마크다운 뷰를 도입해, Cowork/Code 본문 텍스트를 드래그 선택하고 복사할 수 있게 했습니다. +- 테스트는 `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`, `src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs`, `src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs`를 보강했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\` 131개 통과를 확인했습니다. + - 업데이트: 2026-04-15 17:41 (KST) - `~` 워크스페이스 저장/복원에 파일 탐색기와 메모장 상태 복원을 추가했습니다. `src/AxCopilot/Core/AppWorkspaceStateHelper.cs`가 파일 탐색기 현재 폴더 경로와 메모장 열린 파일 경로를 best-effort로 수집하고, `src/AxCopilot/Core/ProcessCommandLineHelper.cs`가 프로세스 명령줄 파싱을 공용화합니다. - `src/AxCopilot/Core/ContextManager.cs`는 저장된 탐색기/메모장 상태가 현재 창 제목과 다를 때 새 창을 띄워 원래 경로와 파일을 다시 열고, 새 창 연속 실행 시에는 CPU·메모리 부하와 이미 실행한 창 수를 반영한 적응형 지연을 넣어 복원 속도를 자동 조절합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d2f49b8..1ba248d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1471,3 +1471,10 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 寃€利? - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_internal_llm_scope\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope\\` 寃쎄퀬 0 / ?ㅻ쪟 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "LlmOperationModeTests" -p:OutputPath=bin\\verify_internal_llm_scope_tests\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope_tests\\` ?듦낵 5 +업데이트: 2026-04-15 18:30 (KST) +- AX Agent 실행 루프의 반복 호출 방어를 보강했습니다. `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`는 동일 시그니처 도구 호출이 계속 반복될 때 read-only 전용 가드 외에 일반 실행 도구용 가드도 적용하고, `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 이 전환을 메인 루프에 연결해 `build_run`, `process`, `wait_agents` 류 호출이 무의미하게 되풀이될 때 다른 접근을 강제하도록 정리했습니다. +- 채팅 상단 라이브 진행 표현을 요약형으로 재구성했습니다. `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`는 상단 카드를 1~2줄 서술형 상태 카드로 단순화했고, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`는 스트리밍 중 발생한 ToolCall/ToolResult 이벤트를 더 이상 라이브 카드 안에만 가두지 않고 본문 타임라인에 누적되게 바꿨습니다. +- 실행 이력의 노이즈도 함께 줄였습니다. `src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`, `src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs`는 모델 응답 대기, 내부 재시도, 저신호 Thinking 요약을 기본 로그/본문에서 더 적극적으로 숨기고, `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`와 `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`는 반복적인 대기 로그를 `Debug` 수준으로 낮췄습니다. +- 사용자에게 보이는 작업 설명도 `src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs`에서 다시 정리했습니다. 코드 탐색, 수정, 실행, 문서화, 권한 대기 같은 단계가 더 짧고 친절한 한국어 문구로 노출되며, 대상 파일/명령/쿼리 같은 힌트는 detail 줄로 별도 노출됩니다. +- 채팅 본문 드래그 복사도 지원합니다. `src/AxCopilot/Services/MarkdownRenderer.cs`에 선택 가능한 RichTextBox 기반 마크다운 렌더를 추가했고, `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`, `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`가 Cowork/Code 본문에 이를 사용하도록 연결했습니다. +- 테스트는 `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`, `src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs`, `src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs`를 갱신했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\` 131개 통과를 확인했습니다. diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index 3fa93a4..c46f868 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -781,6 +781,38 @@ public class AgentLoopCodeQualityTests blocked.Should().BeFalse(); } + [Fact] + public void ShouldBlockRepeatedNoProgressToolLoop_BlocksRepeatedExecutionTools() + { + var blocked = InvokePrivateStatic( + "ShouldBlockRepeatedNoProgressToolLoop", + "build_run", + 6); + var notYet = InvokePrivateStatic( + "ShouldBlockRepeatedNoProgressToolLoop", + "build_run", + 5); + + blocked.Should().BeTrue(); + notYet.Should().BeFalse(); + } + + [Fact] + public void ShouldBlockRepeatedNoProgressToolLoop_GivesPollingToolsMoreRoom() + { + var notYet = InvokePrivateStatic( + "ShouldBlockRepeatedNoProgressToolLoop", + "wait_agents", + 6); + var blocked = InvokePrivateStatic( + "ShouldBlockRepeatedNoProgressToolLoop", + "wait_agents", + 7); + + notYet.Should().BeFalse(); + blocked.Should().BeTrue(); + } + [Fact] public void ShouldRequestDocumentArtifact_RequiresDocsTaskAndMissingArtifact() { diff --git a/src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs b/src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs index 76f6fc9..4fc6368 100644 --- a/src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs +++ b/src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs @@ -6,42 +6,23 @@ namespace AxCopilot.Tests.Services; public class AgentProgressSummarySanitizerTests { - [Theory] - [InlineData("1")] - [InlineData("1.")] - [InlineData("[")] - [InlineData("[O/")] - [InlineData("file_read]")] - public void NormalizeThinkingSummary_ShouldDropLowSignalFragments(string fragment) + [Fact] + public void IsLowSignalStatusSummary_ReturnsTrueForModelWaitMessages() { - AgentProgressSummarySanitizer.NormalizeThinkingSummary(fragment).Should().BeEmpty(); + AgentProgressSummarySanitizer.IsLowSignalStatusSummary( + "수정 단계: 모델 첫 응답을 기다리는 중입니다... (8초)", + "") + .Should() + .BeTrue(); } [Fact] - public void NormalizeThinkingSummary_ShouldStripPreviousToolCallTranscriptHints() + public void IsLowSignalStatusSummary_ReturnsFalseForConcreteWorkMessages() { - var summary = """ - [이전 도구 호출: file_read] - - 1. time-clock.html 파일을 새로 생성했습니다. - """; - - var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary); - - normalized.Should().Be("time-clock.html 파일을 새로 생성했습니다."); - } - - [Fact] - public void NormalizeThinkingSummary_ShouldTrimAndClampMeaningfulText() - { - var summary = """ - 1. 현재 디렉터리 확인 중 - 2. 필요한 HTML 구조를 정리하고 있습니다. - """; - - var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary, maxLength: 20); - - normalized.Should().StartWith("현재 디렉터리 확인 중 필요한"); - normalized.Should().EndWith("…"); + AgentProgressSummarySanitizer.IsLowSignalStatusSummary( + "ChatWindow.xaml.cs와 AgentLoopService.cs를 비교해 수정 범위를 정리하고 있습니다.", + "file_read") + .Should() + .BeFalse(); } } diff --git a/src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs b/src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs index d4fdcec..62f96a2 100644 --- a/src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs @@ -31,7 +31,7 @@ public class AgentStatusNarrativeCatalogTests { Type = AgentEventType.PermissionRequest, ToolName = "file_edit", - Summary = "권한 확인 필요(Deny) · 대상: src/AxCopilot/Views/ChatWindow.xaml.cs" + Summary = "권한 확인 필요: src/AxCopilot/Views/ChatWindow.xaml.cs" }; var narrative = AgentStatusNarrativeCatalog.BuildFromEvent(evt, "Code"); diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 9eea246..e42d98f 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -1274,7 +1274,21 @@ public partial class AgentLoopService : 1; if (TryHandleNoProgressReadOnlyLoopTransition( - call, + effectiveCall, + toolCallSignature, + previewRepeatedToolCount, + messages, + lastModifiedCodeFilePath, + requireHighImpactCodeVerification, + taskPolicy)) + { + lastAnyToolSignature = toolCallSignature; + repeatedAnyToolSignatureCount = previewRepeatedToolCount; + continue; + } + + if (TryHandleRepeatedNoProgressToolLoopTransition( + effectiveCall, toolCallSignature, previewRepeatedToolCount, messages, diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 22dc6c5..0a8df55 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -497,6 +497,41 @@ public partial class AgentLoopService return true; } + private bool TryHandleRepeatedNoProgressToolLoopTransition( + ContentBlock call, + string toolCallSignature, + int repeatedSameSignatureCount, + List messages, + string? lastModifiedCodeFilePath, + bool requireHighImpactCodeVerification, + TaskTypePolicy taskPolicy) + { + if (!ShouldBlockRepeatedNoProgressToolLoop( + call.ToolName, + repeatedSameSignatureCount)) + return false; + + messages.Add(LlmService.CreateToolResultMessage( + call.ToolId, + call.ToolName, + $"[NO_PROGRESS_SIGNATURE_GUARD] 동일한 도구 호출이 {repeatedSameSignatureCount}회 반복되었습니다. {toolCallSignature}\n" + + "Stop repeating the same tool call and switch to a concrete next action that creates new evidence or state change.")); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildNoProgressLoopRecoveryPrompt( + call.ToolName, + lastModifiedCodeFilePath, + requireHighImpactCodeVerification, + taskPolicy) + }); + EmitEvent( + AgentEventType.Thinking, + call.ToolName, + $"동일한 도구 호출이 반복되어 다른 방식으로 전환합니다({repeatedSameSignatureCount}회)"); + return true; + } + private bool TryHandleReadOnlyStagnationTransition( int consecutiveReadOnlySuccessTools, List messages, @@ -684,6 +719,26 @@ public partial class AgentLoopService return AgentToolCatalog.IsReadOnly(toolName); } + private static bool ShouldBlockRepeatedNoProgressToolLoop( + string toolName, + int repeatedSameSignatureCount) + { + if (string.IsNullOrWhiteSpace(toolName) || AgentToolCatalog.IsReadOnly(toolName)) + return false; + + var baseThreshold = Math.Max(GetReadOnlySignatureLoopThreshold() + 1, 5); + if (IsPollingOrCoordinationTool(toolName)) + return repeatedSameSignatureCount >= baseThreshold + 2; + + if (IsMutatingOrExecutionProgressTool(toolName)) + return repeatedSameSignatureCount >= baseThreshold + 1; + + return repeatedSameSignatureCount >= baseThreshold; + } + + private static bool IsPollingOrCoordinationTool(string toolName) + => toolName is "wait_agents" or "task_output" or "task_get" or "task_list"; + private static int GetReadOnlySignatureLoopThreshold() { return ResolveConfiguredOrEnvThresholdValue( diff --git a/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs b/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs index 840abc4..0ce5483 100644 --- a/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs +++ b/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs @@ -57,6 +57,24 @@ internal static class AgentProgressSummarySanitizer return normalized; } + public static bool IsLowSignalStatusSummary(string? summary, string? toolName = null) + { + var normalized = NormalizeThinkingSummary(summary, toolName, maxLength: 160); + if (string.IsNullOrWhiteSpace(normalized)) + return true; + + var lower = normalized.ToLowerInvariant(); + return lower.Contains("모델에 요청하는 중") + || lower.Contains("모델 첫 응답") + || lower.Contains("계속 기다리는 중") + || lower.Contains("응답을 기다리는 중") + || lower.Contains("스트리밍 중간 응답") + || lower.Contains("일시적 llm 오류") + || lower.Contains("gemini 무료 티어 대기") + || lower.Contains("도구명 정규화 적용") + || lower.Contains("[agentloopwait]"); + } + private static string CleanThinkingLine(string line) { var cleaned = s_listPrefixRegex.Replace(line.Trim(), string.Empty).Trim(); diff --git a/src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs b/src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs index 2da8537..f0c1534 100644 --- a/src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs +++ b/src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs @@ -16,12 +16,12 @@ internal static class AgentStatusNarrativeCatalog public static AgentStatusNarrative BuildInitial(string? runTab) => IsCodeTab(runTab) ? new AgentStatusNarrative( - "관련 코드와 수정 범위를 파악하고 있습니다...", - "필요한 파일과 테스트 범위를 좁혀서 읽기 시작합니다.", + "작업 범위와 관련 파일을 먼저 파악하고 있습니다...", + "필요한 코드, 로그, 테스트 범위를 정리한 뒤 바로 수정 단계로 이어갑니다.", "initialize") : new AgentStatusNarrative( "요청 목적과 필요한 자료를 정리하고 있습니다...", - "답변 구조와 필요한 문서·근거를 먼저 가볍게 확인합니다.", + "관련 문서와 작업 범위를 확인한 뒤 바로 다음 단계로 이어갑니다.", "initialize"); public static AgentStatusNarrative BuildFromEvent(AgentEvent evt, string? runTab) @@ -53,7 +53,7 @@ internal static class AgentStatusNarrativeCatalog { return new AgentStatusNarrative( "긴 대화를 이어가기 위해 컨텍스트를 정리하고 있습니다...", - "최근 대화와 작업 결과를 압축한 뒤 이어서 진행합니다.", + "최근 작업 결과를 압축해 다음 단계에 필요한 정보만 남기고 있습니다.", "compact"); } @@ -71,7 +71,7 @@ internal static class AgentStatusNarrativeCatalog if (idle >= TimeSpan.FromSeconds(90)) { return new AgentStatusNarrative( - "작업 범위가 커서 현재까지 수집한 내용을 종합하고 있습니다...", + "현재까지 확인한 내용과 다음 단계를 차분히 정리하고 있습니다...", BuildIdleDetail(category), category); } @@ -120,12 +120,12 @@ internal static class AgentStatusNarrativeCatalog { return category switch { - "read" => "관련 범위를 읽고 확인하는 중", - "edit" => "변경 내용을 적용하는 중", + "read" => "관련 코드와 파일을 확인하는 중", + "edit" => "변경을 적용하는 중", "execute" => "실행 결과를 확인하는 중", - "document" => "문서 산출물을 구성하는 중", + "document" => "문서 내용을 구성하는 중", "git" => "변경 범위를 확인하는 중", - "web" => "외부 정보를 정리하는 중", + "web" => "필요한 정보를 정리하는 중", _ => row.Title, }; } @@ -134,7 +134,7 @@ internal static class AgentStatusNarrativeCatalog { return category switch { - "read" => "읽은 내용을 정리하는 중", + "read" => "확인한 내용을 정리하는 중", "edit" => "적용한 변경을 정리하는 중", "execute" => "실행 결과를 분석하는 중", "document" => "생성 결과를 검토하는 중", @@ -157,24 +157,24 @@ internal static class AgentStatusNarrativeCatalog AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt))); if (category == "compact") - return "컨텍스트 압축 중..."; + return "컨텍스트를 정리하는 중..."; if (evt.Type == AgentEventType.Thinking && string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)) - return "응답을 정리하는 중..."; + return "다음 응답을 준비하는 중..."; return evt.Type switch { AgentEventType.Planning => "작업 순서를 정리하는 중...", AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행 중...", AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 정리 중...", - AgentEventType.PermissionRequest => "권한 확인 대기 중...", + AgentEventType.PermissionRequest => "권한 확인을 기다리는 중...", AgentEventType.PermissionDenied => "대체 경로를 검토하는 중...", AgentEventType.ToolCall => category switch { "read" => "관련 파일을 확인하는 중...", - "edit" => "변경 내용을 적용하는 중...", + "edit" => "변경을 적용하는 중...", "execute" => "실행 결과를 확인하는 중...", - "document" => "산출물을 구성하는 중...", + "document" => "문서를 구성하는 중...", "git" => "변경 범위를 정리하는 중...", "web" => "필요한 정보를 찾는 중...", _ => null, @@ -182,7 +182,7 @@ internal static class AgentStatusNarrativeCatalog AgentEventType.ToolResult => category switch { "read" => "읽은 내용을 정리하는 중...", - "edit" => "적용한 변경을 점검하는 중...", + "edit" => "수정 내용을 정리하는 중...", "execute" => "실행 결과를 분석하는 중...", "document" => "생성 결과를 검토하는 중...", _ => null, @@ -207,16 +207,16 @@ internal static class AgentStatusNarrativeCatalog AgentEventType.Planning => "계획", AgentEventType.StepStart or AgentEventType.StepDone => "단계", AgentEventType.PermissionRequest => "권한", - AgentEventType.PermissionGranted => "권한 승인", + AgentEventType.PermissionGranted => "권한 확인", AgentEventType.PermissionDenied => "권한 거부", AgentEventType.ToolCall => category switch { "read" => "탐색", "edit" => "수정", "execute" => "실행", - "document" => "산출물", + "document" => "문서", "git" => "Git", - "web" => "웹", + "web" => "자료", _ => "도구", }, AgentEventType.ToolResult => category switch @@ -242,15 +242,15 @@ internal static class AgentStatusNarrativeCatalog { AgentEventType.Planning => IsCodeTab(runTab) ? "수정 순서와 검증 단계를 정리하고 있습니다..." - : "작업 순서와 산출물 구조를 정리하고 있습니다...", + : "작업 순서와 결과물 구성을 정리하고 있습니다...", AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 작업을 진행하고 있습니다...", AgentEventType.StepDone when evt.StepTotal > 0 - => $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 넘어가고 있습니다...", + => $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 이어가고 있습니다...", AgentEventType.PermissionRequest => "실행 전에 필요한 권한 확인을 기다리고 있습니다...", AgentEventType.PermissionGranted => "권한이 확인되어 작업을 이어가고 있습니다...", AgentEventType.PermissionDenied => "권한이 거부되어 다른 진행 경로를 검토하고 있습니다...", - AgentEventType.SkillCall => "전용 스킬 절차를 적용하고 있습니다...", + AgentEventType.SkillCall => "적절한 스킬이나 작업 흐름을 적용하고 있습니다...", AgentEventType.ToolCall => category switch { "read" => IsCodeTab(runTab) @@ -260,21 +260,21 @@ internal static class AgentStatusNarrativeCatalog ? "코드 변경을 적용하고 있습니다..." : "초안 내용을 다듬고 있습니다...", "execute" => "실행 결과와 로그를 확인하고 있습니다...", - "document" => "문서 산출물을 구성하고 있습니다...", + "document" => "문서 결과물을 구성하고 있습니다...", "git" => "변경 범위와 저장소 상태를 확인하고 있습니다...", - "web" => "필요한 외부 정보를 찾아 정리하고 있습니다...", + "web" => "필요한 외부 정보를 정리하고 있습니다...", _ => string.IsNullOrWhiteSpace(itemDisplayName) - ? "필요한 작업을 실행하고 있습니다..." + ? "필요한 작업을 진행하고 있습니다..." : $"{itemDisplayName} 작업을 진행하고 있습니다...", }, AgentEventType.ToolResult => category switch { - "read" => "확인한 내용을 정리하고 다음 단계를 준비하고 있습니다...", - "edit" => "적용한 변경을 정리하고 다음 검증으로 넘어가고 있습니다...", + "read" => "확인한 내용을 정리하고 다음 단계로 이어가고 있습니다...", + "edit" => "적용한 변경을 정리하고 다음 검증으로 이어가고 있습니다...", "execute" => "실행 결과를 분석하고 후속 조치를 판단하고 있습니다...", "document" => "생성한 결과를 검토하고 다듬고 있습니다...", "git" => "변경 내용을 정리하고 다음 작업을 준비하고 있습니다...", - "web" => "수집한 정보를 정리하고 답변에 연결하고 있습니다...", + "web" => "수집한 정보를 정리하고 답변에 반영하고 있습니다...", _ => string.IsNullOrWhiteSpace(row.Title) ? "결과를 정리하고 있습니다..." : EnsureSentence(row.Title), @@ -284,13 +284,13 @@ internal static class AgentStatusNarrativeCatalog AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) => "현재까지 진행한 내용을 정리하고 있습니다...", AgentEventType.Thinking when category == "document" - => "문서 흐름과 누락된 내용을 정리하고 있습니다...", + => "문서 흐름과 필요한 내용을 정리하고 있습니다...", AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("검증", StringComparison.OrdinalIgnoreCase) - => "결과를 검토하고 누락된 부분이 없는지 확인하고 있습니다...", + => "결과를 검토하고 빠진 부분이 없는지 확인하고 있습니다...", AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("재시도", StringComparison.OrdinalIgnoreCase) => "다시 시도할 경로를 정리하고 있습니다...", AgentEventType.Complete => "작업이 완료되었습니다.", - AgentEventType.Error => "오류를 정리하고 복구 경로를 판단하고 있습니다...", + AgentEventType.Error => "오류를 정리하고 복구 경로를 검토하고 있습니다...", AgentEventType.Decision => "다음 진행을 위해 사용자 확인을 기다리고 있습니다...", _ => string.IsNullOrWhiteSpace(row.Title) ? "작업을 진행하고 있습니다..." @@ -313,20 +313,14 @@ internal static class AgentStatusNarrativeCatalog } if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 }) - { parts.Add($"총 {evt.Steps.Count}단계 계획을 기준으로 진행합니다."); - } var targetHint = ExtractTargetHint(evt); if (!string.IsNullOrWhiteSpace(targetHint)) - { - parts.Add(evt.Type == AgentEventType.ToolResult - ? $"결과 대상: {targetHint}" - : $"대상: {targetHint}"); - } + parts.Add($"대상: {targetHint}"); if (category == "compact" && evt.ElapsedMs > 0) - parts.Add("이전 대화와 최근 작업 결과를 함께 압축하고 있습니다."); + parts.Add("이전 대화와 최근 작업 결과를 이어서 사용할 수 있게 정리하고 있습니다."); return parts.Count == 0 ? null : string.Join(" · ", parts); } @@ -336,7 +330,7 @@ internal static class AgentStatusNarrativeCatalog { "read" => IsCodeTab(runTab) ? longWait - ? "읽은 코드 맥락을 종합하고 다음 수정 지점을 정리하고 있습니다..." + ? "읽은 코드와 검색 결과를 종합해 다음 수정 지점을 정리하고 있습니다..." : "읽은 코드와 검색 결과를 정리하고 있습니다..." : longWait ? "확인한 자료를 종합해 답변 구조를 정리하고 있습니다..." @@ -348,8 +342,8 @@ internal static class AgentStatusNarrativeCatalog ? "실행 로그를 종합해 원인과 다음 조치를 정리하고 있습니다..." : "실행 결과와 로그를 정리하고 있습니다...", "document" => longWait - ? "초안 구조와 누락된 근거를 다시 맞추고 있습니다..." - : "초안과 산출물 구성을 다듬고 있습니다...", + ? "초안 구조와 빠진 근거를 다시 맞추고 있습니다..." + : "초안과 결과물 구성을 다듬고 있습니다...", "git" => "변경 범위와 저장소 상태를 다시 정리하고 있습니다...", "web" => "수집한 정보를 정리해 답변에 연결하고 있습니다...", "permission" => "권한 확인 결과를 기다리며 다음 단계를 준비하고 있습니다...", @@ -361,14 +355,14 @@ internal static class AgentStatusNarrativeCatalog private static string BuildIdleDetail(string category) => category switch { - "read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결합니다.", - "edit" => "수정 범위와 영향 파일을 다시 확인합니다.", - "execute" => "빌드·테스트 로그에서 실패 원인과 후속 조치를 추립니다.", - "document" => "초안 구조와 빠진 근거, 결론 흐름을 다시 맞춥니다.", - "git" => "변경 범위와 저장소 상태를 함께 확인합니다.", - "web" => "수집한 정보를 질문 흐름에 맞게 추려냅니다.", - "permission" => "권한 결과가 정리되면 같은 작업 흐름으로 이어집니다.", - _ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결합니다.", + "read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결하고 있습니다.", + "edit" => "수정 범위와 영향 파일을 다시 확인하고 있습니다.", + "execute" => "빌드와 테스트 로그에서 원인과 후속 조치를 추리고 있습니다.", + "document" => "초안 구조, 빠진 근거, 연결 흐름을 다시 맞추고 있습니다.", + "git" => "변경 범위와 저장소 상태를 한 번 더 확인하고 있습니다.", + "web" => "수집한 정보를 질문 흐름에 맞게 추리고 있습니다.", + "permission" => "권한 결과가 정리되면 같은 작업 흐름으로 바로 이어집니다.", + _ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결하고 있습니다.", }; private static string ResolveCategory(AgentEvent evt, AgentTranscriptRowPresentation row) @@ -448,9 +442,9 @@ internal static class AgentStatusNarrativeCatalog return Path.GetFileName(text); if (key == "command") - return text.Length > 60 ? text[..60] + "…" : text; + return text.Length > 60 ? text[..60] + "..." : text; - return text.Length > 70 ? text[..70] + "…" : text; + return text.Length > 70 ? text[..70] + "..." : text; } if (value.ValueKind == JsonValueKind.Array) @@ -506,7 +500,7 @@ internal static class AgentStatusNarrativeCatalog normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); if (normalized.Length > 160) - normalized = normalized[..160].TrimEnd() + "…"; + normalized = normalized[..160].TrimEnd() + "..."; return normalized; } diff --git a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs index de820ed..d12463c 100644 --- a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs +++ b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs @@ -150,7 +150,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina if (!firstEventReceived) { firstEventReceived = true; - LogService.Info( + LogService.Debug( $"[AgentLoopWait] {phaseLabel}: 첫 응답 수신 ({waitStopwatch.ElapsedMilliseconds}ms, kind={evt.Kind})"); if (waitStopwatch.Elapsed >= _firstResponseHeartbeatDelay) _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델 첫 응답을 받아 계속 진행합니다."); @@ -226,7 +226,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina var summary = firstEventReceived ? $"{phaseLabel}: 모델 응답이 길어져 계속 기다리는 중입니다... ({seconds}초)" : $"{phaseLabel}: 모델 첫 응답을 기다리는 중입니다... ({seconds}초)"; - LogService.Info($"[AgentLoopWait] {summary}"); + LogService.Debug($"[AgentLoopWait] {summary}"); _emitEvent(AgentEventType.Thinking, "", summary); } } diff --git a/src/AxCopilot/Services/MarkdownRenderer.cs b/src/AxCopilot/Services/MarkdownRenderer.cs index 2cba5d1..38af6ac 100644 --- a/src/AxCopilot/Services/MarkdownRenderer.cs +++ b/src/AxCopilot/Services/MarkdownRenderer.cs @@ -200,8 +200,220 @@ public static class MarkdownRenderer return panel; } + public static FrameworkElement RenderSelectable(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg) + { + var document = BuildSelectableDocument(markdown, textColor, secondaryColor, accentColor, codeBg); + var viewer = new RichTextBox + { + Document = document, + IsReadOnly = true, + IsDocumentEnabled = true, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + Margin = new Thickness(0), + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + AcceptsReturn = true, + }; + + void UpdatePageWidth() + { + var width = System.Math.Max(0, viewer.ActualWidth - 8); + viewer.Document.PageWidth = width <= 0 ? 640 : width; + } + + viewer.Loaded += (_, _) => UpdatePageWidth(); + viewer.SizeChanged += (_, _) => UpdatePageWidth(); + return viewer; + } + + private static FlowDocument BuildSelectableDocument(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg) + { + var document = new FlowDocument + { + PagePadding = new Thickness(0), + Background = Brushes.Transparent, + }; + + if (string.IsNullOrEmpty(markdown)) + return document; + + var lines = markdown.Replace("\r\n", "\n").Split('\n'); + var i = 0; + + while (i < lines.Length) + { + var line = lines[i]; + + if (line.TrimStart().StartsWith("```")) + { + var lang = line.TrimStart().Length > 3 ? line.TrimStart()[3..].Trim() : ""; + var codeLines = new System.Text.StringBuilder(); + i++; + while (i < lines.Length && !lines[i].TrimStart().StartsWith("```")) + { + codeLines.AppendLine(lines[i]); + i++; + } + + if (i < lines.Length) + i++; + + if (!string.IsNullOrWhiteSpace(lang)) + { + document.Blocks.Add(new Paragraph(new Run(lang)) + { + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = accentColor, + Margin = new Thickness(0, 6, 0, 2), + }); + } + + var codeParagraph = new Paragraph + { + Margin = new Thickness(0, 0, 0, 8), + Padding = new Thickness(12, 10, 12, 10), + Background = codeBg, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)), + BorderThickness = new Thickness(1), + FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"), + FontSize = 12.5, + }; + AppendSyntaxHighlightedInlines(codeParagraph.Inlines, codeLines.ToString().TrimEnd(), lang, textColor); + document.Blocks.Add(codeParagraph); + continue; + } + + if (string.IsNullOrWhiteSpace(line)) + { + document.Blocks.Add(new Paragraph(new Run(" ")) + { + Margin = new Thickness(0, 2, 0, 2), + FontSize = 4, + }); + i++; + continue; + } + + if (Regex.IsMatch(line.Trim(), @"^-{3,}$|^\*{3,}$")) + { + document.Blocks.Add(new BlockUIContainer(new Border + { + Height = 1, + Background = secondaryColor, + Opacity = 0.3, + Margin = new Thickness(0, 8, 0, 8), + })); + i++; + continue; + } + + if (line.StartsWith('#')) + { + var level = 0; + while (level < line.Length && line[level] == '#') level++; + var headerText = line[level..].Trim(); + var fontSize = level switch { 1 => 20.0, 2 => 17.0, 3 => 15.0, _ => 14.0 }; + var paragraph = new Paragraph + { + FontSize = fontSize, + FontWeight = FontWeights.Bold, + Foreground = textColor, + Margin = new Thickness(0, level == 1 ? 12 : 8, 0, 4), + }; + AddInlines(paragraph.Inlines, headerText, textColor, accentColor, codeBg, selectable: true); + document.Blocks.Add(paragraph); + i++; + continue; + } + + if (Regex.IsMatch(line, @"^\s*[-*]\s") || Regex.IsMatch(line, @"^\s*\d+\.\s")) + { + var match = Regex.Match(line, @"^(\s*)([-*]|\d+\.)\s(.*)"); + if (match.Success) + { + var indent = match.Groups[1].Value.Length / 2; + var bullet = match.Groups[2].Value; + var content = match.Groups[3].Value; + var paragraph = new Paragraph + { + Margin = new Thickness(12 + indent * 16, 2, 0, 2), + Foreground = textColor, + FontSize = 13.5, + }; + paragraph.Inlines.Add(new Run(bullet is "-" or "*" ? "• " : $"{bullet} ") + { + Foreground = accentColor, + FontWeight = FontWeights.SemiBold, + }); + AddInlines(paragraph.Inlines, content, textColor, accentColor, codeBg, selectable: true); + document.Blocks.Add(paragraph); + } + i++; + continue; + } + + if (line.TrimStart().StartsWith('>')) + { + var quoteLines = new List(); + while (i < lines.Length && lines[i].TrimStart().StartsWith('>')) + { + var ql = lines[i].TrimStart(); + quoteLines.Add(ql.Length > 1 ? ql[1..].TrimStart() : ""); + i++; + } + + var quoteParagraph = new Paragraph + { + Margin = new Thickness(4, 4, 0, 4), + Padding = new Thickness(12, 6, 8, 6), + BorderBrush = accentColor, + BorderThickness = new Thickness(3, 0, 0, 0), + Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)), + FontStyle = FontStyles.Italic, + FontSize = 13, + Foreground = secondaryColor, + LineHeight = 20, + }; + AddInlines(quoteParagraph.Inlines, string.Join("\n", quoteLines), textColor, accentColor, codeBg, selectable: true); + document.Blocks.Add(quoteParagraph); + continue; + } + + if (line.Contains('|') && line.Trim().StartsWith('|')) + { + var tableRows = new List(); + while (i < lines.Length && lines[i].Contains('|')) + { + tableRows.Add(lines[i]); + i++; + } + + var table = CreateMarkdownTable(tableRows, textColor, accentColor, codeBg); + if (table != null) + document.Blocks.Add(new BlockUIContainer(table)); + continue; + } + + var paragraphBlock = new Paragraph + { + FontSize = 13.5, + Foreground = textColor, + LineHeight = 22, + Margin = new Thickness(0, 2, 0, 2), + }; + AddInlines(paragraphBlock.Inlines, line, textColor, accentColor, codeBg, selectable: true); + document.Blocks.Add(paragraphBlock); + i++; + } + + return document; + } + /// 인라인 마크다운 처리: **볼드**, *이탤릭*, `코드`, ~~취소선~~, [링크](url) - private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg) + private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg, bool selectable = false) { // 패턴: [link](url) | ~~strikethrough~~ | **bold** | *italic* | `code` | 일반텍스트 var pattern = @"(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|([^*`~\[]+))"; @@ -242,21 +454,34 @@ public static class MarkdownRenderer } else if (m.Groups[7].Success) // `code` { - var codeBorder = new Border + if (selectable) { - Background = codeBg, - CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(1, 0, 1, 0), - Child = new TextBlock + inlines.Add(new Run(m.Groups[7].Value) { - Text = m.Groups[7].Value, FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"), FontSize = 12.5, - Foreground = accentColor - } - }; - inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center }); + Foreground = accentColor, + Background = codeBg, + }); + } + else + { + var codeBorder = new Border + { + Background = codeBg, + CornerRadius = new CornerRadius(4), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(1, 0, 1, 0), + Child = new TextBlock + { + Text = m.Groups[7].Value, + FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"), + FontSize = 12.5, + Foreground = accentColor + } + }; + inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center }); + } } else if (m.Groups[8].Success) // 일반 텍스트 { @@ -797,105 +1022,59 @@ public static class MarkdownRenderer RegexOptions.Compiled); private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor) + { + tb.Inlines.Clear(); + AppendSyntaxHighlightedInlines(tb.Inlines, code, lang, defaultColor); + } + + private static void AppendSyntaxHighlightedInlines(InlineCollection inlines, string code, string lang, Brush defaultColor) { if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code)) { - tb.Text = code; + inlines.Add(new Run(code) { Foreground = defaultColor }); return; } var keywords = GetKeywordsForLang(lang); var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell" or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1"; - - foreach (Match m in SyntaxPattern.Matches(code)) - { - if (m.Groups[1].Success) // comment - { - var commentText = m.Groups[1].Value; - // # 주석은 해당 언어에서만 적용 - if (commentText.StartsWith('#') && !isCommentHash) - { - // # 이 주석이 아닌 언어: 일반 텍스트로 처리 - tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); - } - else - { - tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); - } - } - else if (m.Groups[2].Success) // string - { - tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); - } - else if (m.Groups[3].Success) // number - { - tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); - } - else if (m.Groups[4].Success) // type - { - var word = m.Groups[4].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); - } - else if (m.Groups[5].Success) // method - { - var word = m.Groups[5].Value; - if (keywords.Contains(word)) - tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); - else - tb.Inlines.Add(new Run(word) { Foreground = MethodBrush }); - } - else if (m.Groups[6].Success) // identifier - { - var word = m.Groups[6].Value; - if (keywords.Contains(word)) - tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); - else - tb.Inlines.Add(new Run(word) { Foreground = defaultColor }); - } - } - - // Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움 - // Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완 - // 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근 - tb.Inlines.Clear(); int lastEnd = 0; foreach (Match m in SyntaxPattern.Matches(code)) { if (m.Index > lastEnd) - tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor }); + inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor }); if (m.Groups[1].Success) { var commentText = m.Groups[1].Value; if (commentText.StartsWith('#') && !isCommentHash) - tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); + inlines.Add(new Run(commentText) { Foreground = defaultColor }); else - tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); + inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); } else if (m.Groups[2].Success) - tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); + inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); else if (m.Groups[3].Success) - tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); + inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); else if (m.Groups[4].Success) { var word = m.Groups[4].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); + inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); } else if (m.Groups[5].Success) { var word = m.Groups[5].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush }); + inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush }); } else if (m.Groups[6].Success) { var word = m.Groups[6].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor }); + inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor }); } lastEnd = m.Index + m.Length; } if (lastEnd < code.Length) - tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor }); + inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor }); } } diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs index 74d3ebe..78fb080 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs @@ -1540,6 +1540,13 @@ public partial class ChatWindow && evt.Type is AgentEventType.Paused or AgentEventType.Resumed) return; + if (!string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase) + && evt.Type == AgentEventType.Thinking + && !string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + && !string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase) + && AgentProgressSummarySanitizer.IsLowSignalStatusSummary(evt.Summary, evt.ToolName)) + return; + var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; var transcriptBadgeLabel = GetTranscriptBadgeLabel(evt); var permissionPresentation = evt.Type switch diff --git a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs index 335da62..4ccab4b 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs @@ -64,7 +64,7 @@ public partial class ChatWindow MarkdownRenderer.EnableFilePathHighlight = (System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = true; - bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush); + bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, userCodeBgBrush); } else { @@ -234,7 +234,7 @@ public partial class ChatWindow Foreground = primaryText, Margin = new Thickness(0, 0, 0, 6), }); - branchStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush)); + branchStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush)); if (branchFiles.Count > 0) { @@ -316,7 +316,7 @@ public partial class ChatWindow } else { - contentStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush)); + contentStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush)); } contentCard.Child = contentStack; diff --git a/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs index f1d5a29..e8bb4c8 100644 --- a/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs @@ -393,7 +393,7 @@ public partial class ChatWindow var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; - var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); + var mdPanel = MarkdownRenderer.RenderSelectable(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); mdPanel.Margin = new Thickness(0, 0, 0, 4); mdPanel.Opacity = 0; var mdCard = new Border diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index 4c66a5e..f5d1836 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -102,7 +102,10 @@ public partial class ChatWindow || string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) return true; - if (!string.IsNullOrWhiteSpace(restoredEvent.Summary)) + if (!string.IsNullOrWhiteSpace(restoredEvent.Summary) + && !AgentProgressSummarySanitizer.IsLowSignalStatusSummary( + restoredEvent.Summary, + restoredEvent.ToolName)) return true; } diff --git a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs index 9492a00..6006f01 100644 --- a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; @@ -16,7 +15,7 @@ public partial class ChatWindow private DateTime _v2LiveStartTime; private TextBlock? _v2LiveElapsedText; - /// V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성 + /// V2: 스트리밍 시작 시 상단 라이브 진행 요약 카드를 생성합니다. private void ShowAgentLiveCardV2(string runTab) { if (MessageList == null) return; @@ -25,11 +24,13 @@ public partial class ChatWindow RemoveAgentLiveCardV2(animated: false); _v2LiveStartTime = DateTime.UtcNow; - _v2LiveToolCards.Clear(); - _v2LastLiveToolCallId = null; var msgMaxWidth = GetMessageMaxWidth(); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hintBg = TryFindResource("HintBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; _v2LiveContainer = new StackPanel { @@ -39,7 +40,6 @@ public partial class ChatWindow Margin = new Thickness(0, 4, 0, 6), }; - // 에이전트 헤더 (아이콘 + 이름 + 경과시간) var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 4) }; headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); @@ -52,8 +52,12 @@ public partial class ChatWindow { var animState = new ChatIconAnimState { - Host = liveIconHost, Canvas = canvas, Pixels = livePixels, - Glows = liveGlows, Rotate = liveRotate, Scale = liveScale, + Host = liveIconHost, + Canvas = canvas, + Pixels = livePixels, + Glows = liveGlows, + Rotate = liveRotate, + Scale = liveScale, IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation, }; StartChatIconAnimation(animState); @@ -79,7 +83,7 @@ public partial class ChatWindow Text = "", FontSize = 10, Foreground = secondaryText, - Opacity = 0.70, + Opacity = 0.7, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, }; @@ -88,10 +92,42 @@ public partial class ChatWindow _v2LiveContainer.Children.Add(headerGrid); + var summaryCard = new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), + }; + + var summaryStack = new StackPanel(); + _v2LiveStatusText = new TextBlock + { + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + }; + summaryStack.Children.Add(_v2LiveStatusText); + + _v2LiveDetailText = new TextBlock + { + FontSize = 10.5, + Foreground = secondaryText, + Opacity = 0.86, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }; + summaryStack.Children.Add(_v2LiveDetailText); + summaryCard.Child = summaryStack; + _v2LiveContainer.Children.Add(summaryCard); + + ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildInitial(runTab)); + AddTranscriptElement(_v2LiveContainer); ForceScrollToEnd(); - // 경과 시간 타이머 _v2LiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _v2LiveElapsedTimer.Tick += (_, _) => { @@ -102,242 +138,41 @@ public partial class ChatWindow _v2LiveElapsedTimer.Start(); } - /// V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트 + /// V2: 라이브 카드는 1~2줄 요약만 유지하고, 상세 실행 이력은 본문 타임라인에 누적합니다. private void UpdateAgentLiveCardV2(AgentEvent agentEvent) { - if (_v2LiveContainer == null) return; + if (_v2LiveContainer == null) + return; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var hintBg = TryFindResource("HintBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var msgMaxWidth = GetMessageMaxWidth(); - - switch (agentEvent.Type) + if (agentEvent.Type == AgentEventType.Thinking + && !string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + && !string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase) + && AgentProgressSummarySanitizer.IsLowSignalStatusSummary(agentEvent.Summary, agentEvent.ToolName)) { - case AgentEventType.ToolCall: - { - var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName); - var toolId = $"{agentEvent.ToolName}_{agentEvent.Timestamp.Ticks}"; - _v2LastLiveToolCallId = toolId; - - var outerGrid = new Grid { Margin = new Thickness(0, 2, 0, 2) }; - outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - // 왼쪽 세로선 (진행중 = 맥박) - var accentColor = ResolveLiveProgressAccentColor( - TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue); - var pulseLine = new Border - { - Width = 2, - Background = new SolidColorBrush(Color.FromArgb(0x80, accentColor.R, accentColor.G, accentColor.B)), - CornerRadius = new CornerRadius(1), - Margin = new Thickness(12, 0, 8, 0), - }; - // 맥박 애니메이션 - var pulseAnim = new DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(800)) - { - AutoReverse = true, - RepeatBehavior = RepeatBehavior.Forever, - EasingFunction = new SineEase(), - }; - pulseLine.BeginAnimation(UIElement.OpacityProperty, pulseAnim); - Grid.SetColumn(pulseLine, 0); - outerGrid.Children.Add(pulseLine); - - var card = new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x1C, accentColor.R, accentColor.G, accentColor.B)), - BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 6, 10, 6), - Tag = "pending", - }; - Grid.SetColumn(card, 1); - - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, - FontSize = 12, - Foreground = iconColor, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), - }); - sp.Children.Add(new TextBlock - { - Text = GetV2ToolDisplayName(agentEvent.ToolName), - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - if (!string.IsNullOrWhiteSpace(agentEvent.FilePath)) - { - sp.Children.Add(new TextBlock - { - Text = TruncateFilePath(agentEvent.FilePath, 50), - FontSize = 10, - Foreground = secondaryText, - Opacity = 0.7, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(8, 0, 0, 0), - }); - } - // 스피너 대신 "..." 텍스트 - sp.Children.Add(new TextBlock - { - Text = "...", - FontSize = 11, - Foreground = secondaryText, - Opacity = 0.5, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(6, 0, 0, 0), - }); - card.Child = sp; - outerGrid.Children.Add(card); - - _v2LiveToolCards[toolId] = card; - _v2LiveContainer.Children.Add(outerGrid); - // 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음 - AutoScrollIfNeeded(); - break; - } - - case AgentEventType.ToolResult: - { - // 마지막 pending 카드를 완료 상태로 변환 - if (_v2LastLiveToolCallId != null && _v2LiveToolCards.TryGetValue(_v2LastLiveToolCallId, out var pendingCard)) - { - var isSuccess = agentEvent.Success; - var statusIcon = isSuccess ? "\uE73E" : "\uE711"; - var statusColor = isSuccess - ? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)) - : new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)); - - // 카드 배경/테두리를 성공/실패 색으로 변경 - pendingCard.Background = isSuccess - ? new SolidColorBrush(Color.FromArgb(0x0A, 0x66, 0xBB, 0x6A)) - : new SolidColorBrush(Color.FromArgb(0x0A, 0xEF, 0x53, 0x50)); - pendingCard.BorderBrush = isSuccess - ? new SolidColorBrush(Color.FromArgb(0x30, 0x66, 0xBB, 0x6A)) - : new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50)); - pendingCard.Tag = "complete"; - - // "..." 텍스트를 상태 아이콘 + 소요시간으로 교체 - if (pendingCard.Child is StackPanel sp) - { - // 마지막 "..." 제거 - if (sp.Children.Count > 0 && sp.Children[^1] is TextBlock lastTb && lastTb.Text == "...") - sp.Children.RemoveAt(sp.Children.Count - 1); - - sp.Children.Add(new TextBlock - { - Text = statusIcon, - FontFamily = s_segoeIconFont, - FontSize = 11, - Foreground = statusColor, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(6, 0, 0, 0), - }); - - var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs); - if (elapsed > 0) - { - sp.Children.Add(new TextBlock - { - Text = $"{elapsed / 1000.0:F1}s", - FontSize = 10, - Foreground = secondaryText, - Opacity = 0.6, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 0, 0), - }); - } - } - - // 세로선 맥박 애니메이션 중지 - var parent = pendingCard.Parent as Grid; - if (parent?.Children[0] is Border pulseLine) - { - pulseLine.BeginAnimation(UIElement.OpacityProperty, null); - pulseLine.Opacity = 1; - pulseLine.Background = isSuccess - ? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A)) - : new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50)); - } - - // ★ 섹션 종료 후 자동 접기 — 완료된 카드는 1.2초 뒤 컴팩트 형태로 축소 - // 얇은 줄이 빠르게 누적되어 공간 낭비되는 문제 해결 - var cardToCollapse = pendingCard; - var outerGridToCollapse = parent; - var collapseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1200) }; - collapseTimer.Tick += (_, _) => - { - collapseTimer.Stop(); - if (cardToCollapse == null) return; - // Padding 축소 + Opacity 감소로 "접힌" 느낌 연출 - var padAnim = new ThicknessAnimation( - cardToCollapse.Padding, - new Thickness(8, 2, 8, 2), - TimeSpan.FromMilliseconds(200)) - { EasingFunction = new QuadraticEase() }; - var opAnim = new DoubleAnimation(1.0, 0.55, TimeSpan.FromMilliseconds(200)) - { EasingFunction = new QuadraticEase() }; - cardToCollapse.BeginAnimation(Border.PaddingProperty, padAnim); - cardToCollapse.BeginAnimation(UIElement.OpacityProperty, opAnim); - if (outerGridToCollapse != null) - outerGridToCollapse.Margin = new Thickness(0, 1, 0, 1); - }; - collapseTimer.Start(); - - _v2LastLiveToolCallId = null; - } - break; - } - - case AgentEventType.Thinking: - { - var thinkText = AgentProgressSummarySanitizer.NormalizeThinkingSummary( - agentEvent.Summary, - agentEvent.ToolName, - maxLength: 100); - if (string.IsNullOrWhiteSpace(thinkText)) break; - - // 사고 과정을 간략히 표시 - var thinkRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(22, 2, 0, 2), - }; - thinkRow.Children.Add(new TextBlock - { - Text = "\uE915", - FontFamily = s_segoeIconFont, - FontSize = 10, - Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0), - }); - thinkRow.Children.Add(new TextBlock - { - Text = thinkText, - FontSize = 10.5, - FontStyle = FontStyles.Italic, - Foreground = secondaryText, - Opacity = 0.82, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = msgMaxWidth - 60, - }); - _v2LiveContainer.Children.Add(thinkRow); - // 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음 - AutoScrollIfNeeded(); - break; - } + return; } + + ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildFromEvent(agentEvent, _activeTab)); + AutoScrollIfNeeded(); + } + + private void ApplyV2LiveNarrative(AgentStatusNarrative narrative) + { + if (_v2LiveStatusText != null) + _v2LiveStatusText.Text = narrative.Message; + + if (_v2LiveDetailText == null) + return; + + if (string.IsNullOrWhiteSpace(narrative.Detail)) + { + _v2LiveDetailText.Text = string.Empty; + _v2LiveDetailText.Visibility = Visibility.Collapsed; + return; + } + + _v2LiveDetailText.Text = narrative.Detail; + _v2LiveDetailText.Visibility = Visibility.Visible; } /// V2: 스트리밍 종료 시 라이브 카드 제거 @@ -346,16 +181,15 @@ public partial class ChatWindow _v2LiveElapsedTimer?.Stop(); _v2LiveElapsedTimer = null; _v2LiveElapsedText = null; + _v2LiveStatusText = null; + _v2LiveDetailText = null; if (_v2LiveContainer == null) return; - // 아이콘 애니메이션 상태 정리 _chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible); var toRemove = _v2LiveContainer; _v2LiveContainer = null; - _v2LiveToolCards.Clear(); - _v2LastLiveToolCallId = null; if (animated && ContainsTranscriptElement(toRemove)) { diff --git a/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs b/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs index bb77de4..e8c3e14 100644 --- a/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs @@ -88,7 +88,7 @@ public partial class ChatWindow MarkdownRenderer.EnableFilePathHighlight = (System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = true; - bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, hintBg); + bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, hintBg); } else { @@ -192,11 +192,11 @@ public partial class ChatWindow if (IsBranchContextMessage(content)) { - contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); + contentPanel.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush); } else { - contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); + contentPanel.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush); } container.Children.Add(contentPanel); diff --git a/src/AxCopilot/Views/ChatWindow.V2Rendering.cs b/src/AxCopilot/Views/ChatWindow.V2Rendering.cs index 4a7e33a..a618766 100644 --- a/src/AxCopilot/Views/ChatWindow.V2Rendering.cs +++ b/src/AxCopilot/Views/ChatWindow.V2Rendering.cs @@ -22,8 +22,8 @@ public partial class ChatWindow // V2 라이브 프로그레스 상태 private StackPanel? _v2LiveContainer; - private readonly Dictionary _v2LiveToolCards = new(); - private string? _v2LastLiveToolCallId; + private TextBlock? _v2LiveStatusText; + private TextBlock? _v2LiveDetailText; private void RenderMessagesV2( ChatConversation conv, @@ -61,7 +61,7 @@ public partial class ChatWindow // 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합) var timeline = BuildV2Timeline(visibleMessages, visibleEvents); - LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}"); + LogService.Debug($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}"); // 새 키 목록 생성 var newKeys = new List(timeline.Count); @@ -78,7 +78,7 @@ public partial class ChatWindow && newKeys.Count >= _v2LastRenderedKeys.Count && KeysArePrefixMatch(_v2LastRenderedKeys, newKeys); - LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}"); + LogService.Debug($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}"); if (canIncremental) { @@ -131,7 +131,7 @@ public partial class ChatWindow _v2LastRenderedMessageCount = visibleMessages.Count; _v2LastRenderedEventCount = visibleEvents.Count; - LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}"); + LogService.Debug($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}"); } catch (Exception ex) { @@ -198,18 +198,12 @@ public partial class ChatWindow // 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외 var eventIndex = 0; var events = visibleEvents.ToList(); - var liveCardCutoff = (_isStreaming && _v2LiveContainer != null) - ? _v2LiveStartTime - : DateTime.MaxValue; for (int i = 0; i < events.Count; i++) { var executionEvent = events[i]; // 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵 - if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff) - continue; - var agentEvent = ToAgentEvent(executionEvent); // SessionStart / UserPromptSubmit 숨김