diff --git a/README.md b/README.md
index 4d60f01..e9cc0d0 100644
--- a/README.md
+++ b/README.md
@@ -1647,3 +1647,7 @@ MIT License
- `time-based` tool result 정리 기준을 모델/서비스별로 세분화해, Claude는 더 보수적으로, Qwen/vLLM 계열은 더 빠르게 오래된 결과를 걷어내도록 조정했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `service:model` 조합에 따라 `gapThresholdMinutes`와 `keepRecent`를 다르게 계산하도록 바뀌었습니다.
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 긴 상세 줄 목록 대신 한 줄 요약 카드로 단순화해, transcript에서 운영성 카드 밀도를 더 낮췄습니다.
+- 업데이트: 2026-04-12 22:27 (KST)
+ - compact 이후 응답/컨텍스트 사용 표시에서 남아 있던 후행 운영 메타를 더 줄였습니다.
+ - [ChatWindow.ResponsePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs)는 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거해, 일반 응답과 같은 밀도로 보이도록 정리했습니다.
+ - [ChatWindow.ContextUsagePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs)는 token usage 팝업 detail에서 `compact 후 첫 응답 대기 중` 문구를 빼고 실제 컨텍스트/압축 정보만 보여주도록 단순화했습니다.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index fd190d6..7245030 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -645,3 +645,15 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- compact 메타 카드를 긴 line-by-line 상세 표시 대신 제목 + 짧은 한 줄 설명으로 단순화했습니다.
- `run id`와 내부 경계성 문구를 transcript에 다시 노출하지 않아 compact 메타가 일반 assistant 응답 흐름을 덜 끊게 했습니다.
+## compact 후행 메타 정리 (2026-04-12 22:27 KST)
+
+- compact 직후 상태를 기록하는 내부 플래그는 유지하되, 사용자에게 보이는 응답 메타와 usage 팝업에서는 후행 운영 문구를 더 줄였습니다.
+- `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`
+ - 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거했습니다.
+ - 실행/토큰 정보는 유지하면서도, 일반 응답과 운영 메타 응답이 시각적으로 다르게 보이지 않도록 맞췄습니다.
+- `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs`
+ - token usage 팝업의 detail 줄에서 `compact 후 첫 응답 대기 중` 문구를 제거하고, 실제 컨텍스트 사용량/압축 정보만 남겼습니다.
+- 기대 효과
+ - compact 이후에도 transcript와 usage UI가 일반 응답과 더 비슷한 밀도로 이어집니다.
+ - 운영 상태는 내부 로직/통계에만 남기고, 사용자 화면은 더 `claw-code` 스타일의 얇은 표현에 가까워집니다.
+
diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
index 1e92c3f..c61474e 100644
--- a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
+++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
@@ -52,7 +52,18 @@ public partial class ChatWindow
var draftText = InputBox?.Text ?? "";
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
- var currentTokens = Math.Max(0, messageTokens + draftTokens);
+
+ // 시스템 프롬프트 + 도구 정의 오버헤드: 첫 메시지 전송 이후에만 포함
+ // 새 대화(메시지 0개)에서는 0% 표시 — 사용자 혼동 방지
+ var hasAnyMessages = messageTokens > 0 || _isStreaming;
+ int baseOverhead = 0;
+ if (hasAnyMessages)
+ {
+ var sysPromptLen = _llm?.SystemPrompt?.Length ?? 0;
+ var toolCount = _toolRegistry?.GetActiveToolsForTab(_activeTab ?? "Chat")?.Count ?? 0;
+ baseOverhead = Services.TokenEstimator.EstimateBaseOverhead(sysPromptLen, toolCount);
+ }
+ var currentTokens = Math.Max(0, messageTokens + draftTokens + baseOverhead);
var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens);
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
@@ -102,14 +113,15 @@ public partial class ChatWindow
TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}";
CompactNowLabel.Text = compactLabel;
+ ViewModel.ContextTokens = currentTokens;
+ ViewModel.MaxContextTokens = maxContextTokens;
+
if (TokenUsagePopupTitle != null)
TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}";
if (TokenUsagePopupUsage != null)
TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}";
if (TokenUsagePopupDetail != null)
- TokenUsagePopupDetail.Text = _pendingPostCompaction
- ? $"compact 후 첫 응답 대기 중 · {detailText}"
- : detailText;
+ TokenUsagePopupDetail.Text = detailText;
if (TokenUsagePopupCompact != null)
TokenUsagePopupCompact.Text = _sessionCompactionCount > 0
? $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)} tokens"
diff --git a/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs
new file mode 100644
index 0000000..1868684
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs
@@ -0,0 +1,621 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using AxCopilot.Models;
+using AxCopilot.Services;
+using AxCopilot.Services.Agent;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ // ─── 응답 재생성 ──────────────────────────────────────────────────────
+
+ private async Task RegenerateLastAsync()
+ {
+ if (_streamingTabs.Contains(_activeTab)) return;
+ ChatConversation conv;
+ lock (_convLock)
+ {
+ if (_currentConversation == null) return;
+ conv = _currentConversation;
+ }
+
+ // 마지막 assistant 메시지 제거
+ lock (_convLock)
+ {
+ var session = ChatSession;
+ if (session != null)
+ {
+ session.RemoveLastAssistantMessage(_activeTab, _storage);
+ _currentConversation = session.CurrentConversation;
+ conv = _currentConversation!;
+ }
+ else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
+ {
+ conv.Messages.RemoveAt(conv.Messages.Count - 1);
+ }
+ }
+
+ RenderMessages(preserveViewport: true);
+ AutoScrollIfNeeded();
+
+ // 재전송
+ await SendRegenerateAsync(conv);
+ }
+
+ /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.
+ private void ShowRetryWithFeedbackInput()
+ {
+ if (_streamingTabs.Contains(_activeTab)) return;
+ var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
+ var itemBg = TryFindResource("ItemBackground") as Brush
+ ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
+
+ var container = new Border
+ {
+ Margin = new Thickness(40, 4, 40, 8),
+ Padding = new Thickness(14, 10, 14, 10),
+ CornerRadius = new CornerRadius(12),
+ Background = itemBg,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ };
+
+ var stack = new StackPanel();
+ stack.Children.Add(new TextBlock
+ {
+ Text = "어떻게 수정하면 좋을지 알려주세요:",
+ FontSize = 12,
+ Foreground = secondaryText,
+ Margin = new Thickness(0, 0, 0, 6),
+ });
+
+ var textBox = new TextBox
+ {
+ MinHeight = 38,
+ MaxHeight = 80,
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 13,
+ Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
+ Foreground = primaryText,
+ CaretBrush = primaryText,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 6, 10, 6),
+ };
+ stack.Children.Add(textBox);
+
+ var btnRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 8, 0, 0),
+ };
+
+ var sendBtn = new Border
+ {
+ Background = accentBrush,
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(14, 6, 14, 6),
+ Cursor = Cursors.Hand,
+ Margin = new Thickness(6, 0, 0, 0),
+ };
+ sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
+ sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
+ sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
+ sendBtn.MouseLeftButtonUp += (_, _) =>
+ {
+ var feedback = textBox.Text.Trim();
+ if (string.IsNullOrEmpty(feedback)) return;
+ RemoveTranscriptElement(container);
+ _ = RetryWithFeedbackAsync(feedback);
+ };
+
+ var cancelBtn = new Border
+ {
+ Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(12, 6, 12, 6),
+ Cursor = Cursors.Hand,
+ };
+ cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
+ cancelBtn.MouseLeftButtonUp += (_, _) => RemoveTranscriptElement(container);
+
+ btnRow.Children.Add(cancelBtn);
+ btnRow.Children.Add(sendBtn);
+ stack.Children.Add(btnRow);
+ container.Child = stack;
+
+ ApplyMessageEntryAnimation(container);
+ AddTranscriptElement(container);
+ ForceScrollToEnd();
+ textBox.Focus();
+ }
+
+ /// 사용자 피드백과 함께 마지막 응답을 재생성합니다.
+ private async Task RetryWithFeedbackAsync(string feedback)
+ {
+ if (_streamingTabs.Contains(_activeTab)) return;
+ ChatConversation conv;
+ lock (_convLock)
+ {
+ if (_currentConversation == null) return;
+ conv = _currentConversation;
+ }
+
+ // 마지막 assistant 메시지 제거
+ lock (_convLock)
+ {
+ var session = ChatSession;
+ if (session != null)
+ {
+ session.RemoveLastAssistantMessage(_activeTab, _storage);
+ _currentConversation = session.CurrentConversation;
+ conv = _currentConversation!;
+ }
+ else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
+ {
+ conv.Messages.RemoveAt(conv.Messages.Count - 1);
+ }
+ }
+
+ // 피드백을 사용자 메시지로 추가
+ var feedbackMsg = new ChatMessage
+ {
+ Role = "user",
+ Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
+ };
+ lock (_convLock)
+ {
+ var session = ChatSession;
+ if (session != null)
+ {
+ session.AppendMessage(_activeTab, feedbackMsg, _storage);
+ _currentConversation = session.CurrentConversation;
+ conv = _currentConversation!;
+ }
+ else
+ {
+ conv.Messages.Add(feedbackMsg);
+ }
+ }
+
+ RenderMessages(preserveViewport: true);
+ AutoScrollIfNeeded();
+
+ // 재전송
+ await SendRegenerateAsync(conv);
+ }
+
+ private async Task SendRegenerateAsync(ChatConversation conv)
+ {
+ var runTab = NormalizeTabName(conv.Tab);
+ AxAgentExecutionEngine.PreparedExecution preparedExecution;
+ lock (_convLock)
+ preparedExecution = PrepareExecutionForConversation(conv, runTab, null);
+ await ExecutePreparedTurnAsync(
+ conv,
+ runTab,
+ conv.Tab ?? _activeTab,
+ preparedExecution,
+ "에이전트 작업 중...");
+ }
+
+ /// 채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다.
+ private double GetMessageMaxWidth()
+ {
+ var hostWidth = _lastResponsiveComposerWidth;
+ if (hostWidth < 100)
+ hostWidth = ComposerShell?.ActualWidth ?? 0;
+ if (hostWidth < 100)
+ hostWidth = MessageList?.ActualWidth ?? 0;
+ if (hostWidth < 100)
+ hostWidth = 1120;
+
+ var maxW = hostWidth - 44;
+ return Math.Clamp(maxW, 320, 760);
+ }
+
+ private bool UpdateResponsiveChatLayout()
+ {
+ var viewportWidth = MessageList?.ActualWidth ?? 0;
+ if (viewportWidth < 200)
+ viewportWidth = ActualWidth;
+ if (viewportWidth < 200)
+ return false;
+
+ // claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록,
+ // 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다.
+ var contentWidth = Math.Max(360, viewportWidth - 24);
+ var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960);
+ var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900);
+
+ if (contentWidth < 760)
+ {
+ messageWidth = Math.Clamp(contentWidth - 10, 344, 820);
+ composerWidth = Math.Clamp(contentWidth - 14, 340, 780);
+ }
+
+ var changed = false;
+ if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1)
+ {
+ _lastResponsiveMessageWidth = messageWidth;
+ if (MessageList != null)
+ MessageList.MaxWidth = messageWidth + 48;
+ if (EmptyState != null)
+ EmptyState.MaxWidth = messageWidth;
+ changed = true;
+ }
+
+ if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1)
+ {
+ _lastResponsiveComposerWidth = composerWidth;
+ if (ComposerShell != null)
+ {
+ ComposerShell.Width = composerWidth;
+ ComposerShell.MaxWidth = composerWidth;
+ }
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ private StackPanel CreateStreamingContainer(out TextBlock streamText)
+ {
+ var msgMaxWidth = GetMessageMaxWidth();
+ var container = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Width = msgMaxWidth,
+ MaxWidth = msgMaxWidth,
+ Margin = new Thickness(0, 3, 0, 3),
+ Opacity = 0,
+ RenderTransform = new TranslateTransform(0, 10)
+ };
+
+ // 컨테이너 페이드인 + 슬라이드 업
+ container.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
+ ((TranslateTransform)container.RenderTransform).BeginAnimation(
+ TranslateTransform.YProperty,
+ new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
+ { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
+
+ var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) };
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var aiIcon = new TextBlock
+ {
+ Text = "\uE945", FontFamily = s_segoeIconFont, FontSize = 8,
+ Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ // AI 아이콘 펄스 애니메이션 (응답 대기 중)
+ aiIcon.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
+ { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
+ EasingFunction = new SineEase() });
+ _activeAiIcon = aiIcon;
+ Grid.SetColumn(aiIcon, 0);
+ headerGrid.Children.Add(aiIcon);
+
+ var (streamAgentName, _, _) = GetAgentIdentity();
+ var aiNameTb = new TextBlock
+ {
+ Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(aiNameTb, 1);
+ headerGrid.Children.Add(aiNameTb);
+
+ // 실시간 경과 시간 (헤더 우측)
+ _elapsedLabel = new TextBlock
+ {
+ Text = "0s",
+ FontSize = 9.5,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Opacity = 0.5,
+ };
+ Grid.SetColumn(_elapsedLabel, 2);
+ headerGrid.Children.Add(_elapsedLabel);
+
+ container.Children.Add(headerGrid);
+
+ var streamCard = new Border
+ {
+ Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(14),
+ Padding = new Thickness(13, 10, 13, 10)
+ };
+ streamText = new TextBlock
+ {
+ Text = "\u258c",
+ FontSize = 12.5,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ TextWrapping = TextWrapping.Wrap,
+ LineHeight = 20,
+ };
+ streamCard.Child = streamText;
+ container.Children.Add(streamCard);
+ return container;
+ }
+
+ // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
+
+ private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
+ {
+ finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent;
+
+ // 스트리밍 plaintext 카드 제거
+ if (streamText.Parent is Border streamCard)
+ container.Children.Remove(streamCard);
+ else
+ container.Children.Remove(streamText);
+
+ // 마크다운 렌더링
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ 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);
+ mdPanel.Margin = new Thickness(0, 0, 0, 4);
+ mdPanel.Opacity = 0;
+ var mdCard = new Border
+ {
+ Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(14),
+ Padding = new Thickness(13, 10, 13, 10),
+ Child = mdPanel,
+ };
+ container.Children.Add(mdCard);
+ mdPanel.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
+
+ // 액션 버튼 바 + 토큰 표시
+ var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var capturedContent = finalContent;
+ var actionBar = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(2, 2, 0, 0),
+ Opacity = 0
+ };
+ actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
+ {
+ try { Clipboard.SetText(capturedContent); } catch { }
+ }));
+ actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
+ actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
+ AddLinkedFeedbackButtons(actionBar, btnColor, message);
+
+ container.Children.Add(actionBar);
+ container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
+ container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
+ container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
+
+ // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
+ var elapsedText = TryGetStreamingElapsed(out var elapsed)
+ ? (elapsed.TotalSeconds < 60
+ ? $"{elapsed.TotalSeconds:0.#}s"
+ : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s")
+ : "0s";
+
+ var usage = _llm.LastTokenUsage;
+ // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
+ var isAgentTab = _activeTab is "Cowork" or "Code";
+ var (usageService, usageModel) = _llm.GetCurrentModelInfo();
+ var tabCumIn = _tabCumulativeInputTokens.GetValueOrDefault(_activeTab);
+ var tabCumOut = _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab);
+ var displayInput = isAgentTab && tabCumIn > 0
+ ? (int)tabCumIn
+ : usage?.PromptTokens ?? 0;
+ var displayOutput = isAgentTab && tabCumOut > 0
+ ? (int)tabCumOut
+ : usage?.CompletionTokens ?? 0;
+
+ var wasPostCompactionResponse = _pendingPostCompaction;
+ if (displayInput > 0 || displayOutput > 0)
+ {
+ UpdateStatusTokens(displayInput, displayOutput);
+ Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse);
+ ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput);
+ }
+ string tokenText;
+ if (displayInput > 0 || displayOutput > 0)
+ tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
+ else if (usage != null)
+ tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
+ else
+ tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
+
+ var metaText = new TextBlock
+ {
+ Text = $"{elapsedText} · {tokenText}",
+ FontSize = 9.5,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 4, 0, 0),
+ Opacity = 0.55,
+ };
+ container.Children.Add(metaText);
+
+ // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
+ var chips = ParseSuggestionChips(finalContent);
+ if (chips.Count > 0)
+ {
+ var chipPanel = new WrapPanel
+ {
+ Margin = new Thickness(0, 8, 0, 4),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ };
+ foreach (var (num, label) in chips)
+ {
+ var chipBorder = new Border
+ {
+ Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(14, 7, 14, 7),
+ Margin = new Thickness(0, 0, 8, 6),
+ Cursor = Cursors.Hand,
+ RenderTransformOrigin = new Point(0.5, 0.5),
+ RenderTransform = new ScaleTransform(1, 1),
+ };
+ chipBorder.Child = new TextBlock
+ {
+ Text = $"{num}. {label}",
+ FontSize = 12.5,
+ Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
+ };
+
+ var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
+ var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
+ chipBorder.MouseEnter += (s, _) =>
+ {
+ if (s is Border b && b.RenderTransform is ScaleTransform st)
+ { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
+ };
+ chipBorder.MouseLeave += (s, _) =>
+ {
+ if (s is Border b && b.RenderTransform is ScaleTransform st)
+ { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
+ };
+
+ var capturedLabel = $"{num}. {label}";
+ var capturedPanel = chipPanel;
+ chipBorder.MouseLeftButtonDown += (_, _) =>
+ {
+ // 칩 패널 제거 (1회용)
+ if (capturedPanel.Parent is Panel parent)
+ parent.Children.Remove(capturedPanel);
+ // 선택한 옵션을 사용자 메시지로 전송
+ InputBox.Text = capturedLabel;
+ _ = SendMessageAsync();
+ };
+ chipPanel.Children.Add(chipBorder);
+ }
+ container.Children.Add(chipPanel);
+ }
+
+ // 완성된 메시지 컨테이너를 GPU 비트맵으로 캐시 → 스크롤 시 레이아웃 재계산 없이 렌더링
+ container.CacheMode = new System.Windows.Media.BitmapCache(1.0)
+ { SnapsToDevicePixels = true };
+ }
+
+ /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)
+ private static List<(string Num, string Label)> ParseSuggestionChips(string content)
+ {
+ var chips = new List<(string, string)>();
+ if (string.IsNullOrEmpty(content)) return chips;
+
+ var lines = content.Split('\n');
+ // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
+ var candidates = new List<(string, string)>();
+ var lastBlockStart = -1;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i].Trim();
+ // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
+ var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
+ if (m.Success)
+ {
+ if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
+ {
+ if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
+ candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
+ }
+ else
+ {
+ // 새로운 블록 시작
+ lastBlockStart = i;
+ candidates.Clear();
+ candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(line))
+ {
+ // 번호 목록이 아닌 줄이 나오면 블록 리셋
+ lastBlockStart = -1;
+ candidates.Clear();
+ }
+ // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
+ }
+
+ // 2개 이상 선택지, 10개 이하일 때만 chips로 표시
+ if (candidates.Count >= 2 && candidates.Count <= 10)
+ chips.AddRange(candidates);
+
+ return chips;
+ }
+
+ /// 토큰 수를 k/m 단위로 포맷
+ private static string FormatTokenCount(int count) => count switch
+ {
+ >= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
+ >= 1_000 => $"{count / 1_000.0:0.#}k",
+ _ => count.ToString(),
+ };
+
+ /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)
+ private static int EstimateTokenCount(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return 0;
+ // 한국어 문자 비율에 따라 가중
+ int cjk = 0;
+ foreach (var c in text)
+ if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
+ double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
+ double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
+ return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
+ }
+
+ // ─── 생성 중지 ──────────────────────────────────────────────────────
+
+ private void StopGeneration()
+ {
+ // 현재 활성 탭이 스트리밍 중이면 그 탭만 취소; 아니면 모두 취소
+ if (_tabStreamCts.TryGetValue(_activeTab, out var activeCts))
+ activeCts.Cancel();
+ else
+ foreach (var cts in _tabStreamCts.Values) cts.Cancel();
+
+ // 대기열 정리: 실행 중 + 대기 중 항목 모두 제거 (중지는 "전부 멈춤"을 의미)
+ lock (_convLock)
+ {
+ _draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage);
+ _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage);
+ _runningDraftId = null;
+ }
+ RefreshDraftQueueUi();
+
+ // 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음
+ StopLiveAgentProgressHints();
+ RemoveAgentLiveCard();
+ HideStickyProgress();
+ StopRainbowGlow();
+ }
+
+ // ─── 대화 내보내기 ──────────────────────────────────────────────────
+}