From 306245524d80ae257c3182bd44e0a950c370083e Mon Sep 17 00:00:00 2001 From: lacvet Date: Mon, 6 Apr 2026 22:44:24 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=20=EC=8B=9C=EA=B0=84=C2=B7=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80\n\n-=20Cowork/Code?= =?UTF-8?q?=20=EC=9E=A5=EA=B8=B0=20=EB=8C=80=EA=B8=B0=EC=99=80=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=95=95=EC=B6=95=20=EC=83=81?= =?UTF-8?q?=ED=99=A9=EC=9D=84=20transcript=EC=97=90=EC=84=9C=20=EB=8D=94?= =?UTF-8?q?=20=EB=AA=85=ED=99=95=ED=9E=88=20=EB=B3=B4=EC=9D=B4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=ED=9E=8C=ED=8A=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5\n-=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=A4=84=20=EC=9A=B0=EC=B8=A1=EC=97=90=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=EA=B0=84=EA=B3=BC=20=EB=88=84=EC=A0=81=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=88=98=EB=A5=BC=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=B4=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=EA=B3=BC=20=EC=B2=98=EB=A6=AC=20=EC=A4=91=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=EA=B5=AC=EB=B6=84=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=EC=A1=B0=EC=A0=95\n-=20process=20feed=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=EC=A4=84=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=B4=20claw-code=20=EB=A0=88?= =?UTF-8?q?=ED=8D=BC=EB=9F=B0=EC=8A=A4=EC=B2=98=EB=9F=BC=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0/=EC=95=95=EC=B6=95=20=EC=83=81=ED=83=9C=EA=B0=80=20?= =?UTF-8?q?=EB=8D=94=20=EB=88=88=EC=97=90=20=EC=9E=98=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=A4=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0\n-=20README=20?= =?UTF-8?q?=EB=B0=8F=20DEVELOPMENT=20=EB=AC=B8=EC=84=9C=EC=97=90=202026-04?= =?UTF-8?q?-06=2022:42=20(KST)=20=EA=B8=B0=EC=A4=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EB=B0=98=EC=98=81\n\n=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B2=B0=EA=B3=BC\n-=20dotnet=20build=20src/AxCopi?= =?UTF-8?q?lot/AxCopilot.csproj=20-c=20Release=20-v=20minimal=20-p:OutputP?= =?UTF-8?q?ath=3Dbin\\verify\\=20-p:IntermediateOutputPath=3Dobj\\verify\\?= =?UTF-8?q?\n-=20=EA=B2=BD=EA=B3=A0=200=EA=B0=9C,=20=EC=98=A4=EB=A5=98=200?= =?UTF-8?q?=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + docs/DEVELOPMENT.md | 9 ++ .../Views/ChatWindow.AgentEventRendering.cs | 96 +++++++++++-- src/AxCopilot/Views/ChatWindow.xaml.cs | 129 ++++++++++++++++++ 4 files changed, 223 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f6fae3b..9db43b5 100644 --- a/README.md +++ b/README.md @@ -1371,3 +1371,6 @@ MIT License - 업데이트: 2026-04-06 22:31 (KST) - AX Agent의 중간 처리 메시지 형식을 `claw-code`에 더 가깝게 재정리했습니다. `Thinking / ToolCall / StepStart / Planning` 계열은 작은 상태칩보다 `요약줄 + 본문 설명` 구조로 보이게 바꿔, 장시간 작업 중 “무슨 작업을 하고 있는지”를 일반 메시지처럼 읽을 수 있게 했습니다. - 진행 메시지의 요약줄은 좌측 chevron과 보조 텍스트 중심으로 정리하고, 상세 설명은 바로 아래 본문 텍스트로 분리해 `claw-code / Claude Code`의 처리 메시지 밀도와 감각에 더 가깝게 맞췄습니다. +- 업데이트: 2026-04-06 22:42 (KST) + - AX Agent의 라이브 대기/압축 진행 힌트를 `claw-code`처럼 더 읽기 쉬운 진행 한 줄로 보강했습니다. 이제 Cowork/Code에서 오래 걸릴 때 `처리 중...`, `컨텍스트 압축 중...` 같은 요약줄이 transcript에 살아 있는 진행 상태로 나타납니다. + - 같은 진행 줄 우측에는 `경과 시간`과 `현재 누적 토큰`이 함께 표시되어, 사용자가 “지금 멈춘 건지 아직 처리 중인지”를 기다릴 근거와 함께 바로 확인할 수 있게 조정했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ff87eb8..ed7bee3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5066,3 +5066,12 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 기존의 작은 pill 카드 대신 `chevron 요약줄 + 본문 설명` 구조로 렌더 - 장시간 작업 중에도 단계/툴 실행/생각 중 상태가 일반 assistant 진행 메시지처럼 읽히도록 정리 - permission/result 카드와 진행 중 feed를 분리해, 실제 처리 메시지는 `claude-code / Claude Code`처럼 더 담백한 transcript 흐름으로 보이게 조정했다. + +## 2026-04-06 22:42 (KST) + +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 라이브 진행 힌트 이벤트에 `ElapsedMs`, `InputTokens`, `OutputTokens`를 실시간으로 채워 넣도록 확장했다. + - Cowork/Code에서 스트리밍 중 오래 걸릴 때마다 현재 경과 시간과 누적 토큰 수가 진행 힌트 자체에 반영된다. + - 대기/압축 힌트는 초 단위 경과 시간이나 토큰 수가 바뀌면 자동 갱신되도록 비교 조건도 함께 보강했다. +- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 process feed 요약줄에 우측 메타 영역을 추가했다. + - `처리 중...`, `컨텍스트 압축 중...` 같은 라이브 진행 줄에 `6m 46s · 38.8K tokens`처럼 기다릴 근거가 함께 노출된다. + - 라이브 대기/압축 상태는 기존 chevron 대신 강조 마커와 조금 더 진한 제목 톤을 써서 일반 이벤트보다 눈에 잘 들어오도록 구분했다. diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs index f1baddf..692500b 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs @@ -10,7 +10,15 @@ namespace AxCopilot.Views; public partial class ChatWindow { - private Border CreateCompactEventPill(string summary, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) + private Border CreateCompactEventPill( + string summary, + Brush primaryText, + Brush secondaryText, + Brush hintBg, + Brush borderBrush, + Brush accentBrush, + string? metaText = null, + bool liveWaitingStyle = false) { return new Border { @@ -21,27 +29,49 @@ public partial class ChatWindow Padding = new Thickness(0), Margin = new Thickness(12, 4, 12, 2), HorizontalAlignment = HorizontalAlignment.Left, - Child = new StackPanel + Child = new Grid { - Orientation = Orientation.Horizontal, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto }, + }, Children = { - new TextBlock + new StackPanel { - Text = "›", - FontSize = 12, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = liveWaitingStyle ? "✶" : "›", + FontSize = liveWaitingStyle ? 11.5 : 12, + Foreground = liveWaitingStyle ? accentBrush : secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + FontWeight = liveWaitingStyle ? FontWeights.SemiBold : FontWeights.Normal, + }, + new TextBlock + { + Text = summary, + FontSize = liveWaitingStyle ? 11.5 : 11, + FontWeight = liveWaitingStyle ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = liveWaitingStyle ? primaryText : secondaryText, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + } + } }, new TextBlock { - Text = summary, - FontSize = 11, - FontWeight = FontWeights.Normal, + Text = metaText ?? "", + FontSize = 10, + FontWeight = liveWaitingStyle ? FontWeights.Medium : FontWeights.Normal, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(10, 0, 0, 0), + Visibility = string.IsNullOrWhiteSpace(metaText) ? Visibility.Collapsed : Visibility.Visible, } } } @@ -82,6 +112,10 @@ public partial class ChatWindow { return evt.Type switch { + AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + => "처리 중...", + AgentEventType.Thinking when string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase) + => "컨텍스트 압축 중...", AgentEventType.Planning when evt.Steps is { Count: > 0 } => $"{evt.Steps.Count}단계 계획 정리", AgentEventType.StepStart when evt.StepTotal > 0 @@ -111,6 +145,7 @@ public partial class ChatWindow var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var processMeta = BuildProcessFeedMetaText(evt); var summary = BuildProcessFeedSummary(evt, transcriptBadgeLabel, itemDisplayName).Trim(); if (string.IsNullOrWhiteSpace(summary)) summary = transcriptBadgeLabel; @@ -120,7 +155,18 @@ public partial class ChatWindow Margin = new Thickness(0), }; - var summaryRow = CreateCompactEventPill(summary, primaryText, secondaryText, hintBg, borderBrush, accentBrush); + var liveWaitingStyle = evt.Type == AgentEventType.Thinking + && (string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + || string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)); + var summaryRow = CreateCompactEventPill( + summary, + primaryText, + secondaryText, + hintBg, + borderBrush, + accentBrush, + processMeta, + liveWaitingStyle); summaryRow.Opacity = 0; summaryRow.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(140))); stack.Children.Add(summaryRow); @@ -166,6 +212,28 @@ public partial class ChatWindow MessagePanel.Children.Add(stack); } + private string BuildProcessFeedMetaText(AgentEvent evt) + { + var parts = new List(); + + if (evt.ElapsedMs > 0) + { + var elapsed = TimeSpan.FromMilliseconds(evt.ElapsedMs); + if (elapsed.TotalHours >= 1) + parts.Add($"{(int)elapsed.TotalHours}h {elapsed.Minutes}m"); + else if (elapsed.TotalMinutes >= 1) + parts.Add($"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"); + else + parts.Add($"{Math.Max(1, elapsed.Seconds)}s"); + } + + var totalTokens = Math.Max(0, evt.InputTokens) + Math.Max(0, evt.OutputTokens); + if (totalTokens > 0) + parts.Add($"{FormatTokenCount(totalTokens)} tokens"); + + return string.Join(" · ", parts); + } + private Border CreateAgentMetaChip(string text, string icon, Brush foreground, Brush background, Brush borderBrush) { return new Border diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 9c78220..78f4618 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -92,6 +92,7 @@ public partial class ChatWindow : Window private readonly DispatcherTimer _taskSummaryRefreshTimer; private readonly DispatcherTimer _conversationPersistTimer; private readonly DispatcherTimer _agentUiEventTimer; + private readonly DispatcherTimer _agentProgressHintTimer; private readonly DispatcherTimer _tokenUsagePopupCloseTimer; private readonly DispatcherTimer _responsiveLayoutTimer; private CancellationTokenSource? _gitStatusRefreshCts; @@ -131,6 +132,8 @@ public partial class ChatWindow : Window private readonly Dictionary _pendingConversationPersists = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _expandedDraftQueueTabs = new(StringComparer.OrdinalIgnoreCase); private AgentEvent? _pendingAgentUiEvent; + private AgentEvent? _liveAgentProgressHint; + private DateTime _lastAgentProgressEventAt = DateTime.UtcNow; private double _lastResponsiveComposerWidth; private double _lastResponsiveMessageWidth; private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg) @@ -268,6 +271,8 @@ public partial class ChatWindow : Window _agentUiEventTimer.Stop(); FlushPendingAgentUiEvent(); }; + _agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _agentProgressHintTimer.Tick += AgentProgressHintTimer_Tick; _tokenUsagePopupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) }; _tokenUsagePopupCloseTimer.Tick += (_, _) => { @@ -5286,6 +5291,7 @@ public partial class ChatWindow : Window _isStreaming = true; _streamRunTab = runTab; + StartLiveAgentProgressHints(); BtnSend.IsEnabled = false; BtnSend.Visibility = Visibility.Collapsed; BtnStop.Visibility = Visibility.Visible; @@ -5468,6 +5474,7 @@ public partial class ChatWindow : Window { FlushPendingConversationPersists(); FlushPendingAgentUiEvent(); + StopLiveAgentProgressHints(); _cursorTimer.Stop(); _elapsedTimer.Stop(); _typingTimer.Stop(); @@ -6135,6 +6142,7 @@ public partial class ChatWindow : Window private void OnAgentEvent(AgentEvent evt) { + TouchLiveAgentProgressHints(); var eventTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; // 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다. @@ -6164,6 +6172,127 @@ public partial class ChatWindow : Window ScheduleTaskSummaryRefresh(); } + private void StartLiveAgentProgressHints() + { + _lastAgentProgressEventAt = DateTime.UtcNow; + UpdateLiveAgentProgressHint(null); + _agentProgressHintTimer.Stop(); + _agentProgressHintTimer.Start(); + } + + private void StopLiveAgentProgressHints() + { + _agentProgressHintTimer.Stop(); + UpdateLiveAgentProgressHint(null); + } + + private void TouchLiveAgentProgressHints() + { + _lastAgentProgressEventAt = DateTime.UtcNow; + UpdateLiveAgentProgressHint(null); + } + + private void AgentProgressHintTimer_Tick(object? sender, EventArgs e) + { + if (!_isStreaming || !_agentLoop.IsRunning) + { + StopLiveAgentProgressHints(); + return; + } + + var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; + if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + UpdateLiveAgentProgressHint(null); + return; + } + + var idle = DateTime.UtcNow - _lastAgentProgressEventAt; + string? summary = null; + var toolName = "agent_wait"; + + if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2)) + { + toolName = "context_compaction"; + summary = idle >= TimeSpan.FromSeconds(12) + ? "컨텍스트를 압축한 뒤 응답을 정리하는 중입니다..." + : "컨텍스트를 압축하고 있습니다..."; + } + else if (idle >= TimeSpan.FromSeconds(12)) + { + summary = "응답을 정리하는 중입니다. 아직 처리 중입니다..."; + } + else if (idle >= TimeSpan.FromSeconds(5)) + { + summary = "생각을 정리하는 중입니다..."; + } + + UpdateLiveAgentProgressHint(summary, toolName); + } + + private void UpdateLiveAgentProgressHint(string? summary, string toolName = "") + { + var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim(); + var currentSummary = _liveAgentProgressHint?.Summary; + var currentToolName = _liveAgentProgressHint?.ToolName ?? ""; + var elapsedMs = _isStreaming + ? Math.Max(0L, (long)(DateTime.UtcNow - _streamStartTime.ToUniversalTime()).TotalMilliseconds) + : 0L; + var inputTokens = Math.Max(0, _agentCumulativeInputTokens); + var outputTokens = Math.Max(0, _agentCumulativeOutputTokens); + var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 1000; + var nextElapsedBucket = elapsedMs / 1000; + if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal) + && string.Equals(currentToolName, toolName, StringComparison.Ordinal) + && currentElapsedBucket == nextElapsedBucket + && (_liveAgentProgressHint?.InputTokens ?? 0) == inputTokens + && (_liveAgentProgressHint?.OutputTokens ?? 0) == outputTokens) + return; + + _liveAgentProgressHint = normalizedSummary == null + ? null + : new AgentEvent + { + Timestamp = DateTime.Now, + RunId = _appState.AgentRun.RunId, + Type = AgentEventType.Thinking, + ToolName = toolName, + Summary = normalizedSummary, + ElapsedMs = elapsedMs, + InputTokens = inputTokens, + OutputTokens = outputTokens, + }; + + if (MessagePanel != null) + RenderMessages(preserveViewport: true); + } + + private AgentEvent? GetLiveAgentProgressHint() + { + if (_liveAgentProgressHint == null) + return null; + + return new AgentEvent + { + Timestamp = _liveAgentProgressHint.Timestamp, + RunId = _liveAgentProgressHint.RunId, + Type = _liveAgentProgressHint.Type, + ToolName = _liveAgentProgressHint.ToolName, + Summary = _liveAgentProgressHint.Summary, + FilePath = _liveAgentProgressHint.FilePath, + Success = _liveAgentProgressHint.Success, + StepCurrent = _liveAgentProgressHint.StepCurrent, + StepTotal = _liveAgentProgressHint.StepTotal, + Steps = _liveAgentProgressHint.Steps, + ElapsedMs = _liveAgentProgressHint.ElapsedMs, + InputTokens = _liveAgentProgressHint.InputTokens, + OutputTokens = _liveAgentProgressHint.OutputTokens, + ToolInput = _liveAgentProgressHint.ToolInput, + Iteration = _liveAgentProgressHint.Iteration, + }; + } + private void OnSubAgentStatusChanged(SubAgentStatusEvent evt) { Dispatcher.Invoke(() =>