Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.xaml.cs
lacvet f4351aa0eb 권한 체계를 사내 모드 기준으로 정리하고 실행 단위 승인 범위를 바로잡음
사내 모드에서 process/build_run/open_external 경로의 외부 접근 차단 범위를 강화했습니다. http_tool과 외부 URI 차단에 더해 curl, Invoke-WebRequest 같은 네트워크성 명령과 build_run custom 실행을 내부 정책으로 막아 실제 동작이 정책 선언과 더 가깝게 맞춰지도록 했습니다.

ChatWindow의 '이번 실행 동안 허용' 승인 규칙을 run-scope로 변경했습니다. 탭 실행 시작과 종료 시 승인 캐시를 초기화하고 같은 실행 안에서만 동일 범위 접근을 재질문 없이 재사용하도록 정리해 창 수명 동안 규칙이 남던 문제를 줄였습니다.

권한 건너뛰기 관련 UI/상태 문구를 실제 동작과 맞췄고, OperationModePolicyTests·OperationModeReadinessTests·AgentLoopE2ETests·LlmOperationModeTests를 통해 권한 정책과 사내 모드 차단 회귀를 검증했습니다. dotnet build 경고 0 / 오류 0, 권한 관련 테스트 49건 통과를 확인했습니다.
2026-04-15 16:34:34 +09:00

9005 lines
369 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using System.IO;
using Microsoft.Win32;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
/// <summary>AX Agent 창. 데스크톱 코파일럿 스타일 — 사이드바 + 카테고리 분류 + 타임라인.</summary>
public partial class ChatWindow : Window
{
private const string UnifiedAdminPassword = "axgo123!";
private bool IsOverlayEncryptionEnabled => _settings.Settings.Llm.EncryptionEnabled;
private readonly ISettingsService _settings;
private readonly IChatStorageService _storage;
private readonly DraftQueueProcessorService _draftQueueProcessor = new();
private readonly ILlmService _llm;
private readonly ToolRegistry _toolRegistry;
private readonly Dictionary<string, AgentLoopService> _agentLoops = new();
private readonly AxAgentExecutionEngine _chatEngine;
private readonly IModelRouterService _router;
private readonly AppStateService _appState;
private readonly object _convLock = new();
private readonly Dictionary<string, FrameworkElement> _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase);
private ChatConversation? _currentConversation;
private string? _runningDraftId;
private readonly HashSet<string> _streamingTabs = [];
private readonly Dictionary<string, CancellationTokenSource> _tabStreamCts = new();
private bool _isStreaming => _streamingTabs.Count > 0;
private bool _sidebarVisible = true;
private double _sidebarExpandedWidth = 262;
private bool _isInWindowMoveSizeLoop;
private bool _pendingResponsiveLayoutRefresh;
private bool _pendingHiddenExecutionHistoryRender;
private bool _pendingBackgroundTaskSummaryRefresh;
private bool _pendingBackgroundInputUiRefresh;
private bool _pendingBackgroundAgentUiEventFlush;
private DispatcherTimer? _settingsSaveTimer;
private CacheMode? _cachedRootCacheModeBeforeMove;
private string _selectedCategory = ""; // "" = 전체
private readonly Dictionary<string, string> _tabSelectedCategory = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = "",
["Cowork"] = "",
["Code"] = "",
};
private readonly Dictionary<string, bool> _tabSidebarVisible = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = true,
["Cowork"] = true,
["Code"] = true,
};
private readonly Dictionary<string, bool> _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase);
private bool _failedOnlyFilter;
private bool _runningOnlyFilter;
/// <summary>아카이브 필터: null=일반만, true=아카이브만, false=전체</summary>
private bool? _archiveFilter = null;
private int _failedConversationCount;
private int _runningConversationCount;
private int _spotlightConversationCount;
private bool _sortConversationsByRecent = false;
private bool _isInlineSettingsSyncing;
private string? _streamRunTab;
private bool _forceClose = false; // 앱 종료 시 진짜 닫기 플래그
// 스트리밍 UI — 커서 깜빡임 + 로딩 아이콘
private readonly DispatcherTimer _cursorTimer;
private bool _cursorVisible = true;
private TextBlock? _activeStreamText;
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
private readonly char[] _streamDisplayBuffer = new char[256 * 1024]; // 256KB 재사용 버퍼 (타이핑 표시용)
private int _streamDisplayBufferLen; // 버퍼에 기록된 실제 길이
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private DispatcherTimer? _activeSpinnerTimer; // 스트리밍 유니코드 스피너 타이머
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
private IPlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
private Border? _userAskCard; // transcript 내 질문 카드
private string? _pendingPlanSummary;
private List<string> _pendingPlanSteps = new();
// 현재 실행 중인 에이전트 스텝 누적 (통합 진행 카드용)
private readonly List<AgentEvent> _currentRunProgressSteps = new();
private bool _userScrolled; // 사용자가 위로 스크롤했는지
private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64)
// 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지)
private readonly Dictionary<string, UIElement> _elementCache = new(StringComparer.Ordinal);
// 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용)
private List<string> _lastRenderedTimelineKeys = new();
private int _lastRenderedHiddenCount;
// 스트리밍 중 불필요한 재렌더링 방지용 카운터
private int _lastRenderedMessageCount;
private int _lastRenderedEventCount;
private bool _lastRenderedShowHistory;
private readonly Dictionary<string, HashSet<string>> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly object _sessionPermissionRulesLock = new();
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
// 경과 시간 표시
private readonly DispatcherTimer _elapsedTimer;
private DateTime _streamStartTime;
private TextBlock? _elapsedLabel;
// 에이전트 루프 라이브 카드 (채팅창 내 처리 과정 실시간 표시)
private StackPanel? _agentLiveContainer;
private TextBlock? _agentLiveStatusText;
private StackPanel? _agentLiveSubItems;
#pragma warning disable CS0414 // V1 레거시 — RemoveAgentLiveCard에서 null 초기화용
private TextBlock? _agentLiveElapsedText;
#pragma warning restore CS0414
private readonly List<string> _agentLiveSubItemTexts = new();
private string? _agentLiveCurrentCategory;
private DispatcherTimer? _agentLiveElapsedTimer;
// 타이핑 효과
private readonly DispatcherTimer _typingTimer;
private readonly DispatcherTimer _gitRefreshTimer;
private readonly DispatcherTimer _conversationSearchTimer;
private readonly DispatcherTimer _inputUiRefreshTimer;
private readonly DispatcherTimer _executionHistoryRenderTimer;
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? _workspaceSkillRefreshCts;
private int _workspaceSkillRefreshVersion;
private CancellationTokenSource? _gitStatusRefreshCts;
private int _displayedLength; // 현재 화면에 표시된 글자 수
private ResourceDictionary? _agentThemeDictionary;
private bool _isOverlaySettingsSyncing;
private string? _currentGitBranchName;
private string? _currentGitTooltip;
private string? _currentGitRoot;
private int _currentGitChangedFileCount;
private int _currentGitInsertions;
private int _currentGitDeletions;
private List<string> _currentGitBranches = new();
private string? _currentGitUpstreamStatus;
private readonly List<string> _recentGitBranches = new();
private string _gitBranchSearchText = "";
private StackPanel? _selectedMessageActionBar;
private Border? _selectedMessageBorder;
private bool _isRefreshingFromSettings;
private int? _lastCompactionBeforeTokens;
private int? _lastCompactionAfterTokens;
private DateTime? _lastCompactionAt;
private bool _lastCompactionWasAutomatic;
private string _lastCompactionStageSummary = "";
private int _sessionCompactionCount;
private int _sessionAutomaticCompactionCount;
private int _sessionManualCompactionCount;
private int _sessionCompactionSavedTokens;
private int _sessionMemoryCompactionCount;
private int _sessionMicrocompactBoundaryCount;
private int _sessionSnipCompactionCount;
private bool _pendingPostCompaction;
private int _sessionPostCompactionResponseCount;
private int _sessionPostCompactionPromptTokens;
private int _sessionPostCompactionCompletionTokens;
private bool _pendingExecutionHistoryAutoScroll;
private readonly Dictionary<string, ChatConversation> _pendingConversationPersists = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _expandedDraftQueueTabs = new(StringComparer.OrdinalIgnoreCase);
private AgentEvent? _pendingAgentUiEvent;
private AgentEvent? _liveAgentProgressHint;
private DateTime _lastAgentProgressEventAt = DateTime.UtcNow;
private double _lastResponsiveComposerWidth;
private double _lastResponsiveMessageWidth;
private bool IsLightweightLiveProgressMode(string? runTab = null)
{
var tab = string.IsNullOrWhiteSpace(runTab) ? (_streamRunTab ?? _activeTab) : runTab!;
if (!string.Equals(tab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(tab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
ChatConversation? conversation;
lock (_convLock)
conversation = _currentConversation;
return !(conversation?.ShowExecutionHistory ?? true);
}
private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg)
{
if (button?.Content is not string text)
return;
button.Background = active ? BrushFromHex(activeBg) : Brushes.Transparent;
button.BorderBrush = active ? BrushFromHex(activeBg) : Brushes.Transparent;
button.BorderThickness = new Thickness(active ? 1 : 0);
button.Foreground = active ? BrushFromHex(activeFg) : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
button.Content = text;
}
private sealed class ConversationMeta
{
public string Id { get; init; } = "";
public string Title { get; init; } = "";
public string UpdatedAtText { get; init; } = "";
public bool Pinned { get; init; }
public string Category { get; init; } = ChatCategory.General;
public string Symbol { get; init; } = "\uE8BD";
public string ColorHex { get; init; } = "#6B7280";
public string Tab { get; init; } = "Chat";
public DateTime UpdatedAt { get; init; }
/// <summary>첫 사용자 메시지 요약 (검색용, 최대 100자).</summary>
public string Preview { get; init; } = "";
/// <summary>분기 원본 대화 ID. null이면 원본 대화.</summary>
public string? ParentId { get; init; }
public int AgentRunCount { get; init; }
public int FailedAgentRunCount { get; init; }
public string LastAgentRunSummary { get; init; } = "";
public DateTime? LastFailedAt { get; init; }
public DateTime? LastCompletedAt { get; init; }
public bool IsRunning { get; init; }
public string WorkFolder { get; init; } = "";
public bool Archived { get; init; }
}
/// <summary>MVVM ViewModel — XAML 바인딩 대상.</summary>
internal ChatWindowViewModel ViewModel { get; }
public ChatWindow(SettingsService settings)
{
ViewModel = new ChatWindowViewModel();
DataContext = ViewModel;
InitializeComponent();
InitializeTranscriptHost();
_appState = (System.Windows.Application.Current as App)?.AppState ?? new AppStateService();
_settings = settings;
_settings.SettingsChanged += Settings_SettingsChanged;
_storage = ServiceLocator.Get<IChatStorageService>();
_llm = ServiceLocator.Get<ILlmService>();
_router = ServiceLocator.Get<IModelRouterService>();
_toolRegistry = ToolRegistry.CreateDefault();
_chatEngine = new AxAgentExecutionEngine();
// 탭별 독립 에이전트 루프 생성 — Chat/Cowork/Code 각각 병렬 실행 가능
foreach (var tab in new[] { "Chat", "Cowork", "Code" })
_agentLoops[tab] = CreateAgentLoopForTab(tab, settings);
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
// 에이전트 이벤트 백그라운드 프로세서 시작 (대화 변이·저장을 UI 스레드에서 분리)
StartAgentEventProcessor();
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = "none";
_cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_cursorTimer.Tick += CursorTimer_Tick;
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += ElapsedTimer_Tick;
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
_typingTimer.Tick += TypingTimer_Tick;
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
_gitRefreshTimer.Tick += async (_, _) =>
{
_gitRefreshTimer.Stop();
await RefreshGitBranchStatusAsync();
};
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_conversationSearchTimer.Tick += (_, _) =>
{
_conversationSearchTimer.Stop();
RefreshConversationList();
};
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_inputUiRefreshTimer.Tick += (_, _) =>
{
_inputUiRefreshTimer.Stop();
_inputUiRefreshTimer.Interval = _isStreaming
? (IsLightweightLiveProgressMode()
? TimeSpan.FromMilliseconds(2000)
: TimeSpan.FromMilliseconds(1500))
: TimeSpan.FromMilliseconds(250);
RefreshContextUsageVisual();
RefreshDraftQueueUi();
};
_executionHistoryRenderTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
_executionHistoryRenderTimer.Tick += (_, _) =>
{
_executionHistoryRenderTimer.Stop();
// 스트리밍 중에는 재렌더링 빈도를 크게 줄여 UI 부하 감소
// Claude Desktop은 React virtual DOM으로 diff만 적용하지만
// WPF는 전체 시각적 트리를 재구성하므로 간격이 넉넉해야 합니다
_executionHistoryRenderTimer.Interval = _isStreaming
? (IsLightweightLiveProgressMode()
? TimeSpan.FromMilliseconds(8000)
: TimeSpan.FromMilliseconds(5000))
: TimeSpan.FromMilliseconds(350);
// 스크롤 수정: 자동 스크롤이 필요하고 사용자가 위로 스크롤하지 않은 경우
// preserveViewport=false로 렌더하여 하단 스크롤 → viewport 보존과 충돌 방지
// (preserveViewport=true는 Background priority로 뷰포트 복원을 예약하는데,
// AutoScrollIfNeeded가 직접 ScrollToEnd하면 뷰포트 복원이 나중에 덮어씀)
var wantsAutoScroll = _pendingExecutionHistoryAutoScroll && !_userScrolled;
_pendingExecutionHistoryAutoScroll = false;
RenderMessages(preserveViewport: !wantsAutoScroll);
};
_taskSummaryRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
_taskSummaryRefreshTimer.Tick += (_, _) =>
{
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Interval = _isStreaming
? (IsLightweightLiveProgressMode()
? TimeSpan.FromMilliseconds(3000)
: TimeSpan.FromMilliseconds(2000))
: TimeSpan.FromMilliseconds(120);
UpdateTaskSummaryIndicators();
};
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
_conversationPersistTimer.Tick += (_, _) =>
{
_conversationPersistTimer.Stop();
FlushPendingConversationPersists();
};
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
_agentUiEventTimer.Tick += (_, _) =>
{
_agentUiEventTimer.Stop();
_agentUiEventTimer.Interval = _isStreaming
? (IsLightweightLiveProgressMode()
? TimeSpan.FromMilliseconds(2000)
: TimeSpan.FromMilliseconds(1500))
: TimeSpan.FromMilliseconds(200);
FlushPendingAgentUiEvent();
};
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_agentProgressHintTimer.Tick += AgentProgressHintTimer_Tick;
_tokenUsagePopupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
_tokenUsagePopupCloseTimer.Tick += (_, _) =>
{
_tokenUsagePopupCloseTimer.Stop();
CloseTokenUsagePopupIfIdle();
};
_responsiveLayoutTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_responsiveLayoutTimer.Tick += (_, _) =>
{
_responsiveLayoutTimer.Stop();
// 스트리밍 중 전체 메시지 재렌더링은 UI 부하가 크므로 연기
if (_isStreaming)
{
_pendingResponsiveLayoutRefresh = true;
return;
}
UpdateTopicPresetScrollMode();
if (UpdateResponsiveChatLayout())
RenderMessages(preserveViewport: true);
};
KeyDown += ChatWindow_KeyDown;
MouseMove += (_, _) =>
{
if (_isInWindowMoveSizeLoop)
return;
if (TokenUsagePopup?.IsOpen == true)
CloseTokenUsagePopupIfIdle();
};
PreviewMouseDown += (_, _) =>
{
if (TokenUsagePopup != null)
TokenUsagePopup.IsOpen = false;
};
Deactivated += (_, _) =>
{
if (TokenUsagePopup != null)
TokenUsagePopup.IsOpen = false;
};
Activated += (_, _) => { FlushDeferredUiRefreshIfNeeded(); EnsureEmptyStateConsistency(); };
IsVisibleChanged += (_, _) => FlushDeferredUiRefreshIfNeeded();
WindowState previousWindowState = WindowState.Normal;
StateChanged += (_, _) =>
{
FlushDeferredUiRefreshIfNeeded();
// 최소화 → 복원 시에만 렌더 — 그 외 상태 변경(최대화 등)에서는 불필요한 재렌더 방지
var currentState = WindowState;
if (previousWindowState == WindowState.Minimized
&& currentState != WindowState.Minimized
&& IsVisible
&& !_isStreaming) // 스트리밍 중에는 렌더 스킵 (EmptyState 복원 방지)
{
InvalidateTimelineCache();
RenderMessages();
}
previousWindowState = currentState;
};
UpdateConversationFailureFilterUi();
UpdateConversationSortUi();
UpdateConversationRunningFilterUi();
Loaded += (_, _) =>
{
ApplyAgentThemeResources();
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
SetupUserInfo();
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = GetAutomaticFolderDataUsage();
UpdateAnalyzerButtonVisibility();
UpdateModelLabel();
RefreshInlineSettingsPanel();
ApplyExpressionLevelUi();
UpdateSidebarModeMenu();
RefreshContextUsageVisual();
UpdateTopicPresetScrollMode();
UpdateResponsiveChatLayout();
UpdateInputBoxHeight();
InputBox.Focus();
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
// A-1: 패널 이벤트 위임 1회 초기화 — 개별 람다 대신 부모 레벨에서 처리
InitConversationPanelDelegation();
InitTopicPanelDelegation();
InitPreviewSplitButtonHover();
InitPlanButtonHover();
// 앱 시작 시 이전 세션에서 남은 고착 스트리밍 상태 정리
_streamingTabs.Clear();
_tabStreamCts.Clear();
ViewModel.IsStreaming = false;
_streamRunTab = null;
// 실행이력 상세도 레이블 + 아이콘 초기화
if (ExecutionLogLabel != null)
{
var initLogLabel = (_settings.Settings.Llm.AgentLogLevel ?? "detailed") switch
{
"hidden" => "숨김",
"simple" => "간략",
"debug" => "디버그",
_ => "상세",
};
ExecutionLogLabel.Text = $"실행이력 · {initLogLabel}";
}
if (ExecutionLogIcon != null)
ExecutionLogIcon.Text = "\uE8F8";
Dispatcher.BeginInvoke(new Action(() =>
{
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
RestoreLastConversations();
BuildTopicButtons();
// 마스코트 캐릭터가 "none"이 아니면 앱 시작 시 GIF 미리 로드 (화면에 안 보여도)
var mascotLvl = (_settings.Settings.Llm.Code?.MascotLevel ?? "none").Trim().ToLowerInvariant();
if (mascotLvl != "none")
InitializeMascot();
RefreshConversationList();
UpdateResponsiveChatLayout();
UpdateTaskSummaryIndicators();
ScheduleGitBranchRefresh();
// 데이터 정리 (디스크 I/O)
_ = Task.Run(() =>
{
var retention = _settings.Settings.Llm.RetentionDays;
if (retention > 0) _storage.PurgeExpired(retention);
_storage.PurgeForDiskSpace();
});
}), System.Windows.Threading.DispatcherPriority.Loaded);
// 입력 바 포커스 글로우 효과
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var inputFocusBorderBrush = TryFindResource("InputFocusBorderColor") as Brush ?? borderBrush;
InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = inputFocusBorderBrush;
InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush;
// 드래그 앤 드롭 파일 첨부 — Claude Desktop 스타일
InputBorder.AllowDrop = true;
Brush? _dragOverOriginalBorder = null;
InputBorder.DragEnter += (_, de) =>
{
if (de.Data.GetDataPresent(DataFormats.FileDrop))
{
_dragOverOriginalBorder = InputBorder.BorderBrush;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
InputBorder.BorderBrush = accent;
InputBorder.BorderThickness = new Thickness(2);
}
};
InputBorder.DragLeave += (_, _) =>
{
if (_dragOverOriginalBorder != null)
{
InputBorder.BorderBrush = _dragOverOriginalBorder;
InputBorder.BorderThickness = new Thickness(1);
_dragOverOriginalBorder = null;
}
};
InputBorder.DragOver += (_, de) =>
{
de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
de.Handled = true;
};
InputBorder.Drop += (_, de) =>
{
// 드래그 오버 하이라이트 복원
if (_dragOverOriginalBorder != null)
{
InputBorder.BorderBrush = _dragOverOriginalBorder;
InputBorder.BorderThickness = new Thickness(1);
_dragOverOriginalBorder = null;
}
if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
{
// AI 액션 팝업은 Cowork/Code 탭에서만, 그 외에는 항상 단순 첨부
var enableAi = _settings.Settings.Llm.EnableDragDropAiActions
&& _activeTab is "Cowork" or "Code";
if (enableAi && files.Length <= 5)
ShowDropActionMenu(files);
else
foreach (var f in files) AddAttachedFile(f);
InputBox.Focus();
}
};
// 스킬 시스템 초기화
if (_settings.Settings.Llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
UpdateConditionalSkillActivation(reset: true);
}
// 슬래시 명령어 칩 닫기 (× 버튼)
SlashChipClose.MouseLeftButtonUp += (_, _) =>
{
HideSlashChip(restoreText: true);
InputBox.Focus();
};
// InputBox에서 슬래시 팝업 열린 상태로 마우스 휠 → 팝업 스크롤
InputBox.PreviewMouseWheel += (_, me) =>
{
if (!SlashPopup.IsOpen) return;
me.Handled = true;
SlashPopup_ScrollByDelta(me.Delta);
};
// 탭 UI 초기 상태
UpdateFolderBar();
// 호버 애니메이션 — 독립 공간이 있는 버튼에만 Scale 적용
// (GhostBtn 스타일 버튼은 XAML에서 배경색+opacity 호버 처리)
ApplyHoverBounceAnimation(BtnModelSelector);
ApplyHoverScaleAnimation(BtnSend, 1.12);
ApplyHoverScaleAnimation(BtnStop, 1.12);
};
SizeChanged += (_, _) =>
{
if (_isInWindowMoveSizeLoop)
{
_pendingResponsiveLayoutRefresh = true;
return;
}
_responsiveLayoutTimer.Stop();
_responsiveLayoutTimer.Start();
};
Closed += (_, _) =>
{
_settings.SettingsChanged -= Settings_SettingsChanged;
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
// 모든 DispatcherTimer 명시적 Stop — auto-stop에만 의존하지 않음
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_conversationSearchTimer.Stop();
_inputUiRefreshTimer.Stop();
_responsiveLayoutTimer.Stop();
_gitRefreshTimer.Stop();
_executionHistoryRenderTimer.Stop();
_taskSummaryRefreshTimer.Stop();
_conversationPersistTimer.Stop();
_agentUiEventTimer.Stop();
_agentProgressHintTimer.Stop();
_tokenUsagePopupCloseTimer.Stop();
_smoothScrollTimer?.Stop();
_sidebarAnimTimer?.Stop();
_fileBrowserRefreshTimer?.Stop();
StopRainbowGlow();
StopAgentEventProcessor();
_llm.Dispose();
};
}
private void Settings_SettingsChanged(object? sender, EventArgs e)
{
if (_forceClose || !IsLoaded || _isRefreshingFromSettings)
return;
Dispatcher.BeginInvoke(new Action(() =>
{
if (_forceClose || !IsLoaded)
return;
_isRefreshingFromSettings = true;
try
{
RefreshFromSavedSettings();
}
finally
{
_isRefreshingFromSettings = false;
}
}), DispatcherPriority.Input);
}
private bool IsPermissionAutoApprovedForSession(string tab, string toolName, string target)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
return false;
var normalizedTarget = target.Trim();
var pathLikeTool = IsPathLikePermissionTool(toolName);
List<string> rulesSnapshot;
lock (_sessionPermissionRulesLock)
{
if (!_sessionPermissionRules.TryGetValue(tab, out var rules) || rules.Count == 0)
return false;
rulesSnapshot = rules.ToList();
}
foreach (var rule in rulesSnapshot)
{
var pivot = rule.IndexOf('|');
if (pivot <= 0 || pivot >= rule.Length - 1)
continue;
var ruleTool = rule[..pivot];
var ruleTarget = rule[(pivot + 1)..];
if (!string.Equals(ruleTool, toolName, StringComparison.OrdinalIgnoreCase))
continue;
if (pathLikeTool)
{
if (normalizedTarget.StartsWith(ruleTarget, StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
return true;
}
else if (string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string NormalizePermissionTarget(string toolName, string target, string? workspaceFolder)
{
if (string.IsNullOrWhiteSpace(target))
return target;
var normalizedTarget = target.Trim();
if (!IsPathLikePermissionTool(toolName))
return normalizedTarget;
try
{
if (System.IO.Path.IsPathRooted(normalizedTarget))
return System.IO.Path.GetFullPath(normalizedTarget);
if (!string.IsNullOrWhiteSpace(workspaceFolder))
return System.IO.Path.GetFullPath(System.IO.Path.Combine(workspaceFolder, normalizedTarget));
return System.IO.Path.GetFullPath(normalizedTarget);
}
catch
{
return normalizedTarget;
}
}
private void RememberPermissionRuleForSession(string tab, string toolName, string target)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
return;
var normalizedTarget = target.Trim();
var scopedTarget = normalizedTarget;
if (IsPathLikePermissionTool(toolName))
{
try
{
if (System.IO.Path.IsPathRooted(normalizedTarget))
{
var full = System.IO.Path.GetFullPath(normalizedTarget);
var directory = System.IO.Path.GetDirectoryName(full);
if (!string.IsNullOrWhiteSpace(directory))
scopedTarget = directory.TrimEnd('\\', '/') + System.IO.Path.DirectorySeparatorChar;
}
}
catch
{
// Ignore invalid path and fall back to exact target matching.
}
}
lock (_sessionPermissionRulesLock)
{
if (!_sessionPermissionRules.TryGetValue(tab, out var rules))
{
rules = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_sessionPermissionRules[tab] = rules;
}
rules.Add($"{toolName}|{scopedTarget}");
}
}
private void ResetPermissionRulesForRun(string tab)
{
lock (_sessionPermissionRulesLock)
{
_sessionPermissionRules.Remove(tab);
}
}
private static bool IsPathLikePermissionTool(string toolName)
{
var normalized = toolName.Trim().ToLowerInvariant();
return normalized.Contains("file", StringComparison.Ordinal)
|| normalized.Contains("edit", StringComparison.Ordinal)
|| normalized.Contains("write", StringComparison.Ordinal);
}
/// <summary>경로가 지정 폴더 외부인지 확인합니다.</summary>
private static bool IsPathOutsideFolder(string path, string folder)
{
try
{
var fullPath = System.IO.Path.IsPathRooted(path)
? System.IO.Path.GetFullPath(path).TrimEnd('\\', '/')
: System.IO.Path.GetFullPath(System.IO.Path.Combine(folder, path)).TrimEnd('\\', '/');
var fullFolder = System.IO.Path.GetFullPath(folder).TrimEnd('\\', '/');
return !fullPath.StartsWith(fullFolder, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
/// <summary>
/// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림).
/// 앱 종료 시에는 ForceClose()를 사용합니다.
/// </summary>
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_forceClose)
{
e.Cancel = true;
Hide();
return;
}
base.OnClosing(e);
}
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
public void ForceClose()
{
// 백그라운드 이벤트 프로세서 종료 (미저장 대화 플러시됨)
StopAgentEventProcessor();
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
lock (_convLock)
{
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
_forceClose = true;
Close();
}
// ─── 사용자 정보 ────────────────────────────────────────────────────
private void SetupUserInfo()
{
var userName = Environment.UserName;
// AD\, AD/, AD: 접두사 제거
var cleanName = userName;
foreach (var sep in new[] { '\\', '/', ':' })
{
var idx = cleanName.LastIndexOf(sep);
if (idx >= 0) cleanName = cleanName[(idx + 1)..];
}
var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U";
var pcName = Environment.MachineName;
UserInitialSidebar.Text = initial;
UserInitialIconBar.Text = initial;
UserNameText.Text = cleanName;
UserPcText.Text = pcName;
BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})";
}
// ─── 스크롤 동작 ──────────────────────────────────────────────────
private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
// 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥
if (GetTranscriptScrollableHeight() <= 1)
{
_userScrolled = false;
return;
}
// 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지
if (Math.Abs(e.ExtentHeightChange) > 0.5)
return;
var atBottom = GetTranscriptVerticalOffset() >= GetTranscriptScrollableHeight() - 120;
_userScrolled = !atBottom;
if (ScrollToBottomFab != null)
ScrollToBottomFab.Visibility = _userScrolled ? Visibility.Visible : Visibility.Collapsed;
}
private void ScrollToBottomFab_Click(object sender, RoutedEventArgs e)
{
ForceScrollToEnd();
}
private void AutoScrollIfNeeded()
{
if (!_userScrolled)
SmoothScrollToEnd();
}
/// <summary>새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋).</summary>
private void ForceScrollToEnd()
{
_userScrolled = false;
Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background);
}
/// <summary>부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다.</summary>
private void SmoothScrollToEnd()
{
var targetOffset = GetTranscriptScrollableHeight();
var currentOffset = GetTranscriptVerticalOffset();
var diff = targetOffset - currentOffset;
// 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화)
if (diff <= 60 || _isStreaming)
{
ScrollTranscriptToEnd();
return;
}
// 부드럽게 스크롤 (DoubleAnimation)
var animation = new DoubleAnimation
{
From = currentOffset,
To = targetOffset,
Duration = TimeSpan.FromMilliseconds(200),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
};
animation.Completed += (_, _) => ScrollTranscriptToVerticalOffset(targetOffset);
// ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간
// 기존 스크롤 애니메이션이 진행 중이면 즉시 종료
_smoothScrollTimer?.Stop();
var startTime = DateTime.UtcNow;
_smoothScrollStartOffset = currentOffset;
_smoothScrollDiff = diff;
_smoothScrollStartTime = startTime;
if (_smoothScrollTimer == null)
{
_smoothScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) }; // ~30fps (충분히 부드러움)
_smoothScrollTimer.Tick += SmoothScrollTimer_Tick;
}
_smoothScrollTimer.Start();
}
// ─── 대화 제목 인라인 편집 ──────────────────────────────────────────
private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e)
{
lock (_convLock)
{
if (_currentConversation == null) return;
}
ChatTitle.Visibility = Visibility.Collapsed;
ChatTitleEdit.Text = ChatTitle.Text;
ChatTitleEdit.Visibility = Visibility.Visible;
ChatTitleEdit.Focus();
ChatTitleEdit.SelectAll();
}
private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit();
private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; }
if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; }
}
private void CommitTitleEdit()
{
var newTitle = ChatTitleEdit.Text.Trim();
ChatTitleEdit.Visibility = Visibility.Collapsed;
ChatTitle.Visibility = Visibility.Visible;
if (string.IsNullOrEmpty(newTitle)) return;
lock (_convLock)
{
if (_currentConversation == null) return;
var session = ChatSession;
if (session != null)
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
else
_currentConversation.Title = newTitle;
}
ViewModel.ChatTitle = newTitle;
try
{
if (ChatSession == null)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null) _storage.Save(conv);
}
}
catch (Exception ex)
{
Services.LogService.Warn($"제목 변경 저장 실패: {ex.Message}");
}
RefreshConversationList();
}
private void CancelTitleEdit()
{
ChatTitleEdit.Visibility = Visibility.Collapsed;
ChatTitle.Visibility = Visibility.Visible;
}
// ─── 카테고리 드롭다운 ──────────────────────────────────────────────
private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
PlacementTarget = BtnCategoryDrop,
Placement = PlacementMode.Bottom,
HorizontalOffset = 0,
VerticalOffset = 4,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 180,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var stack = new StackPanel();
Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand,
};
var g = new Grid();
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
var iconTb = new TextBlock
{
Text = icon, FontFamily = s_segoeIconFont,
FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconTb, 0);
g.Children.Add(iconTb);
var textTb = new TextBlock
{
Text = text, FontSize = 12.5, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(textTb, 1);
g.Children.Add(textTb);
if (isSelected)
{
var check = CreateSimpleCheck(accentBrush, 14);
Grid.SetColumn(check, 2);
g.Children.Add(check);
}
item.Child = g;
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); };
return item;
}
Border CreateSep() => new()
{
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
};
// 전체 보기
var allLabel = _activeTab switch
{
"Cowork" => "모든 작업 유형",
"Code" => "모든 프로젝트",
_ => "모든 주제",
};
stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText,
string.IsNullOrEmpty(_selectedCategory),
() => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); }));
stack.Children.Add(CreateSep());
if (_activeTab == "Cowork")
{
// 코워크: 작업 유형 기반 필터
var presets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets);
var categoryItems = presets
.Select(p => (Category: p.Category, Label: p.Label, Symbol: p.Symbol, Color: p.Color))
.Concat(_storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), "Cowork", StringComparison.OrdinalIgnoreCase))
.Select(c => (Category: c.Category?.Trim() ?? "", Label: c.Category?.Trim() ?? "", Symbol: "\uE8BD", Color: "#6B7280")))
.Where(item => !string.IsNullOrWhiteSpace(item.Category))
.DistinctBy(item => item.Category, StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var categoryItem in categoryItems)
{
var capturedCategory = categoryItem.Category;
var brush = BrushFromHex(categoryItem.Color);
stack.Children.Add(CreateCatItem(categoryItem.Symbol, categoryItem.Label, brush,
string.Equals(_selectedCategory, capturedCategory, StringComparison.OrdinalIgnoreCase),
() => { _selectedCategory = capturedCategory; UpdateCategoryLabel(); RefreshConversationList(); }));
}
}
else if (_activeTab == "Code")
{
// 코드: 워크스페이스 기반 필터
var workspaces = _storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
.Select(c => c.WorkFolder?.Trim() ?? "")
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => System.IO.Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var workspace in workspaces)
{
var displayName = System.IO.Path.GetFileName(workspace.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(displayName))
displayName = workspace;
var capturedWorkspace = workspace;
var item = CreateCatItem("\uE8B7", displayName, accentBrush,
string.Equals(_selectedCategory, capturedWorkspace, StringComparison.OrdinalIgnoreCase),
() => { _selectedCategory = capturedWorkspace; UpdateCategoryLabel(); RefreshConversationList(); });
item.ToolTip = workspace;
stack.Children.Add(item);
}
}
else
{
// Chat: 기존 ChatCategory 기반
foreach (var (key, label, symbol, color) in ChatCategory.All)
{
var capturedKey = key;
stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color),
_selectedCategory == capturedKey,
() => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); }));
}
// 커스텀 프리셋 통합 필터 (Chat)
var chatCustom = _settings.Settings.Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList();
if (chatCustom.Count > 0)
{
stack.Children.Add(CreateSep());
stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText,
_selectedCategory == "__custom__",
() => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); }));
}
}
container.Child = stack;
popup.Child = container;
popup.IsOpen = true;
}
private void UpdateCategoryLabel()
{
if (string.IsNullOrEmpty(_selectedCategory))
{
CategoryLabel.Text = _activeTab switch
{
"Cowork" => "모든 작업 유형",
"Code" => "모든 프로젝트",
_ => "주제 선택",
};
CategoryIcon.Text = "\uE8BD";
}
else if (_activeTab == "Code")
{
var displayName = System.IO.Path.GetFileName(_selectedCategory.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
CategoryLabel.Text = string.IsNullOrWhiteSpace(displayName) ? _selectedCategory : displayName;
CategoryIcon.Text = "\uE8B7";
}
else if (_activeTab == "Cowork")
{
var preset = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
.FirstOrDefault(p => string.Equals(p.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase));
CategoryLabel.Text = preset?.Label ?? _selectedCategory;
CategoryIcon.Text = preset?.Symbol ?? "\uE8BD";
}
else if (_selectedCategory == "__custom__")
{
CategoryLabel.Text = "커스텀 프리셋";
CategoryIcon.Text = "\uE710";
}
else
{
// ChatCategory에서 찾기
foreach (var (key, label, symbol, _) in ChatCategory.All)
{
if (key == _selectedCategory)
{
CategoryLabel.Text = label;
CategoryIcon.Text = symbol;
return;
}
}
// 프리셋 카테고리에서 찾기 (Cowork/Code)
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
var match = presets.FirstOrDefault(p => p.Category == _selectedCategory);
if (match != null)
{
CategoryLabel.Text = match.Label;
CategoryIcon.Text = match.Symbol;
}
else
{
CategoryLabel.Text = _selectedCategory;
CategoryIcon.Text = "\uE8BD";
}
}
}
private void UpdateSidebarModeMenu()
{
if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null)
return;
// 좌측 필터는 상단 공통 드롭다운 하나만 유지한다.
// 탭별 중복 메뉴(주제/작업 유형/워크스페이스)는 항상 숨긴다.
if (SidebarChatMenu.Visibility != Visibility.Collapsed) SidebarChatMenu.Visibility = Visibility.Collapsed;
if (SidebarCoworkMenu.Visibility != Visibility.Collapsed) SidebarCoworkMenu.Visibility = Visibility.Collapsed;
if (SidebarCodeMenu.Visibility != Visibility.Collapsed) SidebarCodeMenu.Visibility = Visibility.Collapsed;
if (SidebarModeBadgeTitle != null && SidebarModeBadgeIcon != null)
{
if (_activeTab == "Cowork")
{
SidebarModeBadgeTitle.Text = "Cowork 메뉴";
SidebarModeBadgeIcon.Text = "\uE8FD";
}
else if (_activeTab == "Code")
{
SidebarModeBadgeTitle.Text = "Code 메뉴";
SidebarModeBadgeIcon.Text = "\uE943";
}
else
{
SidebarModeBadgeTitle.Text = "Chat 메뉴";
SidebarModeBadgeIcon.Text = "\uE8BD";
}
}
if (SidebarChatRunningState != null)
SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF";
}
private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_selectedCategory = "";
UpdateCategoryLabel();
RefreshConversationList();
BuildTopicButtons();
// 현재 대화에 메시지가 있으면 EmptyState를 표시하지 않음
if (EmptyState != null)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
EmptyState.Visibility = (conv?.Messages?.Count ?? 0) > 0
? Visibility.Collapsed
: Visibility.Visible;
}
if (TopicButtonPanel != null)
TopicButtonPanel.Visibility = Visibility.Visible;
}
private void SidebarChatRunning_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnRunningOnlyFilter_Click(this, new RoutedEventArgs());
private void SidebarCoworkCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
private void SidebarCoworkPreset_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
BuildTopicButtons();
ShowToast("프리셋 카드가 갱신되었습니다.");
}
private void SidebarCoworkExecution_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnToggleExecutionLog_Click(this, new RoutedEventArgs());
private void SidebarCodeCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
private void SidebarCodeLanguage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
BuildCodeBottomBar();
ShowToast("코드 옵션 메뉴를 갱신했습니다.");
}
private void SidebarCodeFiles_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (FileBrowserPanel == null)
return;
var visible = FileBrowserPanel.Visibility == Visibility.Visible;
FileBrowserPanel.Visibility = visible ? Visibility.Collapsed : Visibility.Visible;
if (!visible)
BuildFileTree();
}
// ─── 창 컨트롤 ──────────────────────────────────────────────────────
// WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var source = System.Windows.Interop.HwndSource.FromHwnd(
new System.Windows.Interop.WindowInteropHelper(this).Handle);
source?.AddHook(WndProc);
}
private Uri? _lastAppliedThemeUri; // 마지막으로 적용된 테마 URI (중복 교체 방지)
private void ApplyAgentThemeResources()
{
var themeUri = BuildAgentThemeDictionaryUri();
// 같은 테마가 이미 적용되어 있으면 ResourceDictionary 교체 스킵
// — Remove+Insert가 WPF 템플릿 재평가 → RadioButton Checked 이벤트 재발화 →
// 탭 전환 무한 루프 + UI 멈춤의 근본 원인이었음
if (_lastAppliedThemeUri != null
&& Uri.Equals(_lastAppliedThemeUri, themeUri)
&& _agentThemeDictionary != null)
{
return;
}
var isThemeSwitch = _lastAppliedThemeUri != null;
try
{
if (_agentThemeDictionary != null)
Resources.MergedDictionaries.Remove(_agentThemeDictionary);
_agentThemeDictionary = new ResourceDictionary
{
Source = themeUri,
};
Resources.MergedDictionaries.Insert(0, _agentThemeDictionary);
_lastAppliedThemeUri = themeUri;
}
catch
{
// 테마 로드 실패 시 기본 리소스 유지
return;
}
// 테마 전환 시 메시지 버블은 TryFindResource로 Brush 인스턴스를 스냅샷 캡처하므로
// 기존 렌더된 TextBlock의 Foreground가 옛 Brush를 그대로 유지한다.
// → _elementCache를 비우고 RenderMessages()로 재구성하여 새 테마 색상을 적용.
if (isThemeSwitch)
{
try
{
_elementCache.Clear();
RenderMessages(preserveViewport: true);
}
catch
{
// 재렌더 실패는 치명적이지 않음 — 다음 자연스러운 재렌더 시 복구됨
}
}
}
private Uri BuildAgentThemeDictionaryUri()
{
var mode = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claude").Trim().ToLowerInvariant() switch
{
"codex" => "Codex",
"nord" => "Nord",
"ember" => "Ember",
"slate" => "Slate",
"claude" => "Claude",
_ => "Claw",
};
var effectiveMode = mode switch
{
"light" => "Light",
"dark" => "Dark",
_ => "System",
};
var candidate = $"pack://application:,,,/Themes/Agent{preset}{effectiveMode}.xaml";
return new Uri(candidate);
}
private static bool IsSystemDarkTheme()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
return key?.GetValue("AppsUseLightTheme") is int v && v == 0;
}
catch
{
return true;
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == 0x0231) // WM_ENTERSIZEMOVE
{
BeginWindowMoveSizeLoop();
}
else if (msg == 0x0232) // WM_EXITSIZEMOVE
{
EndWindowMoveSizeLoop();
}
// WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보
if (msg == 0x0024)
{
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
var workArea = screen.WorkingArea;
var monitor = screen.Bounds;
var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd);
var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0;
// MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36)
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y
handled = true;
}
return IntPtr.Zero;
}
// 드래그/리사이즈 중 일시 정지할 타이머 목록
private DispatcherTimer[] GetSuspendableTimers() => new[]
{
_cursorTimer, _elapsedTimer, _typingTimer, _gitRefreshTimer,
_conversationSearchTimer, _inputUiRefreshTimer, _executionHistoryRenderTimer,
_taskSummaryRefreshTimer, _conversationPersistTimer, _agentUiEventTimer,
_agentProgressHintTimer, _tokenUsagePopupCloseTimer, _responsiveLayoutTimer,
};
private readonly List<DispatcherTimer> _timersRunningBeforeMove = new();
private void BeginWindowMoveSizeLoop()
{
if (_isInWindowMoveSizeLoop)
return;
_isInWindowMoveSizeLoop = true;
_pendingResponsiveLayoutRefresh = false;
// 비필수 타이머 일시 정지 → 드래그 중 UI 부하 최소화
_timersRunningBeforeMove.Clear();
foreach (var t in GetSuspendableTimers())
{
if (t.IsEnabled)
{
_timersRunningBeforeMove.Add(t);
t.Stop();
}
}
// Storyboard 일시 정지
_pulseDotStoryboard?.Pause();
_statusDiamondStoryboard?.Pause();
if (Content is UIElement rootElement)
{
_cachedRootCacheModeBeforeMove = rootElement.CacheMode;
rootElement.CacheMode = new BitmapCache();
}
}
private void EndWindowMoveSizeLoop()
{
if (!_isInWindowMoveSizeLoop)
return;
_isInWindowMoveSizeLoop = false;
if (Content is UIElement rootElement)
rootElement.CacheMode = _cachedRootCacheModeBeforeMove;
_cachedRootCacheModeBeforeMove = null;
// 타이머 복원
foreach (var t in _timersRunningBeforeMove)
t.Start();
_timersRunningBeforeMove.Clear();
// Storyboard 복원
_pulseDotStoryboard?.Resume();
_statusDiamondStoryboard?.Resume();
if (_pendingResponsiveLayoutRefresh)
{
_pendingResponsiveLayoutRefresh = false;
UpdateTopicPresetScrollMode();
if (UpdateResponsiveChatLayout())
RenderMessages(preserveViewport: true);
}
}
private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void BtnMaximize_Click(object sender, RoutedEventArgs e)
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘
}
private void BtnClose_Click(object sender, RoutedEventArgs e) => Close();
// ─── 탭 전환 ──────────────────────────────────────────────────────────
private string _activeTab = "Chat";
private ChatSessionStateService? ChatSession => _appState.ChatSession;
private void SaveCurrentTabConversationId()
{
lock (_convLock)
{
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
SyncTabConversationIdsFromSession();
}
// 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용)
SaveLastConversations();
}
/// <summary>탭 전환 전 스트리밍 중이면 즉시 중단합니다.</summary>
private void StopStreamingIfActive()
{
if (!_isStreaming) return;
// 모든 탭 스트리밍 중단
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
StopLiveAgentProgressHints();
StopRainbowGlow();
HideStickyProgress();
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_streamingTabs.Clear();
ViewModel.IsStreaming = false;
_streamRunTab = null;
_streamStartTime = default;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnPause.Visibility = Visibility.Collapsed;
PauseIcon.Text = "\uE769"; // 리셋
BtnSend.Visibility = Visibility.Visible;
foreach (var cts in _tabStreamCts.Values) cts.Dispose();
_tabStreamCts.Clear();
SetStatusIdle();
}
private void RefreshStreamingControlsForActiveTab()
{
var isOwningTab = _streamingTabs.Contains(_activeTab);
// 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨
BtnSend.IsEnabled = true;
BtnSend.Visibility = Visibility.Visible;
BtnStop.Visibility = isOwningTab ? Visibility.Visible : Visibility.Collapsed;
BtnPause.Visibility = isOwningTab && (_activeTab == "Cowork" || _activeTab == "Code")
? Visibility.Visible
: Visibility.Collapsed;
if (!isOwningTab) PauseIcon.Text = "\uE769";
// 스트리밍 중인 탭이 아니면 펄스 닷·상태 바 숨김 (다른 탭 작업 상태가 보이지 않도록)
// 스트리밍 탭으로 복귀 시 자동 복원
if (PulseDotBar != null)
{
if (!isOwningTab)
PulseDotBar.Visibility = Visibility.Collapsed;
else if (_streamingTabs.Count > 0)
PulseDotBar.Visibility = Visibility.Visible;
}
}
private bool _isUpdatingTab; // UpdateTabUI 재진입 방지 플래그
private long _lastTabUpdateTicks; // UpdateTabUI 완료 시각 — 비동기 Checked 이벤트 재발화 차단용
private const long TabSwitchCooldownMs = 250; // ResourceDictionary 교체 후 비동기 이벤트 무시 기간
/// <summary>비동기 Checked 재발화 여부 검사. true이면 무시해야 함.</summary>
private bool IsTabSwitchSuppressed(string tabName)
{
if (_isUpdatingTab) return true;
if (_activeTab == tabName) return true;
// ApplyAgentThemeResources의 ResourceDictionary 교체가 WPF RadioButton Checked를
// 비동기로 재발화하는 문제 방지 — UpdateTabUI 완료 후 짧은 시간 내 도착하는 이벤트 무시
if (Environment.TickCount64 - _lastTabUpdateTicks < TabSwitchCooldownMs) return true;
return false;
}
private void TabChat_Checked(object sender, RoutedEventArgs e)
{
if (IsTabSwitchSuppressed("Chat"))
{
Services.LogService.Debug($"[TabSwitch] Chat_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
return;
}
Services.LogService.Info($"[TabSwitch] Chat ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Chat";
RestorePerTabUiState();
UpdateTabUI();
Services.LogService.Info($"[TabSwitch] Chat DONE, emptyState={EmptyState.Visibility}");
}
private void TabCowork_Checked(object sender, RoutedEventArgs e)
{
if (IsTabSwitchSuppressed("Cowork"))
{
Services.LogService.Debug($"[TabSwitch] Cowork_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
return;
}
Services.LogService.Info($"[TabSwitch] Cowork ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Cowork";
RestorePerTabUiState();
UpdateTabUI();
Services.LogService.Info($"[TabSwitch] Cowork DONE, emptyState={EmptyState.Visibility}");
}
private void TabCode_Checked(object sender, RoutedEventArgs e)
{
if (IsTabSwitchSuppressed("Code"))
{
Services.LogService.Debug($"[TabSwitch] Code_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
return;
}
Services.LogService.Info($"[TabSwitch] Code ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Code";
RestorePerTabUiState();
UpdateTabUI();
Services.LogService.Info($"[TabSwitch] Code DONE, emptyState={EmptyState.Visibility}");
}
/// <summary>탭별로 마지막으로 활성화된 대화 ID를 기억.</summary>
private readonly Dictionary<string, string?> _tabConversationId = new()
{
["Chat"] = null, ["Cowork"] = null, ["Code"] = null,
};
/// <summary>
/// ApplyAgentThemeResources 후 올바른 탭 RadioButton을 강제 체크합니다.
/// ResourceDictionary 교체로 WPF가 비동기 Checked 이벤트를 발화하는데,
/// 해당 이벤트가 도착했을 때 이미 올바른 RadioButton이 체크되어 있으면
/// Checked 핸들러의 "if (_activeTab == X) return;" 가드에 의해 무시됩니다.
/// </summary>
private void ForceCheckActiveTabRadioButton()
{
var target = _activeTab switch
{
"Chat" => TabChat,
"Code" => TabCode,
_ => TabCowork,
};
if (target != null && target.IsChecked != true)
target.IsChecked = true;
}
private void SyncTabConversationIdsFromSession()
{
var session = ChatSession;
if (session == null)
return;
foreach (var key in _tabConversationId.Keys.ToList())
_tabConversationId[key] = session.GetConversationId(key);
}
private void SyncTabConversationIdsToSession()
{
var session = ChatSession;
if (session == null)
return;
foreach (var kv in _tabConversationId)
session.RememberConversation(kv.Key, kv.Value);
}
private void UpdateTabUI()
{
if (_isUpdatingTab) return;
_isUpdatingTab = true;
try
{
ViewModel.ActiveTab = _activeTab;
ApplyAgentThemeResources();
// ApplyAgentThemeResources가 ResourceDictionary를 교체하면
// WPF가 RadioButton Checked 이벤트를 비동기로 재발화합니다.
// 올바른 탭 RadioButton을 강제 체크하여 비동기 이벤트가 도착해도
// 이미 같은 탭이므로 Checked 핸들러의 if(_activeTab==X) return; 에 걸려 무시됩니다.
ForceCheckActiveTabRadioButton();
ApplyExpressionLevelUi();
ApplySidebarStateForActiveTab(animated: false);
RefreshStreamingControlsForActiveTab();
if (CurrentTabTitle != null)
{
CurrentTabTitle.Text = _activeTab switch
{
"Cowork" => "AX Agent · Cowork",
"Code" => "AX Agent · Code",
_ => "AX Agent · Chat",
};
}
// 폴더 바는 Cowork/Code 탭에서만 표시
if (FolderBar != null)
{
var folderShouldShow = _activeTab != "Chat";
if (folderShouldShow && FolderBar.Visibility != Visibility.Visible)
{
FolderBar.Opacity = 0;
FolderBar.Visibility = Visibility.Visible;
var fadeIn = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase()
};
FolderBar.BeginAnimation(OpacityProperty, fadeIn);
}
else if (!folderShouldShow)
{
FolderBar.Visibility = Visibility.Collapsed;
}
}
// 탭별 입력 안내 문구
RefreshInputWatermarkText();
// 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용)
ApplyTabDefaultPermission();
// 포맷/디자인 드롭다운은 Cowork 탭에서만 표시
if (_activeTab == "Cowork")
{
BuildBottomBar();
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
else if (_activeTab == "Code")
{
// Code 탭: 언어 선택기
BuildCodeBottomBar();
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
else
{
MoodIconPanel.Children.Clear();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
// 탭별 프리셋 버튼 재구성
BuildTopicButtons();
UpdateSidebarModeMenu();
// 현재 대화를 해당 탭 대화로 전환
SwitchToTabConversation();
// Cowork/Code 탭 전환 시 팁 표시
ShowRandomTip();
// 탭 전환 시 해당 탭의 대기 중인 대기열 항목 자동 재개
// (다른 탭 실행 중 이 탭의 이전 draft가 완료됐을 때 다음 항목이 시작되지 않은 경우)
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), System.Windows.Threading.DispatcherPriority.Background);
}
finally
{
_isUpdatingTab = false;
_lastTabUpdateTicks = Environment.TickCount64;
}
}
private void PersistPerTabUiState()
{
_tabSelectedCategory[_activeTab] = _selectedCategory;
_tabSidebarVisible[_activeTab] = _sidebarVisible;
}
private void RestorePerTabUiState()
{
if (_tabSelectedCategory.TryGetValue(_activeTab, out var category))
_selectedCategory = category ?? "";
else
_selectedCategory = "";
UpdateCategoryLabel();
if (_tabSidebarVisible.TryGetValue(_activeTab, out var visible))
_sidebarVisible = visible;
else
_sidebarVisible = true;
}
private string GetAgentUiExpressionLevel()
{
var raw = _settings.Settings.Llm.AgentUiExpressionLevel;
return (raw ?? "balanced").Trim().ToLowerInvariant() switch
{
"rich" => "rich",
"simple" => "simple",
_ => "balanced",
};
}
private void ApplyExpressionLevelUi()
{
var level = GetAgentUiExpressionLevel();
if (InputBox != null)
{
InputBox.FontSize = level == "simple" ? 13 : 14;
InputBox.MaxHeight = level switch
{
"rich" => 120,
"simple" => 96,
_ => 108,
};
UpdateInputBoxHeight();
}
if (InputWatermark != null)
{
InputWatermark.FontSize = level == "simple" ? 13 : 14;
InputWatermark.Opacity = level == "rich" ? 0.8 : 0.7;
}
if (InlineSettingsHintText != null)
InlineSettingsHintText.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
if (InlineSettingsQuickActions != null)
InlineSettingsQuickActions.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
if (_failedOnlyFilter)
{
_failedOnlyFilter = false;
RefreshConversationList();
}
}
private void SwitchToTabConversation()
{
// 진행 중인 EmptyState 숨김 애니메이션 무효화 —
// 이전 탭의 Completed 콜백이 새 탭의 EmptyState를 잘못 숨기는 것을 방지
++_emptyStateAnimationToken;
Services.LogService.Info($"[SwitchTab] START tab={_activeTab}, emptyState={EmptyState.Visibility}, streaming={_isStreaming}");
// 현재 활성 탭이 스트리밍 중일 때만 전환 차단 —
// 다른 탭이 백그라운드 스트리밍 중이어도 활성 탭 대화는 정상 로드
if (_streamingTabs.Contains(_activeTab))
{
Services.LogService.Info($"[SwitchTab] BLOCKED — 활성 탭 스트리밍 중: activeTab={_activeTab}");
return;
}
var session = ChatSession;
if (session != null)
{
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
Services.LogService.Info($"[SwitchTab] LoadOrCreate: tab={_activeTab}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, " +
$"msgCount={conv.Messages?.Count ?? 0}, convTab={conv.Tab}");
ChatConversation? cur;
lock (_convLock) cur = _currentConversation;
var curId = cur?.Id?[..Math.Min(8, cur?.Id?.Length ?? 0)] ?? "null";
if (cur != null && string.Equals(cur.Id, conv.Id, StringComparison.Ordinal))
{
Services.LogService.Info($"[SwitchTab] SAME_CONV path: curId={curId}, calling RenderMessages");
SyncTabConversationIdsFromSession();
UpdateChatTitle();
UpdateFolderBar();
RenderMessages();
Services.LogService.Info($"[SwitchTab] SAME_CONV done, emptyState={EmptyState.Visibility}");
return;
}
Services.LogService.Info($"[SwitchTab] DIFF_CONV path: curId={curId} → newId={conv.Id[..Math.Min(8, conv.Id.Length)]}");
lock (_convLock) _currentConversation = conv;
SyncTabConversationIdsFromSession();
SaveLastConversations();
ClearTranscriptElements();
InvalidateTimelineCache();
RenderMessages();
Services.LogService.Info($"[SwitchTab] DIFF_CONV done, emptyState={EmptyState.Visibility}");
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
return;
}
// 기억된 대화가 없으면 새 대화
Services.LogService.Info($"[SwitchTab] NO_SESSION path: creating fresh conversation");
lock (_convLock)
{
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
?? new ChatConversation { Tab = _activeTab };
}
ClearTranscriptElements();
InvalidateTimelineCache();
ShowEmptyState();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
Services.LogService.Info($"[SwitchTab] NO_SESSION done, emptyState={EmptyState.Visibility}");
}
// ─── 작업 폴더 ─────────────────────────────────────────────────────────
private readonly List<string> _attachedFiles = new();
private readonly List<ImageAttachment> _pendingImages = new();
private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
private void BtnFolderSelect_Click(object sender, RoutedEventArgs e) => ShowFolderMenu();
private void BtnFolderSelect_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
/// <summary>채팅이 시작되었는지 (메시지가 1개 이상 있는지) 확인합니다.</summary>
private bool HasChatStarted()
{
lock (_convLock)
{
return _currentConversation != null && _currentConversation.Messages.Count > 0;
}
}
/// <summary>경로 텍스트 클릭 — 대화 전: 폴더 메뉴 / 대화 후: 클립보드 복사.</summary>
private void FolderPathLabel_CopyClick(object sender, MouseButtonEventArgs e)
{
if (!HasChatStarted())
{
// 대화 시작 전 → 폴더 변경 가능
ShowFolderMenu();
return;
}
// 대화 시작 후 → 경로 복사만
var path = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(path)) return;
try { Clipboard.SetText(path); }
catch { }
ShowToast("경로가 복사되었습니다.", "\uE8C8", 1500);
}
/// <summary>폴더 미선택 시 폴더 선택 버튼을 시각적으로 강조합니다.</summary>
private void HighlightFolderSelectButton()
{
if (BtnFolderSelect == null) return;
ShowToast("작업 폴더를 먼저 선택하세요.", "\uED25", 3000);
// 버튼 테두리를 강조색으로 깜빡임
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var originalBorder = BtnFolderSelect.BorderBrush;
var flash = new System.Windows.Media.Animation.ColorAnimation
{
From = ((SolidColorBrush)(TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray)).Color,
To = ((SolidColorBrush)accentBrush).Color,
Duration = TimeSpan.FromMilliseconds(300),
AutoReverse = true,
RepeatBehavior = new System.Windows.Media.Animation.RepeatBehavior(3),
};
flash.Completed += (_, _) => BtnFolderSelect.BorderBrush = originalBorder;
var animBrush = new SolidColorBrush();
animBrush.BeginAnimation(SolidColorBrush.ColorProperty, flash);
BtnFolderSelect.BorderBrush = animBrush;
}
/// <summary>폴더 선택 여부에 따라 UI 모드를 전환합니다.
/// 대화 WorkFolder가 비어있으면 "폴더 선택" 버튼, 설정되어 있으면 경로 텍스트.</summary>
private void UpdateFolderSelectButtonStyle()
{
if (BtnFolderSelect == null || FolderPathLabel == null) return;
// 대화 자체에 폴더가 설정되어 있는지 확인 (설정 폴백 제외)
bool convHasFolder;
lock (_convLock)
{
convHasFolder = _currentConversation != null
&& !string.IsNullOrWhiteSpace(_currentConversation.WorkFolder);
}
BtnFolderSelect.Visibility = convHasFolder ? Visibility.Collapsed : Visibility.Visible;
FolderPathLabel.Visibility = convHasFolder ? Visibility.Visible : Visibility.Collapsed;
// ToolTip: 대화 시작 여부에 따라 전환
if (convHasFolder)
{
FolderPathLabel.ToolTip = HasChatStarted()
? "클릭하면 경로가 복사됩니다"
: "클릭하면 작업 폴더를 변경합니다";
}
}
private void FolderMenuSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
RenderFolderMenuItems(null);
}
private void ShowFolderMenu()
{
RenderFolderMenuItems(null);
FolderMenuPopup.IsOpen = true;
}
private async void RenderFolderMenuItems(string? searchText)
{
FolderMenuItems.Children.Clear();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#2A2A2A");
// 현재 대화에 실제 설정된 폴더만 (설정 폴백 아님)
string? convFolder = null;
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrWhiteSpace(_currentConversation.WorkFolder))
convFolder = _currentConversation.WorkFolder;
}
// 최근 폴더 + 대화 이력에서 수집 (중복 제거, 최대 15개)
var recentFolders = _settings.Settings.Llm.RecentWorkFolders
.Where(p => IsPathAllowed(p) && Directory.Exists(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(15)
.ToList();
// ── 버벅임 수정: _storage.LoadAllMeta()는 모든 .axchat 파일을 복호화하는 무거운 동기 작업.
// UI 스레드에서 호출하면 폴더 버튼 클릭 시 수초 멈춤 발생. Task.Run으로 오프로드.
var activeTab = _activeTab;
var conversationFolders = await System.Threading.Tasks.Task.Run(() =>
{
try
{
return _storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), activeTab, StringComparison.OrdinalIgnoreCase))
.Select(c => c.WorkFolder?.Trim() ?? "")
.Where(p => !string.IsNullOrWhiteSpace(p) && Directory.Exists(p))
.ToList();
}
catch { return new List<string>(); }
});
// IsPathAllowed는 UI 스레드 리소스 접근 가능성 있어 UI 스레드로 돌아와서 필터링
conversationFolders = conversationFolders.Where(IsPathAllowed).ToList();
var allFolders = recentFolders
.Concat(conversationFolders)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(15)
.ToList();
// 검색 필터
if (!string.IsNullOrWhiteSpace(searchText))
allFolders = allFolders
.Where(f => f.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0)
.ToList();
// 컴팩트한 폴더 행 추가
foreach (var folder in allFolders)
{
var isActive = convFolder != null
&& folder.Equals(convFolder, StringComparison.OrdinalIgnoreCase);
var row = CreateCompactFolderRow(folder, isActive, accentBrush, primaryText, secondaryText, hoverBg);
FolderMenuItems.Children.Add(row);
}
if (allFolders.Count == 0)
{
FolderMenuItems.Children.Add(new TextBlock
{
Text = "최근 작업 폴더가 없습니다.",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(10, 6, 10, 6),
});
}
// 구분선
FolderMenuItems.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 4, 4, 4),
Opacity = 0.25,
});
var browseBorder = CreatePopupMenuRow(
"\uE710",
"다른 폴더 선택",
string.Empty,
false,
accentBrush,
secondaryText,
primaryText,
() =>
{
FolderMenuPopup.IsOpen = false;
BrowseWorkFolder();
});
browseBorder.Margin = new Thickness(0);
browseBorder.MouseLeftButtonUp += (_, _) =>
{
FolderMenuPopup.IsOpen = false;
};
FolderMenuItems.Children.Add(browseBorder);
}
private void BrowseWorkFolder()
{
var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "작업 폴더를 선택하세요",
ShowNewFolderButton = false,
UseDescriptionForTitle = true,
};
var currentFolder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(currentFolder) && System.IO.Directory.Exists(currentFolder))
dlg.SelectedPath = currentFolder;
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
if (!IsPathAllowed(dlg.SelectedPath))
{
CustomMessageBox.Show("이 경로는 작업 폴더로 선택할 수 없습니다.", "경로 제한", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SetWorkFolder(dlg.SelectedPath);
}
/// <summary>컴팩트한 폴더 행 — 한 줄에 아이콘 + 폴더명 + 경로.</summary>
private Border CreateCompactFolderRow(string folder, bool isActive, Brush accentBrush, Brush primaryText, Brush secondaryText, Brush hoverBg)
{
var folderName = System.IO.Path.GetFileName(folder.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(folderName)) folderName = folder;
var row = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(4),
Padding = new Thickness(8, 4, 8, 4),
Cursor = Cursors.Hand,
Margin = new Thickness(2, 1, 2, 1),
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 체크마크 (선택된 폴더만)
if (isActive)
{
sp.Children.Add(new TextBlock
{
Text = "\uE73E",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
}
// 폴더 아이콘
sp.Children.Add(new TextBlock
{
Text = "\uED25",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = isActive ? accentBrush : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
// 폴더명
sp.Children.Add(new TextBlock
{
Text = folderName,
FontSize = 12,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = isActive ? accentBrush : primaryText,
VerticalAlignment = VerticalAlignment.Center,
MaxWidth = 200,
TextTrimming = TextTrimming.CharacterEllipsis,
});
// 전체 경로 (축약)
sp.Children.Add(new TextBlock
{
Text = folder,
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.6,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
MaxWidth = 220,
TextTrimming = TextTrimming.CharacterEllipsis,
});
row.Child = sp;
row.ToolTip = folder;
// 호버 효과
row.MouseEnter += (_, _) => row.Background = hoverBg;
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
// 클릭
row.MouseLeftButtonUp += (_, _) =>
{
FolderMenuPopup.IsOpen = false;
SetWorkFolder(folder);
};
// 우클릭 → 컨텍스트 메뉴
var capturedPath = folder;
row.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowRecentFolderContextMenu(capturedPath);
};
return row;
}
/// <summary>경로 유효성 검사 — 차단 대상 경로 필터링.</summary>
private static bool IsPathAllowed(string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
// C:\ 루트 차단
var normalized = path.TrimEnd('\\', '/');
if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false;
// "Document" 포함 경로 차단 (대소문자 무시)
if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false;
return true;
}
private void SetWorkFolder(string path)
{
// 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단
// 예: "C:\", "D:\", "E:\" 등
var fullPath = System.IO.Path.GetFullPath(path);
var root = System.IO.Path.GetPathRoot(fullPath);
if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase))
{
ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000);
return;
}
ViewModel.WorkFolder = path;
ChatConversation? convToPersist = null;
lock (_convLock)
{
if (_currentConversation == null && (_activeTab is "Cowork" or "Code"))
_currentConversation = ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab };
if (_currentConversation != null)
{
var session = ChatSession;
if (session != null)
// storage=null 전달 → UI 스레드에서 동기 Save 생략.
// 실제 저장은 아래 Task.Run으로 비동기 수행.
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = path, storage: null);
else
_currentConversation.WorkFolder = path;
convToPersist = _currentConversation;
}
}
// 폴더 변경은 UpdatedAt을 갱신하는 실제 변경이므로 저장은 필요. 단 백그라운드로.
if (convToPersist != null)
{
var copy = convToPersist;
_ = System.Threading.Tasks.Task.Run(() =>
{
try { _storage.Save(copy); }
catch (Exception ex) { Services.LogService.Debug($"WorkFolder 저장 실패: {ex.Message}"); }
});
}
// 최근 폴더 목록에 추가 (차단 경로 제외)
var recent = _settings.Settings.Llm.RecentWorkFolders;
recent.RemoveAll(p => !IsPathAllowed(p));
recent.Remove(path);
recent.Insert(0, path);
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent);
// 탭별 전용 경로에도 기록
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
_settings.Settings.Llm.CodeWorkFolder = path;
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
_settings.Settings.Llm.CoworkWorkFolder = path;
_settings.Settings.Llm.WorkFolder = path;
ScheduleSettingsSave();
RefreshContextUsageVisual();
RefreshInputWatermarkText();
UpdateFolderSelectButtonStyle();
ScheduleGitBranchRefresh();
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
}
private string GetCurrentWorkFolder()
{
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder))
return _currentConversation.WorkFolder;
}
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(_settings.Settings.Llm.CodeWorkFolder))
return _settings.Settings.Llm.CodeWorkFolder;
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(_settings.Settings.Llm.CoworkWorkFolder))
return _settings.Settings.Llm.CoworkWorkFolder;
return _settings.Settings.Llm.WorkFolder;
}
private void EnsureSkillSystemLoadedForCurrentWorkspace()
{
if (!_settings.Settings.Llm.EnableSkillSystem)
return;
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
}
private void ScheduleWorkspaceSkillRefresh(bool reset = false)
{
if (!_settings.Settings.Llm.EnableSkillSystem)
return;
var version = Interlocked.Increment(ref _workspaceSkillRefreshVersion);
_workspaceSkillRefreshCts?.Cancel();
_workspaceSkillRefreshCts?.Dispose();
_workspaceSkillRefreshCts = new CancellationTokenSource();
var ct = _workspaceSkillRefreshCts.Token;
var skillFolder = _settings.Settings.Llm.SkillsFolderPath;
var workFolder = GetCurrentWorkFolder();
var additionalFolders = (_settings.Settings.Llm.AdditionalSkillFolders ?? [])
.Where(path => !string.IsNullOrWhiteSpace(path))
.ToArray();
_ = Task.Run(() =>
{
try
{
ct.ThrowIfCancellationRequested();
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(skillFolder, workFolder, additionalFolders);
ct.ThrowIfCancellationRequested();
Dispatcher.BeginInvoke(new Action(() =>
{
if (ct.IsCancellationRequested || version != _workspaceSkillRefreshVersion)
return;
ApplyConditionalSkillActivation(reset, workFolder);
}), DispatcherPriority.Background);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
Services.LogService.Debug($"작업 폴더 스킬 재로드 실패: {ex.Message}");
}
}, ct);
}
private void ApplyConditionalSkillActivation(bool reset = false, string? cwd = null)
{
if (!_settings.Settings.Llm.EnableSkillSystem)
return;
cwd ??= GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd))
return;
if (reset)
SkillService.ResetConditionalSkillActivation();
SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd);
}
/// <summary>
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
/// 조건부 paths 스킬 활성화를 갱신합니다.
/// </summary>
private void UpdateConditionalSkillActivation(bool reset = false, bool reloadSkillSources = false)
{
if (!_settings.Settings.Llm.EnableSkillSystem)
return;
if (reloadSkillSources)
{
ScheduleWorkspaceSkillRefresh(reset);
return;
}
ApplyConditionalSkillActivation(reset);
}
private void BtnFolderClear_Click(object sender, RoutedEventArgs e)
{
var currentFolder = GetCurrentWorkFolder();
ViewModel.WorkFolder = "";
lock (_convLock)
{
if (_currentConversation != null)
{
var session = ChatSession;
if (session != null)
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = "", _storage);
else
_currentConversation.WorkFolder = "";
}
}
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
_settings.Settings.Llm.CodeWorkFolder = "";
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
_settings.Settings.Llm.CoworkWorkFolder = "";
if (!string.IsNullOrWhiteSpace(currentFolder)
&& string.Equals(_settings.Settings.Llm.WorkFolder, currentFolder, StringComparison.OrdinalIgnoreCase))
_settings.Settings.Llm.WorkFolder = "";
ScheduleSettingsSave();
RefreshContextUsageVisual();
RefreshInputWatermarkText();
UpdateFolderSelectButtonStyle();
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
}
/// <summary>현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용.</summary>
private void LoadConversationSettings()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
var llm = _settings.Settings.Llm;
var fallbackPermission = _activeTab == "Chat"
? PermissionModeCatalog.Deny
: PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission);
var conversationPermission = !string.IsNullOrWhiteSpace(conv?.Permission)
? PermissionModeCatalog.NormalizeGlobalMode(conv.Permission)
: fallbackPermission;
_settings.Settings.Llm.FilePermission = conversationPermission;
_folderDataUsage = GetAutomaticFolderDataUsage();
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
}
private void LoadCompactionMetricsFromConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_sessionCompactionCount = conv?.CompactionCount ?? 0;
_sessionAutomaticCompactionCount = conv?.AutomaticCompactionCount ?? 0;
_sessionManualCompactionCount = conv?.ManualCompactionCount ?? 0;
_sessionCompactionSavedTokens = conv?.CompactionSavedTokens ?? 0;
_sessionMemoryCompactionCount = conv?.SessionMemoryCompactionCount ?? 0;
_sessionMicrocompactBoundaryCount = conv?.MicrocompactBoundaryCount ?? 0;
_sessionSnipCompactionCount = conv?.SnipCompactionCount ?? 0;
_pendingPostCompaction = conv?.PendingPostCompaction ?? false;
_sessionPostCompactionResponseCount = conv?.PostCompactionResponseCount ?? 0;
_sessionPostCompactionPromptTokens = conv?.PostCompactionPromptTokens ?? 0;
_sessionPostCompactionCompletionTokens = conv?.PostCompactionCompletionTokens ?? 0;
_lastCompactionAt = conv?.LastCompactionAt;
_lastCompactionWasAutomatic = conv?.LastCompactionWasAutomatic ?? false;
_lastCompactionBeforeTokens = conv?.LastCompactionBeforeTokens;
_lastCompactionAfterTokens = conv?.LastCompactionAfterTokens;
_lastCompactionStageSummary = conv?.LastCompactionStageSummary ?? "";
}
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
/// <summary>
/// 설정 저장을 250ms 디바운스하여 비동기 실행합니다.
/// UI 스레드에서 동기 File I/O를 제거하여 앱 멈춤을 방지합니다.
/// </summary>
private void ScheduleSettingsSave()
{
_settingsSaveTimer?.Stop();
if (_settingsSaveTimer == null)
{
_settingsSaveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_settingsSaveTimer.Tick += (_, _) =>
{
_settingsSaveTimer.Stop();
_ = _settings.SaveAsync();
};
}
_settingsSaveTimer.Start();
}
private void SaveConversationSettings()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
try
{
var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
var dataUsage = GetAutomaticFolderDataUsage();
var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern");
var outputFormat = conv.OutputFormat ?? "auto";
var unchanged =
string.Equals(conv.Permission ?? "", normalizedPermission, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.DataUsage ?? "", dataUsage, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.Mood ?? "", mood, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.OutputFormat ?? "auto", outputFormat, StringComparison.OrdinalIgnoreCase);
if (unchanged)
return;
conv.Permission = normalizedPermission;
conv.DataUsage = dataUsage;
conv.Mood = mood;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
_currentConversation = session.SaveConversationSettings(_activeTab, normalizedPermission, dataUsage, outputFormat, mood, _storage);
}
else
{
_storage.Save(conv);
}
}
catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
}
/// <summary>
/// SaveConversationSettings의 메모리 전용 버전 — 디스크 저장 없이 필드만 반영.
/// 프리셋 클릭처럼 UI 응답성이 중요한 경로에서 사용. 저장은 호출자가 비동기로 책임진다.
/// </summary>
private void ApplyConversationSettingsInMemory()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
try
{
var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
var dataUsage = GetAutomaticFolderDataUsage();
var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern");
var outputFormat = conv.OutputFormat ?? "auto";
conv.Permission = normalizedPermission;
conv.DataUsage = dataUsage;
conv.Mood = mood;
conv.OutputFormat = outputFormat;
}
catch (Exception ex) { Services.LogService.Debug($"대화 설정 인메모리 반영 실패: {ex.Message}"); }
}
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
private string _lastPermissionBannerMode = "";
private bool TryApplyPermissionModeFromAction(string action, out string appliedMode)
{
appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
var next = action switch
{
"ask" => PermissionModeCatalog.Default,
"default" => PermissionModeCatalog.Default,
"acceptedits" => PermissionModeCatalog.AcceptEdits,
"accept" => PermissionModeCatalog.AcceptEdits,
"auto" => PermissionModeCatalog.AcceptEdits,
"plan" => PermissionModeCatalog.Plan,
"bypass" => PermissionModeCatalog.BypassPermissions,
"bypasspermissions" => PermissionModeCatalog.BypassPermissions,
"fullauto" => PermissionModeCatalog.BypassPermissions,
"dontask" => PermissionModeCatalog.BypassPermissions,
"silent" => PermissionModeCatalog.BypassPermissions,
_ => null,
};
if (string.IsNullOrWhiteSpace(next))
return false;
_settings.Settings.Llm.FilePermission = next!;
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
appliedMode = next!;
return true;
}
private string BuildPermissionStatusText()
{
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
var summary = _appState.GetPermissionSummary(currentConversation);
var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
var overrides = summary.TopOverrides.Count > 0
? string.Join(", ", summary.TopOverrides.Take(3).Select(x => $"{x.Key}:{PermissionModeCatalog.ToDisplayLabel(x.Value)}"))
: "없음";
return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개\n예외 미리보기: {overrides}";
}
private void OpenPermissionPanelFromSlash(string command, string usageText)
{
BtnPermission_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})");
}
// ──── 데이터 활용 수준 메뉴 ────
private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_folderDataUsage = GetAutomaticFolderDataUsage();
}
private string GetAutomaticFolderDataUsage()
=> _activeTab switch
{
"Code" => "active",
"Cowork" => "passive",
_ => "none",
};
/// <summary>Cowork/Code 탭 진입 시 설정의 기본 권한을 적용.</summary>
private void ApplyTabDefaultPermission()
{
if (_activeTab == "Chat")
{
// Chat 탭: 경고 배너 숨기고 기본 제한 모드로 복원
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.Deny;
UpdatePermissionUI();
return;
}
var defaultPerm = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.DefaultAgentPermission);
if (!string.IsNullOrEmpty(defaultPerm))
{
_settings.Settings.Llm.FilePermission = defaultPerm;
UpdatePermissionUI();
}
}
// ─── 파일 첨부 ─────────────────────────────────────────────────────────
private void BtnAttach_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Multiselect = true,
Title = "첨부할 파일을 선택하세요",
Filter = "모든 파일 (*.*)|*.*|텍스트 (*.txt;*.md;*.csv)|*.txt;*.md;*.csv|코드 (*.cs;*.py;*.js;*.ts)|*.cs;*.py;*.js;*.ts",
};
// 작업 폴더가 있으면 초기 경로 설정
var workFolder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(workFolder) && System.IO.Directory.Exists(workFolder))
dlg.InitialDirectory = workFolder;
if (dlg.ShowDialog() != true) return;
foreach (var file in dlg.FileNames)
AddAttachedFile(file);
}
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"
};
private void AddAttachedFile(string filePath)
{
if (_attachedFiles.Contains(filePath)) return;
// 파일 크기 제한 (10MB)
try
{
var fi = new System.IO.FileInfo(filePath);
if (fi.Length > 10 * 1024 * 1024)
{
CustomMessageBox.Show($"파일이 너무 큽니다 (10MB 초과):\n{fi.Name}", "첨부 제한", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 이미지 파일 → Vision API용 base64 변환
var ext = fi.Extension.ToLowerInvariant();
if (ImageExtensions.Contains(ext) && _settings.Settings.Llm.EnableImageInput)
{
var maxKb = _settings.Settings.Llm.MaxImageSizeKb;
if (maxKb <= 0) maxKb = 5120;
if (fi.Length > maxKb * 1024)
{
CustomMessageBox.Show($"이미지가 너무 큽니다 ({fi.Length / 1024}KB, 최대 {maxKb}KB).",
"이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var bytes = System.IO.File.ReadAllBytes(filePath);
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".webp" => "image/webp",
_ => "image/png",
};
var attachment = new ImageAttachment
{
Base64 = Convert.ToBase64String(bytes),
MimeType = mimeType,
FileName = fi.Name,
};
// 중복 확인
if (_pendingImages.Any(i => i.FileName == attachment.FileName)) return;
_pendingImages.Add(attachment);
AddImagePreview(attachment);
return;
}
}
catch { return; }
_attachedFiles.Add(filePath);
RefreshAttachedFilesUI();
UpdateConditionalSkillActivation();
}
private void RemoveAttachedFile(string filePath)
{
_attachedFiles.Remove(filePath);
RefreshAttachedFilesUI();
UpdateConditionalSkillActivation();
}
private void RefreshAttachedFilesUI()
{
AttachedFilesPanel.Items.Clear();
if (_attachedFiles.Count == 0)
{
if (_pendingImages.Count == 0)
AttachedFilesPanel.Visibility = Visibility.Collapsed;
return;
}
AttachedFilesPanel.Visibility = Visibility.Visible;
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
var borderBrush = TryFindResource("SeparatorColor") as Brush ?? Brushes.LightGray;
foreach (var file in _attachedFiles.ToList())
{
var fileName = System.IO.Path.GetFileName(file);
var capturedFile = file;
// 파일 크기 계산
string sizeText = "";
try
{
var fi = new System.IO.FileInfo(file);
sizeText = fi.Length switch
{
< 1024 => $"{fi.Length}B",
< 1024 * 1024 => $"{fi.Length / 1024.0:F0}KB",
_ => $"{fi.Length / (1024.0 * 1024):F1}MB",
};
}
catch { }
// 파일 유형별 아이콘
var ext = System.IO.Path.GetExtension(file).ToLowerInvariant();
var icon = ext switch
{
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" or ".go" or ".rs" or ".rb" or ".swift"
=> "\uE943", // Code
".json" or ".xml" or ".yaml" or ".yml" or ".csv" or ".tsv"
=> "\uE9F9", // Database/data
".html" or ".htm" or ".css" or ".scss"
=> "\uEB41", // Web
".md" or ".txt" or ".log"
=> "\uE8A5", // Document
".pdf" or ".docx" or ".doc" or ".xlsx" or ".xls" or ".pptx" or ".ppt"
=> "\uEA90", // Office
".zip" or ".7z" or ".rar" or ".tar" or ".gz"
=> "\uE8DE", // Archive
_ => "\uE8A5", // Generic file
};
var chip = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 4, 6),
Margin = new Thickness(0, 0, 6, 4),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 파일 아이콘
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = s_segoeIconFont, FontSize = 13,
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
// 파일명 + 크기 (세로 배치)
var nameStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
nameStack.Children.Add(new TextBlock
{
Text = fileName, FontSize = 11.5, Foreground = primaryBrush,
MaxWidth = 180, TextTrimming = TextTrimming.CharacterEllipsis,
ToolTip = file,
});
if (!string.IsNullOrEmpty(sizeText))
{
nameStack.Children.Add(new TextBlock
{
Text = sizeText, FontSize = 10, Foreground = secondaryBrush,
Opacity = 0.7,
});
}
sp.Children.Add(nameStack);
var removeBtn = new Button
{
Content = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 9, Foreground = secondaryBrush },
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, Padding = new Thickness(6, 2, 4, 2), Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile);
sp.Children.Add(removeBtn);
chip.Child = sp;
AttachedFilesPanel.Items.Add(chip);
}
}
/// <summary>첨부 파일 내용을 시스템 메시지로 변환합니다.</summary>
private string BuildFileContextPrompt()
{
if (_attachedFiles.Count == 0) return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n[첨부 파일 컨텍스트]");
foreach (var file in _attachedFiles)
{
try
{
var ext = System.IO.Path.GetExtension(file).ToLowerInvariant();
var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg"
or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt"
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac"
or ".psd" or ".ai" or ".sketch" or ".fig"
or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite";
if (isBinary)
{
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---");
continue;
}
var content = System.IO.File.ReadAllText(file);
// 최대 8000자로 제한
if (content.Length > 8000)
content = content[..8000] + "\n... (이하 생략)";
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---");
sb.AppendLine(content);
}
catch (Exception ex)
{
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---");
}
}
return sb.ToString();
}
private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e)
{
var newW = Width + e.HorizontalChange;
var newH = Height + e.VerticalChange;
if (newW >= MinWidth) Width = newW;
if (newH >= MinHeight) Height = newH;
}
// ─── 사이드바 토글 ───────────────────────────────────────────────────
private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e)
{
_sidebarVisible = !_sidebarVisible;
_tabSidebarVisible[_activeTab] = _sidebarVisible;
ApplySidebarStateForActiveTab(animated: true);
}
private void ApplySidebarStateForActiveTab(bool animated)
{
var targetVisible = _tabSidebarVisible.TryGetValue(_activeTab, out var visible) ? visible : true;
_sidebarVisible = targetVisible;
if (_sidebarVisible)
{
IconBarColumn.Width = new GridLength(0);
IconBarPanel.Visibility = Visibility.Collapsed;
SidebarPanel.Visibility = Visibility.Visible;
SidebarResizeSplitter.Visibility = Visibility.Visible;
var targetWidth = Math.Max(190, _sidebarExpandedWidth);
if (animated)
{
SidebarPanel.Opacity = 0;
AnimateSidebar(0, targetWidth, () => SidebarColumn.MinWidth = 168);
var fadeIn = new System.Windows.Media.Animation.DoubleAnimation(1, TimeSpan.FromMilliseconds(200))
{
BeginTime = TimeSpan.FromMilliseconds(80)
};
SidebarPanel.BeginAnimation(OpacityProperty, fadeIn);
}
else
{
SidebarPanel.Opacity = 1;
SidebarColumn.MinWidth = 168;
SidebarColumn.Width = new GridLength(targetWidth);
}
return;
}
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
if (currentWidth > 0)
_sidebarExpandedWidth = Math.Max(190, currentWidth);
SidebarColumn.MinWidth = 0;
if (animated)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(0, TimeSpan.FromMilliseconds(150));
SidebarPanel.BeginAnimation(OpacityProperty, fadeOut);
AnimateSidebar(currentWidth > 0 ? currentWidth : _sidebarExpandedWidth, 0, () =>
{
SidebarPanel.Visibility = Visibility.Collapsed;
SidebarPanel.BeginAnimation(OpacityProperty, null);
SidebarPanel.Opacity = 1;
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
});
}
else
{
SidebarColumn.Width = new GridLength(0);
SidebarPanel.Visibility = Visibility.Collapsed;
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
}
}
private void SidebarResizeSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
{
if (!_sidebarVisible)
return;
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
_sidebarExpandedWidth = Math.Clamp(currentWidth, 190, 420);
SidebarColumn.Width = new GridLength(_sidebarExpandedWidth);
}
private void AnimateSidebar(double from, double to, Action? onComplete = null)
{
var duration = 200.0;
var start = DateTime.UtcNow;
_sidebarAnimTimer?.Stop();
_sidebarAnimFrom = from;
_sidebarAnimTo = to;
_sidebarAnimStart = start;
_sidebarAnimDuration = duration;
_sidebarAnimComplete = onComplete;
if (_sidebarAnimTimer == null)
{
_sidebarAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) };
_sidebarAnimTimer.Tick += SidebarAnimTimer_Tick;
}
_sidebarAnimTimer.Start();
}
// ─── 대화 목록 ────────────────────────────────────────────────────────
// ─── 검색 ────────────────────────────────────────────────────────────
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
_conversationSearchTimer.Stop();
_conversationSearchTimer.Start();
}
// ─── 사이드바 애니메이션 (재사용 타이머) ──────────────────────────────
private DispatcherTimer? _sidebarAnimTimer;
private double _sidebarAnimFrom, _sidebarAnimTo, _sidebarAnimDuration;
private DateTime _sidebarAnimStart;
private Action? _sidebarAnimComplete;
private void SidebarAnimTimer_Tick(object? sender, EventArgs e)
{
var elapsed = (DateTime.UtcNow - _sidebarAnimStart).TotalMilliseconds;
var t = Math.Min(elapsed / _sidebarAnimDuration, 1.0);
t = 1 - (1 - t) * (1 - t);
SidebarColumn.Width = new GridLength(_sidebarAnimFrom + (_sidebarAnimTo - _sidebarAnimFrom) * t);
if (elapsed >= _sidebarAnimDuration)
{
_sidebarAnimTimer?.Stop();
SidebarColumn.Width = new GridLength(_sidebarAnimTo);
_sidebarAnimComplete?.Invoke();
}
}
private static string FormatDate(DateTime dt)
{
var diff = DateTime.Now - dt;
if (diff.TotalMinutes < 1) return "방금 전";
if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전";
if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전";
return dt.ToString("MM/dd");
}
private void UpdateChatTitle()
{
ChatConversation? conversation;
lock (_convLock)
{
conversation = _currentConversation;
var title = conversation?.Title ?? "";
ViewModel.ChatTitle = title;
}
UpdateSelectedPresetGuide(conversation);
}
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
private const int TimelineRenderPageSize = 180;
private const int TimelineStreamingRenderLimit = 96;
private const int TimelineLightweightStreamingRenderLimit = 60;
private string? _lastRenderedConversationId;
private int _timelineRenderLimit = TimelineRenderPageSize;
private int _emptyStateAnimationToken; // EmptyState 페이드 애니메이션 무효화 토큰
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
private string GetCheckStyle()
{
var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
return theme switch
{
"dark" or "system" => "circle",
"light" => "roundrect",
_ => "circle",
};
}
private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null)
{
var accent = accentBrush ?? TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
// 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간
if (isChecked)
{
return CreateSimpleCheck(accent, 14);
}
// 미선택: 동일 크기 빈 공간 (정렬 유지)
return new System.Windows.Shapes.Rectangle
{
Width = 14, Height = 14,
Fill = Brushes.Transparent,
Margin = new Thickness(0, 0, 10, 0),
};
}
/// <summary>ScaleTransform 바운스/스케일 애니메이션 헬퍼.</summary>
private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease)
{
if (el.RenderTransform is TransformGroup tg)
{
var st = tg.Children.OfType<ScaleTransform>().FirstOrDefault();
if (st != null)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
st.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
st.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
return;
}
}
if (el.RenderTransform is ScaleTransform scale)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
}
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
private void StopAiIconPulse()
{
_activeSpinnerTimer?.Stop();
_activeSpinnerTimer = null;
if (_aiIconPulseStopped || _activeAiIcon == null) return;
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
_activeAiIcon.Opacity = 1.0;
_activeAiIcon = null;
_aiIconPulseStopped = true;
}
private void CursorTimer_Tick(object? sender, EventArgs e)
{
_cursorVisible = !_cursorVisible;
// 커서 상태만 토글 — 버퍼에 이미 기록된 텍스트의 마지막 커서 문자만 교체
if (_activeStreamText != null && _displayedLength > 0 && _streamDisplayBufferLen > 0)
{
var cursorChar = _cursorVisible ? '\u258c' : ' ';
_streamDisplayBuffer[_streamDisplayBufferLen - 1] = cursorChar;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, _streamDisplayBufferLen);
}
}
private void ElapsedTimer_Tick(object? sender, EventArgs e)
{
var sec = TryGetStreamingElapsed(out var elapsed)
? Math.Max(0, (int)elapsed.TotalSeconds)
: 0;
if (_elapsedLabel != null)
_elapsedLabel.Text = $"{sec}s";
// 하단 상태바 시간 갱신
if (StatusElapsed != null)
{
StatusElapsed.Text = $"{sec}초";
StatusElapsed.Visibility = Visibility.Visible;
}
// 입력창 위 스트리밍 메트릭 레이블 갱신 (경과 시간 반영)
if (_isStreaming)
{
var cumIn = (int)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
var cumOut = (int)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
UpdateStreamMetricsLabel(cumIn, cumOut);
}
}
private void TypingTimer_Tick(object? sender, EventArgs e)
{
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
var targetLen = _cachedStreamContent.Length;
if (_displayedLength >= targetLen) return;
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
var pending = targetLen - _displayedLength;
int step;
if (pending > 1000) step = pending / 4;
else if (pending > 300) step = Math.Min(Math.Max(30, pending / 4), 120);
else if (pending > 120) step = Math.Min(Math.Max(15, pending / 6), 40);
else if (pending > 24) step = Math.Min(12, pending);
else step = Math.Min(4, pending);
_displayedLength += step;
// 재사용 버퍼에 표시할 텍스트 + 커서를 직접 기록 (string.Concat 할당 제거)
var displayLen = _displayedLength;
var cursorChar = _cursorVisible ? '\u258c' : ' ';
var needed = displayLen + 1;
if (needed <= _streamDisplayBuffer.Length)
{
_cachedStreamContent.CopyTo(0, _streamDisplayBuffer, 0, displayLen);
_streamDisplayBuffer[displayLen] = cursorChar;
_streamDisplayBufferLen = needed;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, needed);
}
else
{
// 버퍼 초과 시 fallback (256KB 이상 응답)
_activeStreamText.Text = string.Concat(_cachedStreamContent.AsSpan(0, displayLen), cursorChar.ToString());
}
// 스크롤은 150ms마다 한 번만 (레이아웃 재계산 빈도 감소)
if (!_userScrolled)
{
var now = Environment.TickCount64;
if (now - _lastScrollTick >= 150)
{
_lastScrollTick = now;
ScrollTranscriptToVerticalOffset(GetTranscriptScrollableHeight());
}
}
}
// ─── 전송 ──────────────────────────────────────────────────────────────
public void SendInitialMessage(string message)
{
StartNewConversation();
InputBox.Text = message;
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
}
private void StartNewConversation()
{
Services.LogService.Info($"[NewConv] 시작: activeTab={_activeTab}, streaming={_isStreaming}, " +
$"streamingTabs=[{string.Join(",", _streamingTabs)}]");
// 현재 대화가 있으면 저장 후 새 대화 시작
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.SaveCurrentConversation(_storage, _activeTab);
session.ClearCurrentConversation(_activeTab);
_currentConversation = session.CreateFreshConversation(_activeTab, _settings);
SyncTabConversationIdsFromSession();
Services.LogService.Info($"[NewConv] 생성완료: newId={_currentConversation.Id[..Math.Min(8, _currentConversation.Id.Length)]}, " +
$"tabIds=[Chat={_tabConversationId.GetValueOrDefault("Chat")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Chat")?.Length ?? 0)] ?? "null"}, " +
$"Cowork={_tabConversationId.GetValueOrDefault("Cowork")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Cowork")?.Length ?? 0)] ?? "null"}, " +
$"Code={_tabConversationId.GetValueOrDefault("Code")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Code")?.Length ?? 0)] ?? "null"}]");
}
else
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
try { _storage.Save(_currentConversation); } catch { }
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
?? new ChatConversation { Tab = _activeTab };
}
}
// 탭 기억 초기화 (새 대화이므로)
_tabConversationId[_activeTab] = null;
SyncTabConversationIdsToSession();
ClearTranscriptElements();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
LoadConversationSettings();
LoadCompactionMetricsFromConversation();
SyncAppStateWithCurrentConversation();
UpdateChatTitle();
UpdateFolderBar();
UpdateSelectedPresetGuide();
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
RenderMessages();
RefreshConversationList();
BuildTopicButtons();
RefreshDraftQueueUi();
if (_activeTab == "Cowork") BuildBottomBar();
}
/// <summary>
/// 마지막 활성 탭과 대화를 복원합니다.
/// 저장된 대화가 있으면 불러오고, 없으면 새 대화를 생성합니다.
/// </summary>
private void RestoreLastConversations()
{
var session = ChatSession;
if (session == null)
return;
// 마지막 활성 탭 복원
_activeTab = NormalizeTabName(session.ActiveTab);
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase))
TabChat.IsChecked = true;
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
TabCowork.IsChecked = true;
else if (TabCode.IsEnabled)
TabCode.IsChecked = true;
// 항상 새 대화로 시작 (이전 대화는 대화 목록에서 선택)
// 현재 대화가 있으면 저장 후 새로 시작
lock (_convLock)
{
if (_currentConversation != null)
session.SaveCurrentConversation(_storage, _activeTab);
_currentConversation = session.CreateFreshConversation(_activeTab, _settings);
}
RenderMessages();
UpdateChatTitle();
UpdateFolderBar();
LoadConversationSettings();
LoadCompactionMetricsFromConversation();
SyncAppStateWithCurrentConversation();
UpdateSelectedPresetGuide();
UpdateConditionalSkillActivation(reset: true);
RefreshConversationList();
RefreshDraftQueueUi();
if (_activeTab == "Cowork") BuildBottomBar();
}
/// <summary>현재 _tabConversationId를 설정에 저장합니다.</summary>
private void SaveLastConversations()
{
var session = ChatSession;
if (session != null)
{
SyncTabConversationIdsToSession();
session.ActiveTab = _activeTab;
session.Save(_settings);
SyncTabConversationIdsFromSession();
return;
}
var dict = new Dictionary<string, string>();
foreach (var kv in _tabConversationId)
{
if (!string.IsNullOrEmpty(kv.Value))
dict[kv.Key] = kv.Value;
}
_settings.Settings.Llm.LastConversationIds = dict;
ScheduleSettingsSave();
}
private void BtnSend_Click(object sender, RoutedEventArgs e)
{
// Press animation
if (BtnSend.RenderTransform is ScaleTransform st)
{
var shrink = new System.Windows.Media.Animation.DoubleAnimation(0.96, TimeSpan.FromMilliseconds(80));
shrink.Completed += (_, _) =>
{
var grow = new System.Windows.Media.Animation.DoubleAnimation(1.0, TimeSpan.FromMilliseconds(120))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
};
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
}
QueueComposerDraft(priority: "now", explicitKind: null, startImmediatelyWhenIdle: true);
}
private void InputBox_GotFocus(object sender, RoutedEventArgs e)
{
// Shadow deepens on focus
if (InputBorder?.Effect is System.Windows.Media.Effects.DropShadowEffect shadow)
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(0.14, TimeSpan.FromMilliseconds(200))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
shadow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, anim);
}
// Border color + thickness transitions to focus state
if (InputBorder != null)
{
InputBorder.SetResourceReference(Border.BorderBrushProperty, "InputFocusBorderColor");
InputBorder.BorderThickness = new Thickness(1.2);
}
}
private void InputBox_LostFocus(object sender, RoutedEventArgs e)
{
// Shadow returns to normal
if (InputBorder?.Effect is System.Windows.Media.Effects.DropShadowEffect shadow)
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(0.06, TimeSpan.FromMilliseconds(200))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
shadow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, anim);
}
// Border color + thickness returns to default
if (InputBorder != null)
{
InputBorder.SetResourceReference(Border.BorderBrushProperty, "BorderColor");
InputBorder.BorderThickness = new Thickness(0.5);
}
}
private void InputBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (TryHandleSlashNavigationKey(e))
return;
// Escape: 미리보기 패널 닫기
if (e.Key == Key.Escape && PreviewPanel.Visibility == Visibility.Visible)
{
HidePreviewPanel();
e.Handled = true;
return;
}
// Alt+Up: Pop last queued message back to editor
if (e.Key == Key.Up && Keyboard.Modifiers == ModifierKeys.Alt)
{
e.Handled = true;
PopLastQueuedDraftToEditor();
return;
}
// Shift+Tab: 권한 모드 순환 (Claude Code 스타일)
if (e.Key == Key.Tab && Keyboard.Modifiers == ModifierKeys.Shift)
{
e.Handled = true;
CyclePermissionMode();
return;
}
if (e.Key == Key.Tab && TryAcceptTopFileMentionSuggestion())
{
e.Handled = true;
return;
}
// Ctrl+V: 클립보드 이미지 붙여넣기
if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
{
if (TryPasteClipboardImage())
{
e.Handled = true;
return;
}
// 이미지가 아니면 기본 텍스트 붙여넣기로 위임
}
// IME 조합 중(한글 등) Enter → 조합 확정만 수행, 전송하지 않음
// e.Key == ImeProcessed이고 실제 키가 Enter이면 IME 조합 확정 중이므로 무시
if (e.Key == Key.ImeProcessed && e.ImeProcessedKey == Key.Enter)
return;
if (e.Key == Key.Enter)
{
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
// Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임)
return;
}
// 슬래시 팝업이 열려 있으면 선택된 항목 실행
if (SlashPopup.IsOpen && _slashPalette.SelectedIndex >= 0)
{
e.Handled = true;
ExecuteSlashSelectedItem();
return;
}
// /help 직접 입력 시 도움말 창 표시
if (InputBox.Text.Trim().Equals("/help", StringComparison.OrdinalIgnoreCase))
{
e.Handled = true;
InputBox.Text = "";
SlashPopup.IsOpen = false;
ShowSlashHelpWindow();
return;
}
// Ctrl+Enter → 즉시 실행 대기열, Enter → 다음 대기열
e.Handled = true;
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
{
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
return;
}
QueueComposerDraft(priority: "next", explicitKind: null, startImmediatelyWhenIdle: true);
}
}
/// <summary>클립보드에 이미지가 있으면 붙여넣기. 성공 시 true.</summary>
private bool TryPasteClipboardImage()
{
if (!_settings.Settings.Llm.EnableImageInput) return false;
if (!Clipboard.ContainsImage()) return false;
try
{
var img = Clipboard.GetImage();
if (img == null) return false;
// base64 인코딩
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(img));
using var ms = new System.IO.MemoryStream();
encoder.Save(ms);
var bytes = ms.ToArray();
// 크기 제한 확인
var maxKb = _settings.Settings.Llm.MaxImageSizeKb;
if (maxKb <= 0) maxKb = 5120;
if (bytes.Length > maxKb * 1024)
{
CustomMessageBox.Show($"이미지가 너무 큽니다 ({bytes.Length / 1024}KB, 최대 {maxKb}KB).",
"이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning);
return true; // 처리됨 (에러이지만 이미지였음)
}
var base64 = Convert.ToBase64String(bytes);
var attachment = new ImageAttachment
{
Base64 = base64,
MimeType = "image/png",
FileName = $"clipboard_{DateTime.Now:HHmmss}.png",
};
_pendingImages.Add(attachment);
AddImagePreview(attachment, img);
return true;
}
catch (Exception ex)
{
Services.LogService.Debug($"클립보드 이미지 붙여넣기 실패: {ex.Message}");
return false;
}
}
/// <summary>이미지 미리보기 UI 추가.</summary>
private void AddImagePreview(ImageAttachment attachment, System.Windows.Media.Imaging.BitmapSource? thumbnail = null)
{
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
var chip = new Border
{
Background = hintBg,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(4),
Margin = new Thickness(0, 0, 4, 4),
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 썸네일 이미지
if (thumbnail != null)
{
sp.Children.Add(new System.Windows.Controls.Image
{
Source = thumbnail,
MaxHeight = 48, MaxWidth = 64,
Stretch = Stretch.Uniform,
Margin = new Thickness(0, 0, 4, 0),
});
}
else
{
// base64에서 썸네일 생성
sp.Children.Add(new TextBlock
{
Text = "\uE8B9", FontFamily = s_segoeIconFont, FontSize = 16,
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 4, 0),
});
}
sp.Children.Add(new TextBlock
{
Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush,
VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100,
TextTrimming = TextTrimming.CharacterEllipsis,
});
var capturedAttachment = attachment;
var capturedChip = chip;
var removeBtn = new Button
{
Content = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 8, Foreground = secondaryBrush },
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
};
removeBtn.Click += (_, _) =>
{
_pendingImages.Remove(capturedAttachment);
AttachedFilesPanel.Items.Remove(capturedChip);
if (_pendingImages.Count == 0 && _attachedFiles.Count == 0)
AttachedFilesPanel.Visibility = Visibility.Collapsed;
};
sp.Children.Add(removeBtn);
chip.Child = sp;
AttachedFilesPanel.Items.Add(chip);
AttachedFilesPanel.Visibility = Visibility.Visible;
}
// ─── 슬래시 명령어 ────────────────────────────────────────────────────
// ── 슬래시 명령어 팝업 상태 ──
private readonly SlashPaletteState _slashPalette = new();
private readonly Dictionary<int, FrameworkElement> _slashVisibleItemByAbsoluteIndex = new();
private readonly List<int> _slashVisibleAbsoluteOrder = new();
// ── 슬래시 명령어 (탭별 분류) ──
private void InputBox_TextChanged(object sender, TextChangedEventArgs e)
{
UpdateWatermarkVisibility();
UpdateInputBoxHeight();
ScheduleInputUiRefresh();
var text = InputBox.Text;
// 파일 멘션 제안은 매 키입력마다 정규식 + 후보 필터링을 수행하므로
// 120ms 디바운스로 타이핑 중 버벅임을 완화
ScheduleFileMentionRefresh(text);
// 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제
if (_slashPalette.ActiveCommand != null && text.StartsWith("/"))
HideSlashChip(restoreText: false);
if (text.StartsWith("/") && !text.Contains(' '))
{
// 탭별 필터링: Chat → "all"만, Cowork/Code → "all" + "dev"
bool isDev = _activeTab is "Cowork" or "Code";
var slashEntries = SlashCommandCatalog.MatchBuiltinCommands(text, isDev)
.Select(match => (
match.Cmd,
match.Label,
match.IsSkill,
Priority: SlashCommandCatalog.GetBuiltInCommandPriority(match.Cmd)))
.ToList();
// 스킬 슬래시 명령어 매칭 (탭별 필터)
if (_settings.Settings.Llm.EnableSkillSystem)
{
EnsureSkillSystemLoadedForCurrentWorkspace();
var skillMatches = SkillService.MatchSlashCommand(text)
.Where(s => s.IsVisibleInTab(_activeTab))
.Select(s => (Cmd: "/" + s.Name,
Label: BuildSlashSkillLabel(s),
IsSkill: true,
Priority: SkillService.GetSkillSourcePriority(s.SourceScope)));
slashEntries.AddRange(skillMatches);
}
var matches = SlashCommandCatalog.ComposeMatches(slashEntries);
if (matches.Count > 0)
{
_slashPalette.Matches = matches;
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches);
RenderSlashPage();
SlashPopup.IsOpen = true;
ScheduleInputUiRefresh();
return;
}
}
SlashPopup.IsOpen = false;
ScheduleInputUiRefresh();
}
private static void SyncLatestAssistantMessage(ChatConversation conv, string content)
{
if (conv.Messages.Count == 0)
return;
for (var i = conv.Messages.Count - 1; i >= 0; i--)
{
var message = conv.Messages[i];
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
message.Content = content;
return;
}
}
private void ScheduleInputUiRefresh()
{
if (_inputUiRefreshTimer == null)
return;
if (IsBackgroundUiThrottleActive())
{
_pendingBackgroundInputUiRefresh = true;
return;
}
if (IsLightweightLiveProgressMode() && _inputUiRefreshTimer.IsEnabled)
return;
_inputUiRefreshTimer.Stop();
_inputUiRefreshTimer.Start();
}
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
private async Task<(string? slashSystem, string userText)> ParseSlashCommandAsync(string input, CancellationToken ct = default)
{
var trimmed = input.TrimStart();
if (trimmed.StartsWith("/"))
{
var firstSpace = trimmed.IndexOf(' ');
var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim();
var isDev = _activeTab is "Cowork" or "Code";
var exactEntries = new List<(string Cmd, string Label, bool IsSkill, int Priority)>();
if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry)
&& (entry.Tab == "all" || (isDev && entry.Tab == "dev")))
{
exactEntries.Add((
Cmd: commandToken,
Label: entry.Label,
IsSkill: false,
Priority: SlashCommandCatalog.GetBuiltInCommandPriority(commandToken)));
}
if (_settings.Settings.Llm.EnableSkillSystem)
{
EnsureSkillSystemLoadedForCurrentWorkspace();
exactEntries.AddRange(
SkillService.MatchSlashCommand(commandToken)
.Where(s => s.IsVisibleInTab(_activeTab))
.Where(s => string.Equals("/" + s.Name, commandToken, StringComparison.OrdinalIgnoreCase))
.Select(s => (
Cmd: "/" + s.Name,
Label: BuildSlashSkillLabel(s),
IsSkill: true,
Priority: SkillService.GetSkillSourcePriority(s.SourceScope))));
}
var preferredCommand = SlashCommandCatalog.ResolvePreferredCommand(commandToken, exactEntries);
if (preferredCommand.HasValue && preferredCommand.Value.IsSkill)
{
var compiled = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
if (compiled != null)
return (compiled.SystemPrompt, compiled.DisplayText);
}
if (preferredCommand.HasValue
&& !preferredCommand.Value.IsSkill
&& SlashCommandCatalog.TryGetEntry(commandToken, out entry))
{
// __HELP__는 특수 처리 (ParseSlashCommand에서는 무시)
if (entry.SystemPrompt == "__HELP__") return (null, input);
var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : "";
return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest);
}
}
// 스킬 명령어 매칭
EnsureSkillSystemLoadedForCurrentWorkspace();
var compiledInvocation = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
if (compiledInvocation != null)
return (compiledInvocation.SystemPrompt, compiledInvocation.DisplayText);
return (null, input);
}
// ─── 드래그 앤 드롭 AI 액션 팝업 ─────────────────────────────────────
private static readonly Dictionary<string, List<(string Label, string Icon, string Prompt)>> DropActions = new(StringComparer.OrdinalIgnoreCase)
{
["code"] =
[
("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."),
("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."),
("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."),
("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."),
],
["document"] =
[
("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."),
("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."),
("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."),
],
["data"] =
[
("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."),
("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."),
("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"),
],
["image"] =
[
("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."),
("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."),
],
};
private static readonly HashSet<string> CodeExtensions = new(Services.CodeLanguageCatalog.CodeExtensions, StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> DataExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" };
// ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용
private Popup? _dropActionPopup;
private void ShowDropActionMenu(string[] files)
{
if (files == null || files.Length == 0) return;
// 파일 유형 판별
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
string category;
if (CodeExtensions.Contains(ext)) category = "code";
else if (DataExtensions.Contains(ext)) category = "data";
else if (ImageExtensions.Contains(ext)) category = "image";
else category = "document";
var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"];
// 팝업 생성
_dropActionPopup?.SetValue(Popup.IsOpenProperty, false);
var panel = new StackPanel();
// 헤더
var header = new TextBlock
{
Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" {files.Length - 1}" : "")}",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(12, 8, 12, 6),
};
panel.Children.Add(header);
// 액션 항목
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(24, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var textBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
foreach (var (label, icon, prompt) in actions)
{
var capturedPrompt = prompt;
var row = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var stack = new StackPanel { Orientation = Orientation.Horizontal };
stack.Children.Add(new TextBlock
{
Text = icon,
FontFamily = s_segoeIconFont,
FontSize = 13, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
stack.Children.Add(new TextBlock
{
Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center,
});
row.Child = stack;
row.MouseEnter += (_, _) => row.Background = hoverBrush;
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
row.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Text = capturedPrompt;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
if (_settings.Settings.Llm.DragDropAutoSend)
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
};
panel.Children.Add(row);
}
// "첨부만" 항목
var attachOnly = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var attachStack = new StackPanel { Orientation = Orientation.Horizontal };
attachStack.Children.Add(new TextBlock
{
Text = "\uE723",
FontFamily = s_segoeIconFont,
FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
attachStack.Children.Add(new TextBlock
{
Text = "첨부만", FontSize = 13,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
attachOnly.Child = attachStack;
attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush;
attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent;
attachOnly.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Focus();
};
panel.Children.Add(attachOnly);
var container = new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)),
CornerRadius = new CornerRadius(12),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
Padding = new Thickness(4, 4, 4, 6),
Child = panel,
MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
},
};
_dropActionPopup = new Popup
{
PlacementTarget = InputBorder,
Placement = PlacementMode.Top,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Child = container,
};
_dropActionPopup.IsOpen = true;
}
// ─── /help 도움말 창 ─────────────────────────────────────────────────
private void ShowSlashHelpWindow()
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var fg2 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(40, 255, 255, 255));
var win = new Window
{
Title = "AX Agent — 슬래시 명령어 도움말",
Width = 560, Height = 640, MinWidth = 440, MinHeight = 500,
WindowStyle = WindowStyle.None, AllowsTransparency = true,
Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize,
WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this,
Icon = Icon,
};
var mainBorder = new Border
{
Background = bg, CornerRadius = new CornerRadius(16),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1), Margin = new Thickness(10),
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 },
};
var rootGrid = new Grid();
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) });
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
// 헤더
var headerBorder = new Border
{
CornerRadius = new CornerRadius(16, 16, 0, 0),
Background = new LinearGradientBrush(
Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204),
new Point(0, 0), new Point(1, 1)),
Padding = new Thickness(20, 0, 20, 0),
};
var headerGrid = new Grid();
var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = s_segoeIconFont, FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center });
headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center });
headerGrid.Children.Add(headerStack);
var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center };
closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255));
closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent;
closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); };
headerGrid.Children.Add(closeBtn);
headerBorder.Child = headerGrid;
Grid.SetRow(headerBorder, 0);
rootGrid.Children.Add(headerBorder);
// 콘텐츠
var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) };
var contentPanel = new StackPanel();
// 설명
contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 });
// 공통 명령어 섹션
AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg,
("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."),
("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"),
("/new", "새 대화를 시작합니다."),
("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."),
("/model", "모델 선택 패널을 엽니다."),
("/permissions", "권한 설정 패널을 엽니다."),
("/allowed-tools", "허용 도구(권한) 패널을 엽니다."),
("/settings", "AX Agent 설정 창을 엽니다."),
("/stats", "최근 호출의 토큰 통계를 표시합니다."),
("/cost", "최근 호출의 추정 비용을 표시합니다."),
("/export", "현재 대화를 파일로 내보냅니다."),
("/clear", "현재 대화를 정리하고 새 대화를 시작합니다."));
AddHelpSection(contentPanel, "작업/운영 명령어", "에이전트 운영 명령", fg, fg2, accent, itemBg, hoverBg,
("/config", "설정 점검 및 권장안을 제시합니다."),
("/context", "현재 목표/제약/다음 액션을 정리합니다."),
("/session", "세션 핵심 맥락과 다음 할 일을 요약합니다."),
("/usage", "사용 효율을 높이는 팁을 제시합니다."),
("/rename", "현재 대화 이름을 즉시 변경합니다."),
("/feedback", "마지막 응답에 대한 수정 피드백 입력 패널을 엽니다."),
("/skills", "스킬 시스템/브라우저를 엽니다."),
("/sandbox-toggle", "권한 모드를 순환 전환합니다."),
("/statusline", "현재 상태를 한 줄로 요약해 표시합니다."),
("/heapdump", "메모리 사용 현황을 진단합니다."),
("/passes", "반복/패스 관련 설정 프리셋을 순환 전환합니다."),
("/chrome", "인자 없으면 진단, 인자 있으면 브라우저 작업 실행 경로로 라우팅합니다."),
("/stickers", "빠른 상태 스티커 세트를 보여줍니다."),
("/thinkback", "최근 대화 맥락을 요약 회고합니다."),
("/thinkback-play", "회고 내용을 바탕으로 다음 실행 플랜을 제시합니다."),
("/theme", "테마/표현 관련 설정으로 이동합니다."),
("/output-style", "출력 스타일 가이드를 제시합니다."),
("/keybindings", "단축키 효율화 팁을 제시합니다."),
("/privacy-settings", "보안/개인정보 관점 설정 점검표를 제시합니다."),
("/rate-limit-options", "요청 한도 대응 전략을 제시합니다."));
// 개발 명령어 섹션
AddHelpSection(contentPanel, "개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg,
("/review", "변경 코드를 리뷰하고 리스크를 찾습니다."),
("/commit", "변경사항을 확인하고 승인 후 실제 커밋을 실행합니다. (files: 지정 지원)"),
("/ultrareview", "더 엄격한 리뷰 기준으로 치명 리스크를 우선 점검합니다."),
("/security-review", "보안 중심으로 취약점과 개선안을 점검합니다."),
("/pr", "변경사항을 PR 설명 형식으로 정리합니다."),
("/pr-comments", "리뷰 코멘트 형태의 개선 의견을 작성합니다."),
("/test", "테스트 생성/개선 방향을 제시합니다."),
("/verify", "빌드/테스트/리스크 점검까지 포함한 검증 모드로 실행합니다."),
("/structure", "프로젝트 구조를 분석합니다."),
("/build", "빌드 및 오류 분석을 진행합니다."),
("/search", "코드베이스 검색을 수행합니다."),
("/diff", "현재 diff의 핵심 변경점/리스크를 요약합니다."),
("/doctor", "프로젝트/환경 점검 체크를 수행합니다."));
AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg,
("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"),
("/agents", "에이전트 분담 전략 제시"),
("/plugin", "플러그인 구성 점검"),
("/reload-plugins", "플러그인 재로드 점검"),
("/install-github-app", "GitHub 앱 연동 안내"),
("/install-slack-app", "Slack 앱 연동 안내"),
("/remote-env", "원격 환경 연결 점검"),
("/ide", "IDE 연동 점검"),
("/terminal-setup", "터미널 초기 구성 점검"));
// 스킬 명령어 섹션
var skills = SkillService.Skills;
if (skills.Count > 0)
{
var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray();
AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — 기본 스킬 폴더와 추가 폴더에서 함께 불러옵니다.", fg, fg2, accent, itemBg, hoverBg, skillItems);
}
// 사용 팁
contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) });
var tipPanel = new StackPanel();
tipPanel.Children.Add(new TextBlock { Text = "사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) });
var tips = new[]
{
"/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.",
"파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.",
"스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.",
"Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.",
};
foreach (var tip in tips)
{
tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 });
}
contentPanel.Children.Add(tipPanel);
scroll.Content = contentPanel;
Grid.SetRow(scroll, 1);
rootGrid.Children.Add(scroll);
mainBorder.Child = rootGrid;
win.Content = mainBorder;
// 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지)
headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch { } };
win.ShowDialog();
}
private static void AddHelpSection(StackPanel parent, string title, string subtitle,
Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg,
params (string Cmd, string Desc)[] items)
{
parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) });
parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) });
foreach (var (cmd, desc) in items)
{
var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) };
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = new FontFamily("Consolas") };
Grid.SetColumn(cmdText, 0);
grid.Children.Add(cmdText);
var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
Grid.SetColumn(descText, 1);
grid.Children.Add(descText);
row.Child = grid;
row.MouseEnter += (_, _) => row.Background = hoverBg;
row.MouseLeave += (_, _) => row.Background = itemBg;
parent.Children.Add(row);
}
}
private async Task ExecuteManualCompactAsync(string commandText, string runTab)
{
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = commandText };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
}
}
SaveLastConversations();
_storage.Save(conv);
ChatSession?.RememberConversation(runTab, conv.Id);
UpdateChatTitle();
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
InvalidateTimelineCache();
RenderMessages(preserveViewport: false);
var llm = _settings.Settings.Llm;
var working = conv.Messages.ToList();
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
working,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
true,
CancellationToken.None);
var beforeTokens = compactResult.BeforeTokens;
var afterTokens = compactResult.AfterTokens;
RecordCompactionStats(compactResult, wasAutomatic: false);
if (compactResult.Changed)
{
lock (_convLock)
{
conv.Messages = working;
}
}
var assistantText = compactResult.Changed
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}\n- 단계: {compactResult.StageSummary}\n- 절감량: {compactResult.SavedTokens:N0} tokens"
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
lock (_convLock)
{
var session = ChatSession;
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session?.CurrentConversation ?? conv;
conv = _currentConversation!;
}
SaveLastConversations();
_storage.Save(conv);
InvalidateTimelineCache();
RenderMessages(preserveViewport: false);
if (StatusTokens != null)
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
RefreshContextUsageVisual();
RefreshConversationList();
UpdateTaskSummaryIndicators();
}
private void AppendLocalSlashResult(string runTab, string commandText, string assistantText)
{
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = commandText };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
_chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, storage: _storage);
}
}
SaveLastConversations();
_storage.Save(conv);
ChatSession?.RememberConversation(runTab, conv.Id);
UpdateChatTitle();
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
InvalidateTimelineCache();
RenderMessages(preserveViewport: false);
}
private string BuildSlashStatusText()
{
var llm = _settings.Settings.Llm;
var tab = _activeTab;
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var service = llm.Service?.ToLowerInvariant() switch
{
"gemini" => "Gemini",
"sigmoid" or "claude" => "Claude",
"vllm" => "vLLM",
_ => "Ollama",
};
var model = GetCurrentModelDisplayName();
var folder = GetCurrentWorkFolder();
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
return
$"현재 상태\n" +
$"- 탭: {tab}\n" +
$"- 모델: {service} · {model}\n" +
$"- 권한: {permission}\n" +
$"- 작업 폴더: {folderText}\n" +
$"- 스트리밍: {(llm.Streaming ? "ON" : "OFF")}\n" +
$"- 컨텍스트 토큰: {llm.MaxContextTokens:N0}";
}
private string BuildSlashStatsText()
{
var usage = _llm.LastTokenUsage;
if (usage == null)
return "최근 호출 토큰 통계가 아직 없습니다. 대화를 한 번 실행한 뒤 다시 시도하세요.";
return
$"최근 호출 토큰\n" +
$"- 입력: {usage.PromptTokens:N0}\n" +
$"- 출력: {usage.CompletionTokens:N0}\n" +
$"- 합계: {usage.TotalTokens:N0}";
}
private string BuildSlashCostText()
{
var usage = _llm.LastTokenUsage;
if (usage == null)
return "최근 호출 토큰 정보가 없어 비용을 계산할 수 없습니다.";
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
usage.PromptTokens,
usage.CompletionTokens,
_settings.Settings.Llm.Service,
GetCurrentModelDisplayName());
var total = inCost + outCost;
return
$"최근 호출 추정 비용\n" +
$"- 입력 비용: {Services.TokenEstimator.FormatCost(inCost)}\n" +
$"- 출력 비용: {Services.TokenEstimator.FormatCost(outCost)}\n" +
$"- 합계: {Services.TokenEstimator.FormatCost(total)}";
}
private string BuildSlashStatuslineText()
{
var llm = _settings.Settings.Llm;
var (_, model) = _llm.GetCurrentModelInfo();
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var folder = GetCurrentWorkFolder();
var folderName = string.IsNullOrWhiteSpace(folder) ? "NoFolder" : System.IO.Path.GetFileName(folder.TrimEnd('\\', '/'));
var historyCount = _currentConversation?.Messages.Count ?? 0;
return $"[{_activeTab}] {ServiceLabel(llm.Service)}/{model} · {permission} · msg {historyCount} · folder {folderName}";
}
private static string MaskEndpoint(string endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
return "(미설정)";
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
return endpoint;
return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}";
}
private static string NormalizeServiceLabel(string service)
{
return service.Trim().ToLowerInvariant() switch
{
"vllm" => "vLLM",
"gemini" => "Gemini",
"sigmoid" => "Claude",
_ => "Ollama",
};
}
private async Task<string> BuildLlmRuntimeDiagnosisAsync()
{
var snapshot = _llm.GetRuntimeConnectionSnapshot();
var (ok, message) = await _llm.TestConnectionAsync();
var lines = new List<string>
{
"LLM 런타임 진단",
$"- 서비스: {NormalizeServiceLabel(snapshot.Service)}",
$"- 모델: {snapshot.Model}",
$"- 엔드포인트: {MaskEndpoint(snapshot.Endpoint)}",
$"- API 키: {(snapshot.HasApiKey ? "" : "")}",
};
if (string.Equals(snapshot.Service, "vllm", StringComparison.OrdinalIgnoreCase))
lines.Add($"- SSL 인증서 검증 우회: {(snapshot.AllowInsecureTls ? "ON" : "OFF")}");
lines.Add($"- 연결 결과: {(ok ? "" : "")} ({message})");
return string.Join("\n", lines);
}
private bool IsMcpServerEnabled(McpServerEntry server)
{
if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden))
return overridden;
return server.Enabled;
}
private bool IsChromeMcpCandidate(McpServerEntry server)
{
if (!IsMcpServerEnabled(server))
return false;
return
(server.Name?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
(server.Command?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
(server.Url?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
server.Args.Any(a => a.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) >= 0);
}
private async Task<string> BuildChromeRuntimeDiagnosisAsync(CancellationToken ct = default)
{
var servers = _settings.Settings.Llm.McpServers ?? [];
var matches = servers.Where(IsChromeMcpCandidate).ToList();
if (matches.Count == 0)
return "Chrome MCP가 구성되지 않았습니다. 설정에서 MCP 서버를 추가/활성화한 뒤 다시 /chrome 를 실행하세요.";
var lines = new List<string>
{
"Chrome MCP 런타임 진단",
$"- 후보 서버: {matches.Count}개"
};
var connectedCount = 0;
var totalTools = 0;
foreach (var server in matches)
{
var serverName = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
{
lines.Add($"- {serverName}: {transport} 전송은 현재 앱의 런타임 직접 진단 미지원 (구성만 확인)");
continue;
}
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected)
{
lines.Add($"- {serverName}: 연결 실패");
continue;
}
connectedCount++;
var tools = client.Tools;
totalTools += tools.Count;
lines.Add($"- {serverName}: 연결 성공 (도구 {tools.Count}개)");
var toolPreview = tools
.Select(t => t.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Take(6)
.ToList();
if (toolPreview.Count > 0)
lines.Add($" 도구: {string.Join(", ", toolPreview)}");
}
if (connectedCount == 0)
{
lines.Add("진단 결과: 연결 가능한 Chrome MCP 서버가 없습니다.");
lines.Add("확인 항목: command/args 경로, 실행 권한, Node/NPM 설치 상태, 서버 실행 로그");
return string.Join("\n", lines);
}
lines.Add($"진단 결과: {connectedCount}개 서버 연결 성공, 총 도구 {totalTools}개 확인");
lines.Add("다음 단계: /mcp 또는 /mcp reconnect <서버명> 으로 상태를 갱신하세요.");
return string.Join("\n", lines);
}
private async Task<(bool Ready, List<string> ServerNames, List<string> ToolNames)> ProbeChromeToolingAsync(CancellationToken ct = default)
{
var servers = _settings.Settings.Llm.McpServers ?? [];
var matches = servers.Where(IsChromeMcpCandidate).ToList();
var readyServers = new List<string>();
var toolNames = new List<string>();
if (matches.Count == 0)
return (false, readyServers, toolNames);
foreach (var server in matches)
{
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
continue;
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected || client.Tools.Count == 0)
continue;
readyServers.Add(string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name);
toolNames.AddRange(client.Tools.Select(t => $"mcp_{t.ServerName}_{t.Name}"));
}
return (readyServers.Count > 0, readyServers, toolNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
internal static string BuildChromeExecutionSystemPrompt(string userRequest, IEnumerable<string> serverNames, IEnumerable<string> toolNames)
{
var serversText = string.Join(", ", serverNames.Take(6));
var toolList = toolNames.Take(16).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are executing a browser automation task via MCP tools.");
sb.AppendLine($"Target request: {userRequest}");
sb.AppendLine($"Preferred MCP servers: {serversText}");
if (toolList.Count > 0)
sb.AppendLine($"Preferred tools: {string.Join(", ", toolList)}");
sb.AppendLine("Rules:");
sb.AppendLine("1) Prioritize the preferred MCP browser tools first.");
sb.AppendLine("2) If URL scheme is missing, default to https://.");
sb.AppendLine("3) Execute only the minimum required steps.");
sb.AppendLine("4) Return concise evidence (visited URL, key page text, or action result).");
sb.AppendLine("5) If browser tooling is unavailable, explain blocker and suggest /chrome or /mcp reconnect.");
return sb.ToString();
}
internal static string BuildVerifySystemPrompt(string request)
{
var objective = string.IsNullOrWhiteSpace(request) || string.Equals(request.Trim(), "/verify", StringComparison.OrdinalIgnoreCase)
? "현재 변경사항의 품질 검증을 수행하세요."
: request.Trim();
return
"You are in verification mode.\n" +
$"Verification target: {objective}\n" +
"Required flow:\n" +
"1) Inspect current changes and identify risky files.\n" +
"2) Run build and tests with appropriate tools.\n" +
"3) If failures occur, identify root cause and patch minimally.\n" +
"4) Re-run verification until pass or clear blocker.\n" +
"5) Return a structured report in Korean exactly with sections:\n" +
" [검증결과] PASS|FAIL\n" +
" [실행요약] (build/test/review 실행 내역)\n" +
" [변경파일] (수정한 파일 목록)\n" +
" [잔여리스크] (남은 위험 또는 없음)\n";
}
internal static (string action, string argument) ParseGenericAction(string displayText, string command)
{
var raw = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase))
return ("open", "");
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return ("open", "");
if (parts.Length == 1)
return (parts[0].Trim().ToLowerInvariant(), "");
return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
}
internal static (List<string> SelectedFiles, string CommitMessage) ParseCommitCommandInput(string displayText)
{
var raw = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "/commit", StringComparison.OrdinalIgnoreCase))
return (new List<string>(), "");
if (!raw.StartsWith("files:", StringComparison.OrdinalIgnoreCase))
return (new List<string>(), raw);
var body = raw["files:".Length..].Trim();
var split = body.Split(new[] { "::" }, 2, StringSplitOptions.None);
var filesPart = split[0].Trim();
var msgPart = split.Length >= 2 ? split[1].Trim() : "";
var files = filesPart
.Split([','], StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return (files, msgPart);
}
private static string? FindGitExecutablePath()
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo("where.exe", "git")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
return null;
var output = proc.StandardOutput.ReadToEnd().Trim();
proc.WaitForExit(5000);
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
}
catch
{
return null;
}
}
private static string? ResolveGitRoot(string? workFolder)
{
if (string.IsNullOrWhiteSpace(workFolder) || !System.IO.Directory.Exists(workFolder))
return null;
var dir = new System.IO.DirectoryInfo(workFolder);
while (dir != null)
{
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunGitAsync(
string gitPath,
string workDir,
IEnumerable<string> args,
CancellationToken ct = default)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = gitPath,
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8,
StandardErrorEncoding = System.Text.Encoding.UTF8,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
return (-1, "", "git 프로세스를 시작하지 못했습니다.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
var stdoutTask = proc.StandardOutput.ReadToEndAsync(timeoutCts.Token);
var stderrTask = proc.StandardError.ReadToEndAsync(timeoutCts.Token);
await proc.WaitForExitAsync(timeoutCts.Token);
var stdout = await stdoutTask;
var stderr = await stderrTask;
return (proc.ExitCode, stdout, stderr);
}
private async Task<string> ExecuteCommitWithApprovalAsync(string? displayText, CancellationToken ct = default)
{
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
return "커밋 실행은 Cowork/Code 탭에서만 지원됩니다.";
}
var folder = GetCurrentWorkFolder();
var gitRoot = ResolveGitRoot(folder);
if (string.IsNullOrWhiteSpace(gitRoot))
return "Git 저장소를 찾지 못했습니다. 작업 폴더를 Git 프로젝트로 설정해 주세요.";
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return "Git 실행 파일을 찾지 못했습니다. Git 설치 및 PATH를 확인해 주세요.";
var status = await RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct).ConfigureAwait(false);
if (status.ExitCode != 0)
return $"git status 실패:\n{status.StdErr.Trim()}";
var changedLines = status.StdOut
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (changedLines.Count == 0)
return "커밋할 변경사항이 없습니다.";
var (selectedFilesRaw, parsedMessage) = ParseCommitCommandInput(displayText ?? "");
var changedPathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in changedLines)
{
var path = line.Length > 3 ? line[3..].Trim() : line.Trim();
if (string.IsNullOrWhiteSpace(path))
continue;
if (path.Contains("->", StringComparison.Ordinal))
{
var renameParts = path.Split(new[] { "->" }, 2, StringSplitOptions.None);
if (renameParts.Length == 2)
{
var before = renameParts[0].Trim();
var after = renameParts[1].Trim();
if (!string.IsNullOrWhiteSpace(before)) changedPathSet.Add(before);
if (!string.IsNullOrWhiteSpace(after)) changedPathSet.Add(after);
continue;
}
}
changedPathSet.Add(path);
}
var selectedFiles = selectedFilesRaw.Count == 0
? changedPathSet.ToList()
: selectedFilesRaw
.Where(p => changedPathSet.Contains(p))
.ToList();
if (selectedFiles.Count == 0)
{
if (selectedFilesRaw.Count > 0)
return "지정한 파일이 현재 변경 목록에 없습니다. /commit files:<경로1,경로2> :: <메시지> 형식을 확인해 주세요.";
return "커밋할 변경 파일을 찾지 못했습니다.";
}
var commitMessage = string.IsNullOrWhiteSpace(parsedMessage)
? $"작업: 변경사항 반영 ({selectedFiles.Count}개 파일)"
: parsedMessage;
var previewFiles = selectedFiles
.Take(8)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
var previewText = previewFiles.Count == 0
? "(파일 목록 없음)"
: string.Join("\n", previewFiles.Select(f => $"- {f}"));
var confirm = CustomMessageBox.Show(
$"다음 내용으로 커밋을 진행할까요?\n\n저장소: {gitRoot}\n메시지: {commitMessage}\n\n커밋 대상 파일(일부):\n{previewText}",
"커밋 승인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
return "커밋 실행을 취소했습니다.";
var addArgs = new List<string> { "add" };
if (selectedFilesRaw.Count == 0)
{
addArgs.Add("-A");
}
else
{
addArgs.Add("--");
addArgs.AddRange(selectedFiles);
}
var add = await RunGitAsync(gitPath, gitRoot, addArgs, ct).ConfigureAwait(false);
if (add.ExitCode != 0)
return $"git add 실패:\n{add.StdErr.Trim()}";
var commit = await RunGitAsync(gitPath, gitRoot, new[] { "commit", "-m", commitMessage }, ct).ConfigureAwait(false);
if (commit.ExitCode != 0)
{
var err = string.IsNullOrWhiteSpace(commit.StdErr) ? commit.StdOut : commit.StdErr;
return $"git commit 실패:\n{err.Trim()}";
}
var head = await RunGitAsync(gitPath, gitRoot, new[] { "log", "--oneline", "-1" }, ct).ConfigureAwait(false);
var headLine = (head.StdOut ?? "").Trim();
return
$"커밋이 완료되었습니다.\n" +
$"- 메시지: {commitMessage}\n" +
$"- 커밋 파일 수: {selectedFiles.Count}\n" +
(string.IsNullOrWhiteSpace(headLine) ? "" : $"- HEAD: {headLine}\n") +
"- 원격 반영은 사용자가 직접 push 해주세요.\n" +
"- 팁: /commit files:path1,path2 :: 메시지 형태로 부분 커밋할 수 있습니다.";
}
internal static (string action, string target) ParseMcpAction(string displayText)
{
var text = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(text) || string.Equals(text, "/mcp", StringComparison.OrdinalIgnoreCase))
return ("status", "");
var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return ("status", "");
var action = parts[0].Trim().ToLowerInvariant();
var target = parts.Length >= 2 ? string.Join(' ', parts.Skip(1)).Trim() : "";
return action switch
{
"enable" => ("enable", target),
"disable" => ("disable", target),
"reconnect" => ("reconnect", target),
"add" => ("add", target),
"remove" => ("remove", target),
"reset" => ("reset", target),
"login" => ("login", target),
"logout" => ("logout", target),
"status" => ("status", target),
_ => ("help", text),
};
}
internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target)
{
var raw = (target ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw))
return (false, "", "", "사용법: /mcp login <서버명> <토큰>");
var firstSpace = raw.IndexOf(' ');
if (firstSpace <= 0 || firstSpace >= raw.Length - 1)
return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>");
var serverTarget = raw[..firstSpace].Trim();
var token = raw[(firstSpace + 1)..].Trim();
if (string.IsNullOrWhiteSpace(serverTarget))
return (false, "", "", "서버명이 비어 있습니다.");
if (string.IsNullOrWhiteSpace(token))
return (false, "", "", "토큰이 비어 있습니다.");
return (true, serverTarget, token, "");
}
internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target)
{
var raw = (target ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw))
return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse <URL>");
var sep = raw.IndexOf("::", StringComparison.Ordinal);
if (sep < 0)
return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js");
var name = raw[..sep].Trim();
var spec = raw[(sep + 2)..].Trim();
if (string.IsNullOrWhiteSpace(name))
return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요.");
if (string.IsNullOrWhiteSpace(spec))
return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요.");
var tokens = TokenizeCommand(spec);
if (tokens.Count < 2)
return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse");
var transport = tokens[0].Trim().ToLowerInvariant();
if (transport == "stdio")
{
var command = tokens[1].Trim();
if (string.IsNullOrWhiteSpace(command))
return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다.");
var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List<string>();
return (true, new McpServerEntry
{
Name = name,
Transport = "stdio",
Command = command,
Args = args,
Enabled = true,
}, "");
}
if (transport == "sse")
{
var url = string.Join(" ", tokens.Skip(1)).Trim();
if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed))
return (false, null, $"유효하지 않은 SSE URL 입니다: {url}");
if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다.");
return (true, new McpServerEntry
{
Name = name,
Transport = "sse",
Url = url,
Enabled = true,
}, "");
}
return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse");
}
internal static List<string> TokenizeCommand(string input)
{
var text = input ?? "";
var tokens = new List<string>();
var sb = new System.Text.StringBuilder();
var inQuote = false;
foreach (var ch in text)
{
if (ch == '"')
{
inQuote = !inQuote;
continue;
}
if (!inQuote && char.IsWhiteSpace(ch))
{
if (sb.Length == 0) continue;
tokens.Add(sb.ToString());
sb.Clear();
continue;
}
sb.Append(ch);
}
if (sb.Length > 0)
tokens.Add(sb.ToString());
return tokens;
}
private async Task<string> BuildMcpRuntimeStatusTextAsync(
IEnumerable<McpServerEntry>? source = null,
bool runtimeCheck = true,
CancellationToken ct = default)
{
var servers = (source ?? _settings.Settings.Llm.McpServers ?? []).ToList();
if (servers.Count == 0)
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 /mcp 를 다시 실행하세요.";
var lines = new List<string>
{
"MCP 상태",
$"- 전체: {servers.Count}개",
$"- 활성(세션 기준): {servers.Count(IsMcpServerEnabled)}개"
};
foreach (var server in servers.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : "";
if (!IsMcpServerEnabled(server))
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
if (!runtimeCheck)
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected)
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
var toolCount = client.Tools.Count;
var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount);
lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}");
}
lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>");
return string.Join("\n", lines);
}
internal static string ResolveMcpDisplayStatus(bool isEnabled, string transport, bool runtimeCheck, bool connected, int? toolCount)
{
var normalizedTransport = string.IsNullOrWhiteSpace(transport) ? "stdio" : transport.Trim().ToLowerInvariant();
if (!isEnabled)
return "Disabled";
if (!runtimeCheck)
return "Enabled";
if (!string.Equals(normalizedTransport, "stdio", StringComparison.OrdinalIgnoreCase))
return "Configured";
if (!connected)
return "Disconnected";
if ((toolCount ?? 0) <= 0)
return "NeedsAuth (도구 0개)";
return $"Connected (도구 {toolCount}개)";
}
private string ResolveMcpServerName(IEnumerable<McpServerEntry> servers, string inputName)
{
var trimmed = inputName.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return "";
var exact = servers.FirstOrDefault(s => string.Equals(s.Name, trimmed, StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact.Name;
var partial = servers.FirstOrDefault(s => (s.Name?.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0);
return partial?.Name ?? "";
}
private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server)
{
var clone = new McpServerEntry
{
Name = server.Name,
Command = server.Command,
Args = server.Args?.ToList() ?? new List<string>(),
Env = new Dictionary<string, string>(server.Env ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
Enabled = server.Enabled,
Transport = server.Transport,
Url = server.Url,
};
var key = clone.Name ?? "";
if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token))
clone.Env["MCP_AUTH_TOKEN"] = token;
return clone;
}
private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
{
var llm = _settings.Settings.Llm;
llm.McpServers ??= new List<McpServerEntry>();
var servers = llm.McpServers;
var (action, target) = ParseMcpAction(displayText);
if (action == "help")
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
if (action == "status")
return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
if (action == "reset")
{
var changed = _sessionMcpEnabledOverrides.Count;
_sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "login")
{
var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target);
if (!ok)
return error;
var resolved = ResolveMcpServerName(servers, serverTarget);
if (string.IsNullOrWhiteSpace(resolved))
return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}";
_sessionMcpAuthTokens[resolved] = token;
return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
}
if (action == "logout")
{
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
var removed = _sessionMcpAuthTokens.Count;
_sessionMcpAuthTokens.Clear();
return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"로그아웃 대상 서버를 찾지 못했습니다: {target}";
var removedOne = _sessionMcpAuthTokens.Remove(resolved);
return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "add")
{
var (ok, entry, error) = ParseMcpAddTarget(target);
if (!ok || entry == null)
return error;
var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase));
if (duplicate)
return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요.";
servers.Add(entry);
ScheduleSettingsSave();
_sessionMcpEnabledOverrides.Remove(entry.Name);
return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (servers.Count == 0)
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요.";
if (action == "remove")
{
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
var removed = servers.Count;
servers.Clear();
_sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
ScheduleSettingsSave();
return $"MCP 서버를 모두 제거했습니다. ({removed}개)";
}
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"제거 대상 서버를 찾지 못했습니다: {target}";
var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
_sessionMcpEnabledOverrides.Remove(resolved);
_sessionMcpAuthTokens.Remove(resolved);
ScheduleSettingsSave();
return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action is "enable" or "disable")
{
var newEnabled = action == "enable";
int changed;
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
changed = 0;
foreach (var server in servers.Where(s => IsMcpServerEnabled(s) != newEnabled))
{
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
changed++;
}
}
else
{
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"대상 서버를 찾지 못했습니다: {target}";
var server = servers.First(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
changed = IsMcpServerEnabled(server) == newEnabled ? 0 : 1;
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
}
var status = newEnabled ? "활성화" : "비활성화";
return $"MCP 서버 {status} 완료 ({changed}개 변경, 현재 세션에만 적용)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "reconnect")
{
List<McpServerEntry> targetServers;
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
targetServers = servers.Where(IsMcpServerEnabled).ToList();
}
else
{
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"재연결 대상 서버를 찾지 못했습니다: {target}";
targetServers = servers.Where(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (targetServers.Count == 0)
return "재연결할 활성 MCP 서버가 없습니다.";
return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false);
}
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
}
private string BuildSlashHeapDumpText()
{
var proc = System.Diagnostics.Process.GetCurrentProcess();
var managedBytes = GC.GetTotalMemory(forceFullCollection: false);
var workingSet = proc.WorkingSet64;
var privateBytes = proc.PrivateMemorySize64;
return
$"메모리 진단 (heapdump)\n" +
$"- Managed Heap: {managedBytes / 1024.0 / 1024.0:F1} MB\n" +
$"- Working Set: {workingSet / 1024.0 / 1024.0:F1} MB\n" +
$"- Private Bytes: {privateBytes / 1024.0 / 1024.0:F1} MB\n" +
$"- GC Gen0/1/2: {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}";
}
private string CyclePassPreset()
{
var llm = _settings.Settings.Llm;
var current = llm.MaxAgentIterations <= 16 ? 16 : llm.MaxAgentIterations <= 25 ? 25 : 40;
var next = current switch
{
16 => 25,
25 => 40,
_ => 16,
};
llm.MaxAgentIterations = next;
ScheduleSettingsSave();
return $"Agent Pass 프리셋을 {next}로 변경했습니다.";
}
private string BuildThinkbackSummaryText()
{
var conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0)
return "회고할 대화가 없습니다.";
var recent = conv.Messages
.Where(m => m.Role is "user" or "assistant")
.TakeLast(10)
.ToList();
if (recent.Count == 0)
return "회고할 대화가 없습니다.";
var userCount = recent.Count(m => m.Role == "user");
var assistantCount = recent.Count(m => m.Role == "assistant");
var latestRun = _appState.GetLatestConversationRun(conv.AgentRunHistory);
var highlights = recent
.TakeLast(4)
.Select(m => $"- {m.Role}: {TruncateForStatus((m.Content ?? "").Replace("\r", " ").Replace("\n", " "), 80)}");
return
$"최근 대화 회고\n" +
$"- 최근 메시지: {recent.Count}개 (user {userCount}, assistant {assistantCount})\n" +
$"- 최근 실행 상태: {(latestRun == null ? " " : $"{latestRun.Status} · {TruncateForStatus(latestRun.Summary, 40)}")}\n" +
string.Join("\n", highlights);
}
private string BuildThinkbackPlayText()
{
var conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0)
return "재생할 대화 이력이 없습니다.";
var lastUser = conv.Messages.LastOrDefault(m => m.Role == "user")?.Content ?? "(없음)";
var lastAssistant = conv.Messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "(없음)";
var folder = GetCurrentWorkFolder();
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
return
$"회고 기반 실행 플랜\n" +
$"1. 마지막 사용자 요청 재확인: {TruncateForStatus(lastUser.Replace('\n', ' '), 72)}\n" +
$"2. 마지막 응답의 보강 포인트 점검: {TruncateForStatus(lastAssistant.Replace('\n', ' '), 72)}\n" +
$"3. 작업 폴더 기준 실행/검증: {TruncateForStatus(folderText, 52)}\n" +
$"4. 부족한 근거/테스트 보강 후 다음 답변 생성";
}
private void OpenSkillsFromSlash()
{
var llm = _settings.Settings.Llm;
if (!llm.EnableSkillSystem)
{
llm.EnableSkillSystem = true;
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders);
UpdateConditionalSkillActivation(reset: true);
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
}
OpenCommandSkillBrowser("/");
}
private string TogglePermissionModeFromSlash()
{
var llm = _settings.Settings.Llm;
llm.FilePermission = NextPermission(llm.FilePermission);
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
return PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
}
private bool PromptRenameConversationFromSlash(out string renamedTitle)
{
renamedTitle = "";
ChatConversation? conv;
lock (_convLock)
{
_currentConversation ??= ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab };
conv = _currentConversation;
}
var currentTitle = string.IsNullOrWhiteSpace(conv.Title) ? "새 대화" : conv.Title;
var dlg = new Views.InputDialog("대화 이름 변경", "새 대화 이름:", currentTitle) { Owner = this };
if (dlg.ShowDialog() != true)
return false;
var newTitle = dlg.ResponseText.Trim();
if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, currentTitle, StringComparison.Ordinal))
return false;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
conv = _currentConversation;
}
else if (conv != null)
{
conv.Title = newTitle;
_storage.Save(conv);
}
}
SaveLastConversations();
UpdateChatTitle();
RefreshConversationList();
renamedTitle = newTitle;
return true;
}
private async Task SendMessageAsync(string? directText = null)
{
var useComposerInput = directText == null;
var rawText = (directText ?? InputBox.Text).Trim();
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
var text = useComposerInput && _slashPalette.ActiveCommand != null
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
: rawText;
if (useComposerInput)
HideSlashChip(restoreText: false);
if (string.IsNullOrEmpty(text)) return;
// 스트리밍 가드: 이전 세션에서 정리되지 않은 고착 상태 감지 및 자동 복구
if (_streamingTabs.Contains(_activeTab))
{
// CTS가 없거나 이미 취소/해제된 경우 → 고착 상태 → 강제 정리 후 진행
if (!_tabStreamCts.TryGetValue(_activeTab, out var existingCts)
|| existingCts.IsCancellationRequested)
{
Services.LogService.Warn($"고착된 스트리밍 상태 감지 (탭={_activeTab}). 강제 정리합니다.");
ResetStreamingUiState(_activeTab);
}
else
{
// 실제로 스트리밍 중 → 전송 차단
return;
}
}
// placeholder 정리
ClearPromptCardPlaceholder();
// 슬래시 명령어 처리
var (slashSystem, displayText) = await ParseSlashCommandAsync(text, CancellationToken.None);
if (slashSystem == null
&& _settings.Settings.Llm.EnableSkillSystem
&& !text.TrimStart().StartsWith("/", StringComparison.Ordinal))
{
EnsureSkillSystemLoadedForCurrentWorkspace();
var autoSkillPrompt = await SkillService.BuildProactiveSkillSystemPromptAsync(displayText, _activeTab, GetCurrentWorkFolder(), CancellationToken.None);
if (!string.IsNullOrWhiteSpace(autoSkillPrompt))
slashSystem = autoSkillPrompt;
}
if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/clear", "현재 대화를 정리하고 새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__NEW__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/new", "새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__RESET__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/reset", "현재 세션 컨텍스트를 초기화하고 새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__STATUS__", StringComparison.Ordinal))
{
var (statusAction, _) = ParseGenericAction(displayText ?? "", "/status");
if (statusAction is "test" or "diag" or "connection" or "connect")
{
var diagnosis = await BuildLlmRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/status", diagnosis);
return;
}
AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText() + "\n(연결 점검: /status test)");
return;
}
if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
{
var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
if (TryApplyPermissionModeFromAction(permAction, out var appliedMode))
{
AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(appliedMode)}({appliedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
return;
}
if (permAction == "status")
{
AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|accept|plan|bypass|status");
return;
}
OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|accept|plan|bypass|status");
return;
}
if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
{
var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools");
if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode))
{
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(allowedMode)}({allowedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
return;
}
if (toolAction == "status")
{
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|accept|plan|bypass|status");
return;
}
OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|accept|plan|bypass|status");
return;
}
if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal))
{
BtnModelSelector_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, "/model", "모델 선택 패널을 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__SETTINGS__", StringComparison.Ordinal))
{
var (settingsAction, _) = ParseGenericAction(displayText ?? "", "/settings");
if (settingsAction == "model")
{
BtnModelSelector_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, "/settings", "모델 선택 패널을 열었습니다.");
return;
}
if (settingsAction == "permissions")
{
OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions");
return;
}
if (settingsAction == "mcp")
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. MCP 항목에서 서버를 관리하세요.");
return;
}
if (settingsAction == "theme")
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. 테마 항목을 확인하세요.");
return;
}
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. (사용법: /settings model|permissions|mcp|theme)");
return;
}
if (string.Equals(slashSystem, "__THEME__", StringComparison.Ordinal))
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/theme", "설정 창을 열었습니다. AX Agent 테마를 변경할 수 있습니다.");
return;
}
if (string.Equals(slashSystem, "__STATS__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/stats", BuildSlashStatsText());
return;
}
if (string.Equals(slashSystem, "__COST__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/cost", BuildSlashCostText());
return;
}
if (string.Equals(slashSystem, "__EXPORT__", StringComparison.Ordinal))
{
ExportConversation();
AppendLocalSlashResult(_activeTab, "/export", "현재 대화를 내보냈습니다.");
return;
}
if (string.Equals(slashSystem, "__STATUSLINE__", StringComparison.Ordinal))
{
var line = BuildSlashStatuslineText();
SetStatus(line, spinning: false);
AppendLocalSlashResult(_activeTab, "/statusline", line);
return;
}
if (string.Equals(slashSystem, "__HEAPDUMP__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/heapdump", BuildSlashHeapDumpText());
return;
}
if (string.Equals(slashSystem, "__PASSES__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/passes", CyclePassPreset());
return;
}
if (string.Equals(slashSystem, "__CHROME__", StringComparison.Ordinal))
{
var chromeInput = (displayText ?? "").Trim();
var hasArgs = !string.IsNullOrWhiteSpace(chromeInput)
&& !string.Equals(chromeInput, "/chrome", StringComparison.OrdinalIgnoreCase);
if (!hasArgs)
{
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/chrome", diagnosis);
return;
}
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
AppendLocalSlashResult(_activeTab, "/chrome",
"브라우저 제어 실행은 Cowork/Code 탭에서 지원됩니다. 탭 전환 후 다시 실행해 주세요.\n" +
$"예: /chrome {chromeInput}");
return;
}
var probe = await ProbeChromeToolingAsync();
if (!probe.Ready)
{
var reconnectResult = await HandleMcpSlashAsync("/mcp reconnect all");
probe = await ProbeChromeToolingAsync();
if (probe.Ready)
{
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
text = chromeInput;
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비(재연결 성공)", spinning: false);
AppendLocalSlashResult(_activeTab, "/chrome", "사전 점검에서 연결이 부족해 /mcp reconnect all을 자동 실행했고 재시도에 성공했습니다.");
goto CHROME_ROUTING_READY;
}
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/chrome",
"브라우저 MCP 도구가 준비되지 않아 실행을 시작하지 못했습니다.\n" +
"자동 재시도(/mcp reconnect all) 결과:\n" + reconnectResult + "\n\n" + diagnosis);
return;
}
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
text = chromeInput;
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비", spinning: false);
CHROME_ROUTING_READY:;
}
if (string.Equals(slashSystem, "__MCP__", StringComparison.Ordinal))
{
var mcpResult = await HandleMcpSlashAsync(displayText ?? "");
AppendLocalSlashResult(_activeTab, "/mcp", mcpResult);
return;
}
if (string.Equals(slashSystem, "__STICKERS__", StringComparison.Ordinal))
{
var queueSummary = _appState.GetDraftQueueSummary(_activeTab);
var activeCount = _appState.ActiveTasks.Count;
AppendLocalSlashResult(_activeTab, "/stickers",
"빠른 상태 스티커\n" +
$"- [RUN] 진행중 작업 {activeCount}\n" +
$"- [QUEUE] 대기 {queueSummary.QueuedCount}\n" +
$"- [BLOCK] 승인 대기 {queueSummary.BlockedCount}\n" +
"- [DONE] 완료 후 /statusline 으로 상태 확인");
return;
}
if (string.Equals(slashSystem, "__THINKBACK__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/thinkback", BuildThinkbackSummaryText());
return;
}
if (string.Equals(slashSystem, "__THINKBACK_PLAY__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/thinkback-play", BuildThinkbackPlayText());
return;
}
if (string.Equals(slashSystem, "__SKILLS__", StringComparison.Ordinal))
{
OpenSkillsFromSlash();
AppendLocalSlashResult(_activeTab, "/skills", "스킬 브라우저를 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal))
{
var mode = TogglePermissionModeFromSlash();
AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(mode)}({mode})로 변경했습니다.");
return;
}
if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal))
{
if (PromptRenameConversationFromSlash(out var renamedTitle))
AppendLocalSlashResult(_activeTab, "/rename", $"대화 이름을 \"{renamedTitle}\"로 변경했습니다.");
else
AppendLocalSlashResult(_activeTab, "/rename", "이름 변경을 취소했습니다.");
return;
}
if (string.Equals(slashSystem, "__FEEDBACK__", StringComparison.Ordinal))
{
ChatConversation? currentConv;
lock (_convLock)
currentConv = _currentConversation;
var hasAssistant = currentConv?.Messages.Any(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) == true;
if (!hasAssistant)
{
AppendLocalSlashResult(_activeTab, "/feedback", "피드백할 이전 응답이 없습니다. 먼저 대화를 진행한 뒤 다시 시도하세요.");
return;
}
ShowRetryWithFeedbackInput();
AppendLocalSlashResult(_activeTab, "/feedback", "수정 피드백 입력 패널을 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__VERIFY__", StringComparison.Ordinal))
{
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
AppendLocalSlashResult(_activeTab, "/verify", "검증 모드는 Cowork/Code 탭에서만 실행할 수 있습니다.");
return;
}
slashSystem = BuildVerifySystemPrompt(displayText ?? "");
text = string.IsNullOrWhiteSpace(displayText) ? "현재 변경사항 검증 실행" : displayText;
SetStatus("검증 모드 실행 준비...", spinning: false);
}
if (string.Equals(slashSystem, "__COMMIT__", StringComparison.Ordinal))
{
var commitResult = await ExecuteCommitWithApprovalAsync(displayText, _tabStreamCts.TryGetValue(_activeTab, out var commitCts) ? commitCts.Token : CancellationToken.None);
AppendLocalSlashResult(_activeTab, "/commit", commitResult);
return;
}
if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal))
{
await ExecuteManualCompactAsync("/compact", _activeTab);
return;
}
// Cowork/Code 탭: 작업 폴더 미선택 시 실행 차단
// 대화 자체에 WorkFolder가 설정되어 있는지 확인 (전역 설정 fallback은 사용하지 않음)
if (_activeTab is "Cowork" or "Code")
{
string? convWorkFolder;
lock (_convLock) convWorkFolder = _currentConversation?.WorkFolder;
if (string.IsNullOrWhiteSpace(convWorkFolder) || !System.IO.Directory.Exists(convWorkFolder))
{
HighlightFolderSelectButton();
return;
}
}
// Code 탭 + 읽기 전용 권한: 코드 개발이 어려우므로 사용자에게 확인
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
var currentPerm = Services.Agent.PermissionModeCatalog.NormalizeGlobalMode(
_settings.Settings.Llm.FilePermission);
if (string.Equals(currentPerm, Services.Agent.PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase))
{
var result = MessageBox.Show(
"현재 읽기 전용 모드입니다.\n코드 개발 시 파일 수정이 필요할 수 있어 작업이 제한됩니다.\n\n그래도 진행하시겠습니까?",
"읽기 전용 권한 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes)
return;
}
}
// 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처
var originTab = _activeTab;
var runTab = originTab;
var queuedDraftId = _runningDraftId;
var draftSucceeded = false;
var draftCancelled = false;
string? draftFailure = null;
var lastAutoSaveUtc = DateTime.UtcNow;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
// 첨부 파일 경로를 사용자 메시지에 명시 (대화 기록에서 파일 참조가 보이도록)
var displayText2 = text;
if (_attachedFiles.Count > 0)
{
var fileLines = string.Join("\n", _attachedFiles.Select(f => $"📎 {f}"));
displayText2 = $"{fileLines}\n\n{text}";
}
var userMsg = new ChatMessage { Role = "user", Content = displayText2 };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
if (conv.Messages.Count(m => m.Role == "user") == 1)
conv.Title = text.Length > 30 ? text[..30] + "…" : text;
}
}
// ── UI 먼저 갱신: 사용자 메시지를 즉시 화면에 표시 ──
Services.LogService.Info($"[SendMsg] 사용자 메시지 전송: tab={runTab}, msgCount={conv.Messages.Count}, convId={conv.Id?[..Math.Min(8, conv.Id?.Length ?? 0)]}");
UpdateChatTitle();
if (useComposerInput)
{
InputBox.Text = "";
UpdateInputBoxHeight();
}
EmptyState.Visibility = Visibility.Collapsed;
StopMascotAnimation();
InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장
try
{
RenderMessages(preserveViewport: false); // 사용자 메시지 전송 시 스크롤 하단 이동 포함 재렌더
}
catch (Exception renderEx)
{
Services.LogService.Error($"[SendMsg] RenderMessages 실패: {renderEx.Message}\n{renderEx.StackTrace}");
}
// RenderMessages가 EmptyState를 Visible로 되돌릴 수 있으므로 강제 재설정
EmptyState.Visibility = Visibility.Collapsed;
// ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ──
var convForPersist = conv;
_ = Dispatcher.InvokeAsync(() =>
{
try
{
SaveLastConversations();
PersistConversationSnapshot(originTab, convForPersist, "대화 중간 저장 실패");
}
catch { /* 초기 저장 실패는 무시 — 중간 저장에서 재시도 */ }
}, System.Windows.Threading.DispatcherPriority.Background);
// 대화 통계 기록
Services.UsageStatisticsService.RecordChat(runTab);
ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동
PlayRainbowGlow(); // 무지개 글로우 애니메이션
// 스트리밍 상태를 미리 설정 — PrepareExecution/CondenseAsync 비동기 대기 중
// EmptyState가 다시 Visible로 되돌려지는 것을 방지합니다.
// catch 블록에서 ResetStreamingUiState가 호출되므로 예외 시에도 정리됩니다.
_streamingTabs.Add(runTab);
ViewModel.IsStreaming = true;
Services.LogService.Info($"[SendMsg] 스트리밍 준비 시작: tab={runTab}");
// 스트리밍 중 불필요한 타이머 일시 정지 — UI 스레드 부하 대폭 감소
_gitRefreshTimer.Stop();
_conversationSearchTimer.Stop();
_responsiveLayoutTimer.Stop();
_tokenUsagePopupCloseTimer.Stop();
SetStatus("응답 준비 중...", spinning: true);
// 준비 단계(Prepare/Condense)에서도 취소할 수 있도록 CTS를 미리 생성
// ExecutePreparedTurnAsync 진입 시 새 CTS로 교체됩니다.
var prepareCts = new CancellationTokenSource();
_tabStreamCts[runTab] = prepareCts;
var assistantContent = string.Empty;
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
ModelRouteResult? routeResult = null;
try
{
List<ChatMessage> sendMessages;
// 첨부 파일 컨텍스트 삽입
string? fileContext = null;
if (_attachedFiles.Count > 0)
{
fileContext = BuildFileContextPrompt();
if (!string.IsNullOrEmpty(fileContext))
{
// 전송용 메시지에는 엔진이 적용하고, 원본 대화에는 첨부 기록만 남김
}
userMsg.AttachedFiles = _attachedFiles.ToList();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
}
// ── 이미지 첨부 ──
List<ImageAttachment>? outboundImages = null;
if (_pendingImages.Count > 0)
{
userMsg.Images = _pendingImages.ToList();
outboundImages = _pendingImages.ToList();
_pendingImages.Clear();
AttachedFilesPanel.Items.Clear();
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
}
// 시스템 프롬프트 빌드는 디스크 I/O(프로젝트 규칙, 메모리, 피드백)를 포함하므로
// UI 스레드 블로킹 방지를 위해 백그라운드에서 실행
var capturedConv = conv;
var capturedSlashSystem = slashSystem;
var capturedFileContext = fileContext;
var capturedImages = outboundImages;
var capturedRunTab = runTab;
var preparedExecution = await Task.Run(() =>
PrepareExecutionForConversation(
capturedConv,
capturedRunTab,
capturedSlashSystem,
capturedFileContext,
capturedImages));
var executionMode = preparedExecution.Mode;
sendMessages = preparedExecution.Messages;
// ── 전송 전 컨텍스트 사전 압축 ──
{
var llm = _settings.Settings.Llm;
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
sendMessages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
_tabStreamCts.TryGetValue(runTab, out var compactCts) ? compactCts.Token : CancellationToken.None);
if (compactResult.Changed)
{
RecordCompactionStats(compactResult, wasAutomatic: true);
SetStatus($"컨텍스트를 사전 정리했습니다 · {compactResult.BeforeTokens:N0} → {compactResult.AfterTokens:N0} tokens", spinning: true);
RefreshContextUsageVisual();
}
}
// ── 자동 모델 라우팅 ──
if (_settings.Settings.Llm.EnableAutoRouter)
{
routeResult = _router.Route(text);
if (routeResult != null)
{
_llm.PushRouteOverride(routeResult.Service, routeResult.Model);
SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true);
}
}
var executionOutcome = await ExecutePreparedTurnAsync(
conv,
runTab,
originTab,
preparedExecution,
"응답 생성 중...");
conv = executionOutcome.Conversation;
assistantContent = executionOutcome.AssistantContent;
draftSucceeded = executionOutcome.DraftSucceeded;
draftCancelled = executionOutcome.DraftCancelled;
draftFailure = executionOutcome.DraftFailure;
}
catch (OperationCanceledException)
{
// 사용자가 준비 단계에서 취소 → 조용히 정리
ResetStreamingUiState(runTab);
draftCancelled = true;
}
catch (Exception ex)
{
// PrepareExecution / CondenseAsync 단계에서 예외 발생 시
// ExecutePreparedTurnAsync에 진입하지 못해 ResetStreamingUiState가 호출되지 않음
// → 스트리밍 상태 정리 + 에러 표시
Services.LogService.Error($"[SendMsg] 메시지 전송 중 오류: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
ResetStreamingUiState(runTab);
ShowToast($"오류: {ex.Message}", "\uE783", 3500);
draftFailure = ex.Message;
}
finally
{
// 자동 라우팅 오버라이드 해제
if (routeResult != null)
{
_llm.ClearRouteOverride();
UpdateModelLabel();
}
// 준비 단계 CTS 정리 (ExecutePreparedTurnAsync가 새 CTS로 교체했거나, 예외로 미진입)
prepareCts.Dispose();
// 중단된 타이머 복원 (스트리밍 상태와 무관하게 항상)
if (!_isStreaming)
{
_gitRefreshTimer.Start();
_conversationSearchTimer.Start();
_responsiveLayoutTimer.Start();
}
}
FinalizeQueuedDraft(originTab, queuedDraftId, draftSucceeded, draftCancelled, draftFailure);
}
private AgentLoopService CreateAgentLoopForTab(string tab, SettingsService settings)
{
return new AgentLoopService(_llm, _toolRegistry, settings)
{
Dispatcher = action =>
{
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
// Background 우선순위로 UI 입력/렌더를 먼저 흘려보내고 에이전트 이벤트를 비동기 전달.
appDispatcher.BeginInvoke(action, System.Windows.Threading.DispatcherPriority.Background);
},
AskPermissionCallback = async (toolName, filePath) =>
{
_agentLoops.TryGetValue(tab, out var tabLoop);
var wsFolder = tabLoop?.RuntimeWorkFolderOverride;
if (string.IsNullOrWhiteSpace(wsFolder))
wsFolder = _currentConversation?.WorkFolder ?? "";
var resolvedTarget = NormalizePermissionTarget(toolName, filePath, wsFolder);
if (IsPermissionAutoApprovedForSession(tab, toolName, resolvedTarget))
return true;
PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject;
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
await appDispatcher.InvokeAsync(() =>
{
AgentLoopService.PermissionPromptPreview? preview = null;
if (tabLoop != null &&
tabLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
preview = pendingPreview;
// 사내모드 + 워크스페이스 외부 경로 접근 시 안내 문구 표시
string? notice = null;
if (Services.OperationModePolicy.IsInternal(_settings.Settings)
&& !string.IsNullOrWhiteSpace(filePath))
{
if (!string.IsNullOrEmpty(wsFolder) && IsPathOutsideFolder(filePath, wsFolder))
notice = "사내 모드에서는 지정된 경로 외의 접근 시 무조건 승인이 필요합니다.";
}
decision = PermissionRequestWindow.Show(this, toolName, resolvedTarget, preview, notice);
});
if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession)
RememberPermissionRuleForSession(tab, toolName, resolvedTarget);
return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
},
UserAskCallback = ShowInlineUserAskAsync,
};
}
private AgentLoopService GetAgentLoop(string tab) =>
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.FirstOrDefault()
?? throw new InvalidOperationException($"No agent loop registered for tab '{tab}'");
private async Task<string> RunAgentLoopAsync(
string runTab,
string originTab,
ChatConversation conversation,
IReadOnlyList<ChatMessage> sendMessages,
CancellationToken cancellationToken)
{
OpenWorkflowAnalyzerIfEnabled();
_tabCumulativeInputTokens[runTab] = 0;
_tabCumulativeOutputTokens[runTab] = 0;
ResetPermissionRulesForRun(runTab);
var loop = GetAgentLoop(runTab);
// 클로저로 runTab 캡처 — 동시에 여러 탭이 실행될 때도 이벤트가 올바른 탭에 귀속됨
void agentEventHandler(AgentEvent evt) => OnAgentEvent(evt, runTab);
loop.EventOccurred += agentEventHandler;
loop.UserDecisionCallback = CreatePlanDecisionCallback();
// 채팅창 내 라이브 진행 카드 표시
ShowAgentLiveCard(runTab);
try
{
loop.ActiveTab = runTab;
loop.RuntimeWorkFolderOverride = conversation.WorkFolder;
// 에이전트 루프를 백그라운드 스레드에서 실행 — UI 스레드 블록 방지
// RunAsync 내부의 _llm.SendAsync가 SynchronizationContext로 UI 스레드에
// 복귀할 때 WPF 메시지 펌프를 독점하여 창 드래그/최소화가 안 되는 문제 해결
var msgList = sendMessages.ToList();
var response = await Task.Run(() => loop.RunAsync(msgList, cancellationToken));
if (_settings.Settings.Llm.NotifyOnComplete)
{
var title = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? "AX Code Agent"
: "AX Cowork Agent";
var message = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? "코드 작업이 완료되었습니다."
: "코워크 작업이 완료되었습니다.";
Services.NotificationService.Notify(title, message);
}
// 코워크 완료 후 문서 자동 처리 (설정에 따라)
if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase))
{
var onComplete = (_settings.Settings.Llm.CoworkOnComplete ?? "none").Trim().ToLowerInvariant();
if (onComplete != "none")
{
var lastFilePath = loop.Events
.Where(ev => ev.Success && !string.IsNullOrWhiteSpace(ev.FilePath) && System.IO.File.Exists(ev.FilePath))
.Select(ev => ev.FilePath!)
.LastOrDefault();
if (!string.IsNullOrEmpty(lastFilePath))
{
await Dispatcher.InvokeAsync(() =>
{
if (onComplete == "open")
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(lastFilePath) { UseShellExecute = true }); } catch { }
}
else if (onComplete == "preview")
{
ShowPreviewPanel(lastFilePath);
}
});
}
}
}
return response;
}
finally
{
ResetPermissionRulesForRun(runTab);
loop.RuntimeWorkFolderOverride = null;
loop.EventOccurred -= agentEventHandler;
loop.UserDecisionCallback = null;
}
}
private AxAgentExecutionEngine.PreparedExecution PrepareExecutionForConversation(
ChatConversation conversation,
string runTab,
string? slashSystem,
string? fileContext = null,
IReadOnlyList<ImageAttachment>? images = null)
{
var coworkSystem = string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)
? BuildCoworkSystemPrompt()
: null;
var codeSystem = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? BuildCodeSystemPrompt()
: null;
var (resolvedService, _) = _llm.GetCurrentModelInfo();
return _chatEngine.PrepareExecution(
conversation,
runTab,
_settings.Settings.Llm.Streaming,
resolvedService,
conversation.SystemCommand,
slashSystem,
coworkSystem,
codeSystem,
fileContext,
images);
}
private void ResetStreamingUiState(string? finishedTab = null)
{
var tab = finishedTab ?? _streamRunTab ?? "";
_streamingTabs.Remove(tab);
if (_tabStreamCts.Remove(tab, out var doneCts))
doneCts.Dispose();
_streamRunTab = _streamingTabs.Count > 0 ? _streamingTabs.First() : null;
// Cowork/Code 탭 작업 완료 시, 앱이 포커스 없으면 Windows 알림
NotifyTaskCompletionIfBackground(tab);
// 탭별로 타이머·글로우 등 공유 상태는 마지막 탭 완료 시에만 정리
if (_streamingTabs.Count == 0)
{
ViewModel.IsStreaming = false; // ViewModel 상태 동기화 (CanSend 등 파생 프로퍼티 갱신)
FlushPendingConversationPersists();
FlushPendingAgentUiEvent();
StopLiveAgentProgressHints();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_executionHistoryRenderTimer.Stop();
_pendingExecutionHistoryAutoScroll = false;
_taskSummaryRefreshTimer.Stop();
_conversationPersistTimer.Stop();
_agentUiEventTimer.Stop();
HideStickyProgress();
StopRainbowGlow();
ResetLiveProgressCardCache(); // 라이브 진행 카드 캐시 초기화
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_streamStartTime = default;
SetStatusIdle();
UpdateStreamMetricsLabel(); // 스트리밍 종료 시 메트릭 레이블 숨김
// 스트리밍 종료 — 캐시 무효화만 수행
// FinalizeConversationTurn()이 직후 RenderMessages()를 호출하므로 여기서는 렌더하지 않음
InvalidateTimelineCache();
// 지연된 레이아웃 갱신 수행
if (_pendingResponsiveLayoutRefresh)
{
_pendingResponsiveLayoutRefresh = false;
UpdateTopicPresetScrollMode();
UpdateResponsiveChatLayout();
}
}
RefreshStreamingControlsForActiveTab();
// 스트리밍 종료 후 EmptyState 일관성 강제 검사 — 메시지가 있으면 절대 보이지 않도록
EnsureEmptyStateConsistency();
}
/// <summary>앱이 포커스를 갖고 있지 않을 때 Cowork/Code 탭 작업 완료를 Windows 알림으로 표시합니다.</summary>
private void NotifyTaskCompletionIfBackground(string finishedTab)
{
// Chat 탭은 단순 대화이므로 알림 불필요
if (!string.Equals(finishedTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(finishedTab, "Code", StringComparison.OrdinalIgnoreCase))
return;
// 앱이 포커스 중이면 알림 불필요 (사용자가 이미 결과를 보고 있음)
if (IsActive) return;
// 대화 제목/미리보기를 알림 본문으로 사용
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
var title = conv?.Title?.Trim();
if (string.IsNullOrWhiteSpace(title) || string.Equals(title, "새 대화", StringComparison.OrdinalIgnoreCase))
title = finishedTab;
var preview = "";
if (conv?.Messages?.Count > 0)
{
var lastAssistant = conv.Messages.LastOrDefault(m =>
string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase));
if (lastAssistant != null)
{
preview = lastAssistant.Content?.Trim() ?? "";
if (preview.Length > 120) preview = preview[..120] + "…";
}
}
var body = string.IsNullOrWhiteSpace(preview)
? $"{finishedTab} 작업이 완료되었습니다."
: preview;
Services.NotificationService.Notify($"✅ {title}", body);
}
/// <summary>EmptyState 표시 상태가 대화 내용과 일치하는지 확인하고, 불일치 시 보정합니다.</summary>
private void EnsureEmptyStateConsistency()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
var hasMessages = (conv?.Messages?.Count ?? 0) > 0;
if (hasMessages && EmptyState.Visibility == Visibility.Visible)
{
HideEmptyState(animate: false);
InvalidateTimelineCache();
RenderMessages(preserveViewport: false);
}
else if (!hasMessages && !_isStreaming && EmptyState.Visibility != Visibility.Visible)
{
// 빈 대화인데 EmptyState가 숨겨져 있으면 표시 (탭 전환 레이스 보정)
ShowEmptyState();
}
}
private void FinalizeConversationTurn(string rememberTab, ChatConversation conversation)
{
bool isCurrentConversation;
lock (_convLock)
{
// 새 대화 시작 등으로 _currentConversation이 다른 대화로 변경된 경우,
// 이전 스트리밍 결과로 현재 대화를 덮어쓰지 않음.
isCurrentConversation = _currentConversation == null
|| string.Equals(_currentConversation.Id, conversation.Id, StringComparison.Ordinal);
if (isCurrentConversation)
_currentConversation = conversation;
}
if (isCurrentConversation)
{
RenderMessages(preserveViewport: true);
AutoScrollIfNeeded();
PersistConversationSnapshot(rememberTab, conversation, "대화 저장 실패");
SyncTabConversationIdsFromSession();
}
else
{
// 현재 대화가 변경된 경우에도 이전 대화 결과는 저장함 (UI 업데이트는 하지 않음)
try { _storage.Save(conversation); } catch { }
}
RefreshConversationList();
}
private void FinalizeQueuedDraft(string originTab, string? queuedDraftId, bool draftSucceeded, bool draftCancelled, string? draftFailure)
{
if (string.IsNullOrWhiteSpace(queuedDraftId))
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
if (draftSucceeded)
{
_draftQueueProcessor.Complete(session, originTab, queuedDraftId, _storage, _appState.TaskRuns);
_draftQueueProcessor.ClearCompleted(session, originTab, _storage); // 완료 항목 즉시 자동 제거
}
else
_draftQueueProcessor.HandleFailure(session, originTab, queuedDraftId, draftFailure, draftCancelled, 3, _storage, _appState.TaskRuns);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
if (string.Equals(_runningDraftId, queuedDraftId, StringComparison.OrdinalIgnoreCase))
_runningDraftId = null;
}
RefreshDraftQueueUi();
if (!draftCancelled && string.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase))
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background);
}
private sealed record PreparedTurnOutcome(
ChatConversation Conversation,
string AssistantContent,
bool DraftSucceeded,
bool DraftCancelled,
string? DraftFailure);
private async Task<PreparedTurnOutcome> ExecutePreparedTurnAsync(
ChatConversation conversation,
string runTab,
string rememberTab,
AxAgentExecutionEngine.PreparedExecution preparedExecution,
string busyStatus)
{
_streamingTabs.Add(runTab);
ViewModel.IsStreaming = true;
_streamRunTab = runTab;
var streamCts = new CancellationTokenSource();
_tabStreamCts[runTab] = streamCts;
var streamToken = streamCts.Token;
Services.LogService.Info($"[Exec] ExecutePreparedTurnAsync 진입: tab={runTab}, mode={preparedExecution.Mode}");
RefreshStreamingControlsForActiveTab();
StartLiveAgentProgressHints();
UpdateStreamMetricsLabel();
ForceScrollToEnd();
var assistantContent = string.Empty;
var draftSucceeded = false;
var draftCancelled = false;
string? draftFailure = null;
var responseElapsedMs = 0L;
var promptTokens = 0;
var completionTokens = 0;
string? assistantMetaRunId = null;
_activeStreamText = null;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus(busyStatus, spinning: true);
StackPanel? streamingContainer = null;
TextBlock? streamingText = null;
try
{
if (!preparedExecution.Mode.UseAgentLoop && preparedExecution.Mode.UseStreamingTransport)
{
streamingContainer = CreateStreamingContainer(out var createdStreamText);
streamingText = createdStreamText;
_activeStreamText = streamingText;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
AddTranscriptElement(streamingContainer);
ForceScrollToEnd();
_cursorTimer.Start();
_typingTimer.Start();
ShowStreamingStatusBar("생각하는 중...");
// Bug fix: 스트리밍 루프를 백그라운드 스레드에서 실행하여 UI 프리징 방지
// (Cowork/Code는 이미 Task.Run으로 실행 중이나, Chat은 UI 스레드에서 실행되고 있었음)
var streamSb = new System.Text.StringBuilder(4096);
var messagesList = preparedExecution.Messages.ToList();
var dispatcher = Dispatcher;
assistantContent = await Task.Run(async () =>
{
var lastSyncTick = Environment.TickCount64;
await foreach (var chunk in _llm.StreamAsync(messagesList, streamToken))
{
if (string.IsNullOrEmpty(chunk))
continue;
streamSb.Append(chunk);
var now = Environment.TickCount64;
if (now - lastSyncTick >= 30)
{
var snapshot = streamSb.ToString();
lastSyncTick = now;
_ = dispatcher.InvokeAsync(() =>
{
_cachedStreamContent = snapshot;
if (_activeStreamText != null && _displayedLength == 0)
_activeStreamText.Text = _cursorVisible ? "\u258c" : " ";
}, DispatcherPriority.Normal);
}
}
return streamSb.ToString();
}, streamToken).ConfigureAwait(true);
_cachedStreamContent = assistantContent;
}
else
{
var response = await _chatEngine.ExecutePreparedAsync(
preparedExecution,
(messages, token) => RunAgentLoopAsync(runTab, rememberTab, conversation, messages, token),
(messages, token) => _llm.SendAsync(messages.ToList(), token),
streamToken);
assistantContent = response ?? string.Empty;
}
responseElapsedMs = GetStreamingElapsedMsOrZero();
assistantMetaRunId = _appState.AgentRun.RunId;
var usage = _llm.LastTokenUsage;
if (usage != null)
{
var cumIn = _tabCumulativeInputTokens.GetValueOrDefault(runTab);
var cumOut = _tabCumulativeOutputTokens.GetValueOrDefault(runTab);
if (runTab is "Cowork" or "Code" && (cumIn > 0 || cumOut > 0))
{
promptTokens = (int)Math.Max(0, cumIn);
completionTokens = (int)Math.Max(0, cumOut);
}
else
{
promptTokens = Math.Max(0, usage.PromptTokens);
completionTokens = Math.Max(0, usage.CompletionTokens);
}
}
StopAiIconPulse();
_cachedStreamContent = assistantContent;
draftSucceeded = true;
if (streamingContainer != null && streamingText != null)
{
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
}
else if (preparedExecution.Mode.UseAgentLoop)
{
// Claude Code 스타일: 에이전트 루프 완료 시 assistant 말풍선 표시하지 않음
// 실행이력 + 완료 요약 카드만 표시 (사용자 입력만 말풍선으로)
RemoveAgentLiveCard();
}
else
{
RemoveAgentLiveCard();
}
}
catch (OperationCanceledException)
{
RemoveAgentLiveCard();
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, cancelled: true);
assistantContent = finalized.Content;
draftCancelled = finalized.Cancelled;
draftFailure = finalized.FailureReason;
if (streamingContainer != null && streamingText != null)
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
}
catch (Exception ex)
{
RemoveAgentLiveCard();
Services.LogService.Debug($"에이전트 실행 실패: {ex}");
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, ex);
assistantContent = finalized.Content;
draftFailure = finalized.FailureReason;
ShowToast("실패한 요청은 작업 요약에서 다시 시도할 수 있습니다.", "\uE783", 2600);
if (streamingContainer != null && streamingText != null)
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
}
finally
{
ResetStreamingUiState(runTab);
}
lock (_convLock)
{
var session = ChatSession;
assistantContent = _chatEngine.FinalizeAssistantTurn(
session,
conversation,
runTab,
assistantContent,
promptTokens,
completionTokens,
responseElapsedMs,
assistantMetaRunId,
_storage);
_currentConversation = session?.CurrentConversation ?? conversation;
conversation = _currentConversation!;
}
// Bug fix: 스트리밍 컨테이너가 남아있으면 RenderMessages가 동일 메시지를 다시 렌더링하여
// 중복 버블이 발생함 → FinalizeConversationTurn 전에 스트리밍 컨테이너 제거
if (streamingContainer != null)
RemoveTranscriptElement(streamingContainer);
FinalizeConversationTurn(rememberTab, conversation);
return new PreparedTurnOutcome(conversation, assistantContent, draftSucceeded, draftCancelled, draftFailure);
}
private async Task ShowTypedAssistantPreviewAsync(string finalContent, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(finalContent))
return;
// 라이브 카드 제거 (최종 메시지 버블 표시 전)
RemoveAgentLiveCard();
var container = CreateStreamingContainer(out var streamText);
if (container == null || streamText == null)
return;
// 공유 필드 사용 금지 — 탭 동시 완료 시 서로 덮어쓰는 것을 막기 위해
// _activeStreamText / _cachedStreamContent / _displayedLength / 공유 타이머 미사용
// 모든 상태를 로컬 변수로 관리하고 await Task.Delay로 UI 스레드 양보
var displayedLength = 0;
var cursorVisible = true;
AddTranscriptElement(container);
ForceScrollToEnd();
streamText.Text = "\u258c";
var deadline = DateTime.UtcNow.AddMilliseconds(Math.Clamp(finalContent.Length * 18, 1400, 6500));
try
{
while (displayedLength < finalContent.Length && DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
{
await Task.Delay(20, ct);
var pending = finalContent.Length - displayedLength;
int step;
if (pending > 300) step = Math.Min(Math.Max(8, pending / 12), 18);
else if (pending > 120) step = Math.Min(Math.Max(5, pending / 14), 10);
else if (pending > 24) step = Math.Min(4, pending);
else step = Math.Min(2, pending);
displayedLength = Math.Min(displayedLength + step, finalContent.Length);
cursorVisible = !cursorVisible;
streamText.Text = finalContent[..displayedLength] + (cursorVisible ? "\u258c" : " ");
}
}
catch (OperationCanceledException)
{
return;
}
streamText.Text = finalContent;
FinalizeStreamingContainer(container, streamText, finalContent);
}
private void ScheduleExecutionHistoryRender(bool autoScroll = true)
{
_pendingExecutionHistoryAutoScroll |= autoScroll;
if (!IsLoaded || !IsVisible || WindowState == WindowState.Minimized)
{
_pendingHiddenExecutionHistoryRender = true;
return;
}
// 스트리밍 중 타이머가 이미 실행 중이면 재시작하지 않음 (Stop+Start 루프 방지)
// 이전에는 매 이벤트마다 Stop→Start로 타이머가 영원히 지연되는 문제가 있었음
if (_isStreaming && _executionHistoryRenderTimer.IsEnabled)
return;
_executionHistoryRenderTimer.Stop();
_executionHistoryRenderTimer.Start();
}
private void FlushDeferredUiRefreshIfNeeded()
{
if (!IsLoaded || !IsVisible || WindowState == WindowState.Minimized)
return;
if (_pendingHiddenExecutionHistoryRender)
{
_pendingHiddenExecutionHistoryRender = false;
_executionHistoryRenderTimer.Stop();
_executionHistoryRenderTimer.Start();
}
if (_pendingBackgroundTaskSummaryRefresh)
{
_pendingBackgroundTaskSummaryRefresh = false;
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Start();
}
if (_pendingBackgroundInputUiRefresh)
{
_pendingBackgroundInputUiRefresh = false;
_inputUiRefreshTimer.Stop();
_inputUiRefreshTimer.Start();
}
if (_pendingBackgroundAgentUiEventFlush)
{
_pendingBackgroundAgentUiEventFlush = false;
_agentUiEventTimer.Stop();
_agentUiEventTimer.Start();
}
}
private void ScheduleTaskSummaryRefresh()
{
if (IsBackgroundUiThrottleActive())
{
_pendingBackgroundTaskSummaryRefresh = true;
return;
}
if (IsLightweightLiveProgressMode() && _taskSummaryRefreshTimer.IsEnabled)
return;
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Start();
}
private void ScheduleConversationPersist(ChatConversation? conversation)
{
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
return;
_pendingConversationPersists[conversation.Id] = conversation;
_conversationPersistTimer.Stop();
_conversationPersistTimer.Start();
}
private void ScheduleAgentUiEvent(AgentEvent evt)
{
_pendingAgentUiEvent = evt;
if (IsBackgroundUiThrottleActive())
{
_pendingBackgroundAgentUiEventFlush = true;
return;
}
if (IsLightweightLiveProgressMode() && _agentUiEventTimer.IsEnabled)
return;
_agentUiEventTimer.Stop();
_agentUiEventTimer.Start();
}
private bool IsBackgroundUiThrottleActive()
{
if (!IsLoaded)
return true;
if (!IsVisible || WindowState == WindowState.Minimized)
return true;
return !IsActive;
}
private void FlushPendingAgentUiEvent()
{
var evt = _pendingAgentUiEvent;
_pendingAgentUiEvent = null;
if (evt == null)
return;
UpdateStatusBar(evt);
UpdateAgentProgressBar(evt);
if (evt.StepCurrent > 0 && evt.StepTotal > 0)
UpdatePlanViewerStep(evt);
if (evt.Type == AgentEventType.Complete)
CompletePlanViewer();
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath))
RefreshFileTreeIfVisible();
if (evt.Type == AgentEventType.ToolResult && evt.ToolName == "suggest_actions" && evt.Success)
RenderSuggestActionChips(evt.Summary);
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath) &&
(evt.Type == AgentEventType.ToolResult || evt.Type == AgentEventType.Complete) &&
WriteToolNames.Contains(evt.ToolName))
{
var autoPreview = _settings.Settings.Llm.AutoPreview;
if (autoPreview == "auto")
{
if (PreviewWindow.IsOpen)
PreviewWindow.RefreshIfOpen(evt.FilePath);
else
TryShowPreview(evt.FilePath);
}
}
}
private void PersistConversationSnapshot(string rememberTab, ChatConversation conversation, string failureLabel)
{
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
return;
_pendingConversationPersists.Remove(conversation.Id);
try
{
_storage.Save(conversation);
ChatSession?.RememberConversation(rememberTab, conversation.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"{failureLabel}: {ex.Message}");
}
}
private void FlushPendingConversationPersists()
{
if (_pendingConversationPersists.Count == 0)
return;
// 대화 저장(디스크 I/O)을 백그라운드로 이동하여 UI 스레드 블로킹 방지
var snapshot = _pendingConversationPersists.Values.ToList();
_pendingConversationPersists.Clear();
Task.Run(() =>
{
foreach (var conversation in snapshot)
{
try
{
_storage.Save(conversation);
_appState.ChatSession?.RememberConversation(conversation.Tab ?? "Chat", conversation.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"대화 지연 저장 실패: {ex.Message}");
}
}
});
}
/// <summary>워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다.</summary>
private void OpenWorkflowAnalyzerIfEnabled()
{
var llm = _settings.Settings.Llm;
if (!llm.WorkflowVisualizer) return;
if (_analyzerWindow == null)
{
// 새로 생성
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
// 테마 리소스 전달
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
// Hide()로 숨겨진 창 → 기존 내용 유지한 채 다시 표시
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
// 이미 보이는 상태 → 기존 이력 유지한 채 활성화 (사용자가 수동으로 초기화)
_analyzerWindow.Activate();
}
// 타임라인 탭으로 전환 (새 실행 시작)
_analyzerWindow.SwitchToTimelineTab();
// 모든 탭 루프에 구독 (중복 방지) — 워크플로우 분석기는 전 탭 이벤트를 표시
foreach (var loop in _agentLoops.Values)
{
loop.EventOccurred -= _analyzerWindow.OnAgentEvent;
loop.EventOccurred += _analyzerWindow.OnAgentEvent;
}
}
/// <summary>워크플로우 분석기 버튼의 표시 상태를 갱신합니다.</summary>
private void UpdateAnalyzerButtonVisibility()
{
var llm = _settings.Settings.Llm;
BtnShowAnalyzer.Visibility = llm.WorkflowVisualizer
? Visibility.Visible : Visibility.Collapsed;
}
private void SyncWorkflowVisualizerWindow()
{
UpdateAnalyzerButtonVisibility();
if (_settings.Settings.Llm.WorkflowVisualizer)
{
OpenWorkflowAnalyzerIfEnabled();
}
else if (_analyzerWindow != null && _analyzerWindow.IsVisible)
{
_analyzerWindow.Hide();
}
}
/// <summary>워크플로우 분석기 창을 수동으로 열거나 포커스합니다 (하단 바 버튼).</summary>
private void BtnShowAnalyzer_Click(object sender, MouseButtonEventArgs e)
{
if (_analyzerWindow == null)
{
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
// 에이전트 이벤트 구독 (모든 탭 루프)
foreach (var loop in _agentLoops.Values)
{
loop.EventOccurred -= _analyzerWindow.OnAgentEvent;
loop.EventOccurred += _analyzerWindow.OnAgentEvent;
}
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
_analyzerWindow.Activate();
}
}
/// <summary>탭별 에이전트 루프 누적 토큰 (하단 바 표시용)</summary>
private readonly Dictionary<string, long> _tabCumulativeInputTokens = new();
private readonly Dictionary<string, long> _tabCumulativeOutputTokens = new();
private static readonly HashSet<string> WriteToolNames = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "html_create", "xlsx_create",
"docx_create", "csv_create", "md_create", "script_create",
"diff_preview", "open_external",
};
private void OnAgentEvent(AgentEvent evt, string runTab)
{
TouchLiveAgentProgressHints();
var eventTab = runTab;
// V2 라이브 카드 실시간 업데이트
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)
&& IsAgentLiveCardEligibleTab(runTab))
{
EnsureAgentLiveCardVisible(runTab);
UpdateAgentLiveCardV2(evt);
}
// ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ───────────
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
{
if (evt.Type is AgentEventType.Complete or AgentEventType.Error)
{
HideStreamingStatusBar();
FlushPendingAgentUiEvent();
}
else if (PulseDotBar?.Visibility == Visibility.Visible)
{
var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab);
UpdateStreamingStatusBar(
liveStatus.Message,
detail: liveStatus.Detail,
subItemCategory: liveStatus.Category);
}
}
// 현재 실행 중인 스텝 추적 (통합 진행 카드용)
if (IsProcessFeedEvent(evt) && string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
{
_currentRunProgressSteps.Add(evt);
if (_currentRunProgressSteps.Count > 8)
_currentRunProgressSteps.RemoveAt(0);
}
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
var isActiveRunTab = string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase);
var lightweightLiveMode = isActiveRunTab
&& !shouldShowExecutionHistory
&& IsLightweightLiveProgressMode(eventTab);
var shouldRender = isActiveRunTab && (
shouldShowExecutionHistory
|| (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|| evt.Type == AgentEventType.Complete
|| evt.Type == AgentEventType.Error
|| (evt.Type == AgentEventType.ToolResult && evt.Success && IsDocumentCreationTool(evt.ToolName)));
EnqueueAgentEventWork(evt, eventTab, shouldRender);
// ── 3단계: 경량 상태 추적 (UI 스레드) ───────────────────────────────
_appState.ApplyAgentEvent(evt);
// 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
{
_tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens;
_tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens;
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]);
}
ScheduleAgentUiEvent(evt);
if (!lightweightLiveMode
|| evt.Type is AgentEventType.Complete
or AgentEventType.Error
or AgentEventType.Planning
or AgentEventType.PermissionRequest
or AgentEventType.ToolResult)
{
ScheduleTaskSummaryRefresh();
}
}
private void StartLiveAgentProgressHints()
{
_lastAgentProgressEventAt = DateTime.UtcNow;
_currentRunProgressSteps.Clear();
var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!;
if (IsAgentLiveCardEligibleTab(runTab))
{
EnsureAgentLiveCardVisible(runTab);
var initialStatus = AgentStatusNarrativeCatalog.BuildInitial(runTab);
UpdateLiveAgentProgressHint(initialStatus.Message, "agent_wait");
ShowStreamingStatusBar(initialStatus.Message, detail: initialStatus.Detail);
}
else
{
RemoveAgentLiveCard(animated: false);
UpdateLiveAgentProgressHint(null);
ShowStreamingStatusBar("생각하는 중...");
}
_agentProgressHintTimer.Stop();
_agentProgressHintTimer.Start();
}
private void StopLiveAgentProgressHints()
{
_agentProgressHintTimer.Stop();
UpdateLiveAgentProgressHint(null);
HideStreamingStatusBar();
}
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e)
{
var templates = _settings.Settings.Llm.PromptTemplates;
TemplateItems.Items.Clear();
var search = TemplateSearchBox?.Text?.Trim() ?? "";
if (templates == null || templates.Count == 0)
{
TemplateEmptyHint.Visibility = Visibility.Visible;
TemplatePopup.IsOpen = true;
return;
}
if (!string.IsNullOrWhiteSpace(search))
{
templates = templates
.Where(t => t.Name.Contains(search, StringComparison.OrdinalIgnoreCase)
|| t.Content.Contains(search, StringComparison.OrdinalIgnoreCase))
.ToList();
}
TemplateEmptyHint.Visibility = templates.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
if (templates.Count == 0)
{
TemplateEmptyHint.Text = "검색 결과가 없습니다.";
TemplatePopup.IsOpen = true;
return;
}
TemplateEmptyHint.Text = "등록된 템플릿이 없습니다. 설정에서 추가하세요.";
var favoriteSet = new HashSet<string>(_settings.Settings.Llm.FavoritePromptTemplates ?? [], StringComparer.OrdinalIgnoreCase);
var recentList = _settings.Settings.Llm.RecentPromptTemplates ?? [];
var recentRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < recentList.Count; i++)
{
var key = recentList[i]?.Trim();
if (!string.IsNullOrWhiteSpace(key) && !recentRank.ContainsKey(key))
recentRank[key] = i;
}
void AddSectionHeader(string title)
{
TemplateItems.Items.Add(new Border
{
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(10, 8, 10, 6),
Child = new TextBlock
{
Text = title,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
}
});
}
void AddTemplateRow(PromptTemplate tpl, bool isFavorite, bool isRecent)
{
var item = new Border
{
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(10, 10, 10, 10),
Margin = new Thickness(0, 0, 0, 0),
Cursor = System.Windows.Input.Cursors.Hand,
Tag = tpl.Content,
};
var stack = new StackPanel();
var titleRow = new StackPanel { Orientation = Orientation.Horizontal };
titleRow.Children.Add(new TextBlock
{
Text = tpl.Name,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = (Brush)FindResource("PrimaryText"),
});
if (isFavorite)
{
titleRow.Children.Add(new TextBlock
{
Text = " 고정",
FontSize = 10,
Foreground = BrushFromHex("#B45309"),
});
}
else if (isRecent)
{
titleRow.Children.Add(new TextBlock
{
Text = " 최근",
FontSize = 10,
Foreground = BrushFromHex("#2563EB"),
});
}
stack.Children.Add(titleRow);
var preview = tpl.Content.Length > 60 ? tpl.Content[..60] + "…" : tpl.Content;
stack.Children.Add(new TextBlock
{
Text = preview,
FontSize = 10.5,
Foreground = (Brush)FindResource("SecondaryText"),
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 2, 0, 0),
});
item.Child = stack;
item.MouseEnter += (s, _) =>
{
if (s is Border b) b.Background = (Brush)FindResource("ItemHoverBackground");
};
item.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
};
item.MouseLeftButtonUp += (s, _) =>
{
if (s is Border b && b.Tag is string content)
{
RegisterRecentPromptTemplate(tpl.Name);
InputBox.Text = content;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
TemplatePopup.IsOpen = false;
}
};
item.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ToggleFavoritePromptTemplate(tpl.Name);
BtnTemplateSelector_Click(this, new RoutedEventArgs());
};
TemplateItems.Items.Add(item);
}
var favorites = templates.Where(t => favoriteSet.Contains(t.Name)).ToList();
var recents = templates.Where(t => !favoriteSet.Contains(t.Name) && recentRank.ContainsKey(t.Name))
.OrderBy(t => recentRank[t.Name])
.ToList();
var remaining = templates.Where(t => !favoriteSet.Contains(t.Name) && !recentRank.ContainsKey(t.Name))
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (favorites.Count > 0)
{
AddSectionHeader("고정");
foreach (var tpl in favorites)
AddTemplateRow(tpl, true, false);
}
if (recents.Count > 0)
{
AddSectionHeader("최근");
foreach (var tpl in recents)
AddTemplateRow(tpl, false, true);
}
if (remaining.Count > 0)
{
AddSectionHeader("전체");
foreach (var tpl in remaining)
AddTemplateRow(tpl, false, false);
}
TemplatePopup.IsOpen = true;
}
private void TemplateSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (TemplatePopup?.IsOpen == true)
BtnTemplateSelector_Click(this, new RoutedEventArgs());
}
private void RegisterRecentPromptTemplate(string name)
{
if (string.IsNullOrWhiteSpace(name))
return;
var recent = _settings.Settings.Llm.RecentPromptTemplates ??= new List<string>();
recent.RemoveAll(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
recent.Insert(0, name);
if (recent.Count > 10)
recent.RemoveRange(10, recent.Count - 10);
ScheduleSettingsSave();
}
private void ToggleFavoritePromptTemplate(string name)
{
if (string.IsNullOrWhiteSpace(name))
return;
var favorites = _settings.Settings.Llm.FavoritePromptTemplates ??= new List<string>();
var existing = favorites.FirstOrDefault(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
favorites.Remove(existing);
else
favorites.Add(name);
ScheduleSettingsSave();
}
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
private DateTime _progressStartTime;
private DispatcherTimer? _progressElapsedTimer;
private void UpdateAgentProgressBar(AgentEvent evt)
{
switch (evt.Type)
{
case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
ShowStickyProgress(evt.Steps.Count);
break;
case AgentEventType.StepStart when evt.StepTotal > 0:
UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
break;
case AgentEventType.Complete:
HideStickyProgress();
break;
}
}
private void ShowStickyProgress(int totalSteps)
{
_progressStartTime = DateTime.Now;
AgentProgressBar.Visibility = Visibility.Visible;
ProgressIcon.Text = "\uE768"; // play
ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
ProgressPercent.Text = "0%";
ProgressElapsed.Text = "0:00";
ProgressFill.Width = 0;
// 경과 시간 타이머
_progressElapsedTimer?.Stop();
_progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_progressElapsedTimer.Tick += (_, _) =>
{
var elapsed = DateTime.Now - _progressStartTime;
ProgressElapsed.Text = elapsed.TotalHours >= 1
? elapsed.ToString(@"h\:mm\:ss")
: elapsed.ToString(@"m\:ss");
};
_progressElapsedTimer.Start();
}
private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
{
if (AgentProgressBar.Visibility != Visibility.Visible) return;
var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
ProgressPercent.Text = $"{(int)(pct * 100)}%";
// 프로그레스 바 너비 애니메이션
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var targetWidth = parentBorder.ActualWidth * pct;
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
};
ProgressFill.BeginAnimation(WidthProperty, anim);
}
}
private void HideStickyProgress()
{
_progressElapsedTimer?.Stop();
_progressElapsedTimer = null;
if (AgentProgressBar.Visibility != Visibility.Visible) return;
// 완료 표시 후 페이드아웃
ProgressIcon.Text = "\uE930"; // check
ProgressStepLabel.Text = "작업 완료";
ProgressPercent.Text = "100%";
// 프로그레스 바 100%
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
ProgressFill.BeginAnimation(WidthProperty, anim);
}
// 3초 후 숨기기
var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
hideTimer.Tick += (_, _) =>
{
hideTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) =>
{
AgentProgressBar.Visibility = Visibility.Collapsed;
AgentProgressBar.Opacity = 1;
ProgressFill.BeginAnimation(WidthProperty, null);
ProgressFill.Width = 0;
};
AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
hideTimer.Start();
}
// ─── 하단 상태바 ──────────────────────────────────────────────────────
private void BtnCompactNow_Click(object sender, RoutedEventArgs e)
{
if (_isStreaming)
{
SetStatus("응답 생성 중에는 압축을 실행할 수 없습니다", spinning: false);
return;
}
_ = ExecuteManualCompactAsync("/compact", _activeTab);
}
private async void BtnGitBranch_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_currentGitBranchName) || GitBranchPopup == null || GitBranchItems == null)
return;
if (GitBranchPopup.IsOpen)
{
GitBranchPopup.IsOpen = false;
return;
}
try
{
await RefreshGitBranchStatusAsync();
_gitBranchSearchText = "";
if (GitBranchSearchBox != null)
GitBranchSearchBox.Text = "";
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
GitBranchSearchBox?.Focus();
}
catch (Exception ex)
{
Services.LogService.Error($"Git branch refresh failed: {ex.Message}");
}
}
private void GitBranchSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
_gitBranchSearchText = GitBranchSearchBox?.Text?.Trim() ?? "";
if (GitBranchPopup?.IsOpen == true)
BuildGitBranchPopup();
}
private void TrackRecentGitBranch(string? branchName)
{
if (string.IsNullOrWhiteSpace(branchName))
return;
_recentGitBranches.RemoveAll(branch => string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase));
_recentGitBranches.Insert(0, branchName);
if (_recentGitBranches.Count > 6)
_recentGitBranches.RemoveRange(6, _recentGitBranches.Count - 6);
}
private static string BuildUsageModelKey(string? service, string? model)
{
var normalizedService = (service ?? "").Trim().ToLowerInvariant();
var normalizedModel = (model ?? "").Trim();
if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel))
return "";
return $"{normalizedService}:{normalizedModel}";
}
private static long GetUsageValue(Dictionary<string, long>? source, string key)
{
if (source == null || string.IsNullOrWhiteSpace(key))
return 0;
return source.TryGetValue(key, out var value) ? value : 0;
}
private string BuildTopModelUsageSummary(DailyUsageStats todayUsage)
{
if (todayUsage.ModelPromptTokens.Count == 0 && todayUsage.ModelCompletionTokens.Count == 0)
return "";
var totals = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in todayUsage.ModelPromptTokens)
totals[pair.Key] = pair.Value;
foreach (var pair in todayUsage.ModelCompletionTokens)
totals[pair.Key] = totals.TryGetValue(pair.Key, out var current) ? current + pair.Value : pair.Value;
var topEntries = totals
.OrderByDescending(pair => pair.Value)
.Take(3)
.Select(pair =>
{
var label = pair.Key.Replace(":", " · ");
var postPrompt = GetUsageValue(todayUsage.PostCompactionPromptTokens, pair.Key);
var postCompletion = GetUsageValue(todayUsage.PostCompactionCompletionTokens, pair.Key);
var postTotal = postPrompt + postCompletion;
return postTotal > 0
? $"- {label}: {FormatTokenCount(pair.Value)} tokens (compact 이후 {FormatTokenCount(postTotal)})"
: $"- {label}: {FormatTokenCount(pair.Value)} tokens";
})
.ToList();
return topEntries.Count == 0
? ""
: "\n오늘 상위 모델\n" + string.Join("\n", topEntries);
}
private static string FormatTokenCount(long value)
{
if (value <= int.MaxValue)
return Services.TokenEstimator.Format((int)value);
if (value >= 1_000_000)
return $"{value / 1_000_000d:0.#}M";
if (value >= 1_000)
return $"{value / 1_000d:0.#}K";
return value.ToString("N0");
}
private double _lastArcRatio = -1;
private void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
{
ratio = Math.Clamp(ratio, 0, 0.9999);
// 비율 변화가 1% 미만이면 렌더링 생략 (250ms마다 호출되므로 불필요한 재생성 방지)
if (Math.Abs(ratio - _lastArcRatio) < 0.01)
return;
_lastArcRatio = ratio;
if (ratio <= 0)
{
path.Data = Geometry.Empty;
return;
}
var start = GetCirclePoint(centerX, centerY, radius, -90);
var end = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
var figure = new PathFigure
{
StartPoint = start,
IsClosed = false,
IsFilled = false,
};
figure.Segments.Add(new ArcSegment
{
Point = end,
Size = new Size(radius, radius),
SweepDirection = SweepDirection.Clockwise,
IsLargeArc = ratio >= 0.5,
});
path.Data = new PathGeometry(new[] { figure });
}
private static void PositionThresholdMarker(FrameworkElement marker, double ratio, double centerX, double centerY, double radius, double halfSize)
{
ratio = Math.Clamp(ratio, 0, 0.9999);
var point = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
Canvas.SetLeft(marker, point.X - halfSize);
Canvas.SetTop(marker, point.Y - halfSize);
}
private static Point GetCirclePoint(double centerX, double centerY, double radius, double angleDegrees)
{
var radians = angleDegrees * Math.PI / 180.0;
return new Point(
centerX + radius * Math.Cos(radians),
centerY + radius * Math.Sin(radians));
}
private void RecordCompactionStats(ContextCompactionResult result, bool wasAutomatic)
{
var beforeTokens = Math.Max(0, result.BeforeTokens);
var afterTokens = Math.Max(0, result.AfterTokens);
var savedTokens = Math.Max(0, beforeTokens - afterTokens);
_lastCompactionBeforeTokens = beforeTokens;
_lastCompactionAfterTokens = afterTokens;
_lastCompactionAt = DateTime.Now;
_lastCompactionWasAutomatic = wasAutomatic;
_lastCompactionStageSummary = result.StageSummary;
_sessionCompactionCount++;
_sessionCompactionSavedTokens += savedTokens;
_sessionMemoryCompactionCount += result.SessionMemoryApplied ? 1 : 0;
_sessionMicrocompactBoundaryCount += result.MicrocompactBoundaryCount;
_sessionSnipCompactionCount += result.SnippedMessageCount + result.CollapsedBoundaryCount;
_pendingPostCompaction = true;
if (wasAutomatic)
_sessionAutomaticCompactionCount++;
else
_sessionManualCompactionCount++;
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null)
return;
conv.CompactionCount = _sessionCompactionCount;
conv.AutomaticCompactionCount = _sessionAutomaticCompactionCount;
conv.ManualCompactionCount = _sessionManualCompactionCount;
conv.CompactionSavedTokens = _sessionCompactionSavedTokens;
conv.SessionMemoryCompactionCount = _sessionMemoryCompactionCount;
conv.MicrocompactBoundaryCount = _sessionMicrocompactBoundaryCount;
conv.SnipCompactionCount = _sessionSnipCompactionCount;
conv.LastCompactionAt = _lastCompactionAt;
conv.LastCompactionWasAutomatic = wasAutomatic;
conv.LastCompactionBeforeTokens = beforeTokens;
conv.LastCompactionAfterTokens = afterTokens;
conv.LastCompactionStageSummary = _lastCompactionStageSummary;
conv.PendingPostCompaction = true;
try { _storage.Save(conv); } catch { }
}
private void ConsumePostCompactionUsageIfNeeded(int promptTokens, int completionTokens)
{
if (!_pendingPostCompaction)
return;
_pendingPostCompaction = false;
_sessionPostCompactionResponseCount++;
_sessionPostCompactionPromptTokens += Math.Max(0, promptTokens);
_sessionPostCompactionCompletionTokens += Math.Max(0, completionTokens);
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null)
return;
conv.PendingPostCompaction = false;
conv.PostCompactionResponseCount = _sessionPostCompactionResponseCount;
conv.PostCompactionPromptTokens = _sessionPostCompactionPromptTokens;
conv.PostCompactionCompletionTokens = _sessionPostCompactionCompletionTokens;
try { _storage.Save(conv); } catch { }
}
private void ScheduleGitBranchRefresh(int delayMs = 400)
{
if (BtnGitBranch == null)
return;
_gitRefreshTimer.Stop();
_gitRefreshTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(100, delayMs));
_gitRefreshTimer.Start();
}
private async Task RefreshGitBranchStatusAsync()
{
var folder = GetCurrentWorkFolder();
if (_activeTab == "Chat" || string.IsNullOrWhiteSpace(folder))
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
var gitRoot = ResolveGitRoot(folder);
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitRoot) || string.IsNullOrWhiteSpace(gitPath))
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
_gitStatusRefreshCts?.Cancel();
_gitStatusRefreshCts?.Dispose();
_gitStatusRefreshCts = new CancellationTokenSource();
var ct = _gitStatusRefreshCts.Token;
try
{
var branchTask = RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, ct);
var statusTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct);
var diffTask = RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat", "HEAD" }, ct);
var branchesTask = RunGitAsync(gitPath, gitRoot, new[] { "branch", "--format", "%(refname:short)" }, ct);
var upstreamTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "-sb" }, ct);
await Task.WhenAll(branchTask, statusTask, diffTask, branchesTask, upstreamTask);
var branchResult = await branchTask;
var statusResult = await statusTask;
var diffResult = await diffTask;
var branchesResult = await branchesTask;
var upstreamResult = await upstreamTask;
if (branchResult.ExitCode != 0 || statusResult.ExitCode != 0)
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
var branchName = branchResult.StdOut.Trim();
if (string.IsNullOrWhiteSpace(branchName))
branchName = "detached";
var fileCount = statusResult.StdOut
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Length;
var branches = branchesResult.ExitCode == 0
? branchesResult.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x.Equals(branchName, StringComparison.OrdinalIgnoreCase) ? "" : x, StringComparer.OrdinalIgnoreCase)
.ToList()
: new List<string>();
var diffText = diffResult.ExitCode == 0 ? diffResult.StdOut : "";
if (string.IsNullOrWhiteSpace(diffText))
{
var fallbackDiff = await RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat" }, ct);
diffText = fallbackDiff.ExitCode == 0 ? fallbackDiff.StdOut : "";
}
var insertions = ParseGitShortStat(diffText, "insertion");
var deletions = ParseGitShortStat(diffText, "deletion");
var filesText = fileCount == 0 && insertions == 0 && deletions == 0
? "깨끗함"
: $"{fileCount:N0}개 파일";
var addedText = insertions > 0 ? $"+{insertions:N0}" : "";
var deletedText = deletions > 0 ? $"-{deletions:N0}" : "";
var tooltip = $"브랜치: {branchName}\n작업 폴더: {gitRoot}\n변경 파일: {fileCount:N0}\n추가 라인: +{insertions:N0}\n삭제 라인: -{deletions:N0}";
_currentGitRoot = gitRoot;
_currentGitChangedFileCount = fileCount;
_currentGitInsertions = insertions;
_currentGitDeletions = deletions;
_currentGitBranches = branches;
_currentGitUpstreamStatus = ExtractGitUpstreamStatus(upstreamResult.StdOut);
UpdateGitBranchUi(branchName, filesText, addedText, deletedText, tooltip, Visibility.Visible);
}
catch (OperationCanceledException)
{
}
catch
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
}
}
private static int ParseGitShortStat(string text, string unit)
{
if (string.IsNullOrWhiteSpace(text))
return 0;
var match = System.Text.RegularExpressions.Regex.Match(
text,
$@"(\d+)\s+{unit}",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success && int.TryParse(match.Groups[1].Value, out var value) ? value : 0;
}
private static string? ExtractGitUpstreamStatus(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? "";
var start = firstLine.IndexOf('[', StringComparison.Ordinal);
var end = firstLine.IndexOf(']', StringComparison.Ordinal);
if (start >= 0 && end > start)
return firstLine[(start + 1)..end];
return null;
}
private async Task SwitchGitBranchAsync(string branchName)
{
if (string.IsNullOrWhiteSpace(branchName) || string.IsNullOrWhiteSpace(_currentGitRoot))
return;
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
try
{
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", branchName }, CancellationToken.None);
if (result.ExitCode != 0)
{
CustomMessageBox.Show($"브랜치 전환에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
TrackRecentGitBranch(branchName);
GitBranchPopup.IsOpen = false;
await RefreshGitBranchStatusAsync();
SetStatus($"브랜치 전환: {branchName}", spinning: false);
}
catch (Exception ex)
{
CustomMessageBox.Show($"브랜치 전환 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async Task CreateGitBranchAsync()
{
if (string.IsNullOrWhiteSpace(_currentGitRoot))
return;
var dlg = new Views.InputDialog("새 브랜치 생성", "브랜치 이름:", "", "feature/my-change") { Owner = this };
if (dlg.ShowDialog() != true)
return;
var branchName = (dlg.ResponseText ?? "").Trim();
if (string.IsNullOrWhiteSpace(branchName))
return;
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
try
{
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", "-c", branchName }, CancellationToken.None);
if (result.ExitCode != 0)
{
CustomMessageBox.Show($"브랜치 생성에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
TrackRecentGitBranch(branchName);
GitBranchPopup.IsOpen = false;
await RefreshGitBranchStatusAsync();
SetStatus($"새 브랜치 생성: {branchName}", spinning: false);
}
catch (Exception ex)
{
CustomMessageBox.Show($"브랜치 생성 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private static string TruncateForStatus(string? text, int max = 40)
{
if (string.IsNullOrEmpty(text)) return "";
return text.Length <= max ? text : text[..max] + "…";
}
private DraftQueueItem? EnqueueDraftRequest(string text, string priority, string? explicitKind = null)
{
DraftQueueItem? queuedItem = null;
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
_currentConversation = (queuedItem = session.EnqueueDraft(
_activeTab,
text,
priority,
_storage,
InferDraftKind(text, explicitKind))) != null
? session.CurrentConversation
: _currentConversation;
}
return queuedItem;
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
private Popup? _taskSummaryPopup;
private UIElement? _taskSummaryTarget;
private string _taskSummaryTaskFilter = "all";
private void BtnDraftEnqueue_Click(object sender, RoutedEventArgs e)
{
var text = InputBox?.Text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text))
return;
QueueComposerDraft(
priority: Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ? "now" : "next",
explicitKind: "steering",
startImmediatelyWhenIdle: true);
}
private void BtnDraftEdit_Click(object sender, RoutedEventArgs e)
{
if (InputBox == null)
return;
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
}
private void BtnDraftClear_Click(object sender, RoutedEventArgs e)
{
if (InputBox == null)
return;
InputBox.Clear();
InputBox.Focus();
UpdateInputBoxHeight();
RefreshDraftQueueUi();
}
private void QueueDraftForImmediateRun(string draftId)
{
if (_streamingTabs.Contains(_activeTab))
{
// 실행 중이면 큐에 대기 — 현재 작업 완료 후 자동 실행
ShowToast("메시지가 대기열에 추가되었습니다. 현재 작업 완료 후 실행됩니다.", "\uE8AB");
return;
}
StartNextQueuedDraftIfAny(draftId);
}
private void ResetDraftInQueue(string draftId)
{
lock (_convLock)
{
var session = ChatSession;
if (session != null && session.ResetDraftToQueued(_activeTab, draftId, _storage))
_currentConversation = session.CurrentConversation;
}
RefreshDraftQueueUi();
}
private void RemoveDraftFromQueue(string draftId)
{
lock (_convLock)
{
var session = ChatSession;
if (session != null && session.RemoveDraft(_activeTab, draftId, _storage))
_currentConversation = session.CurrentConversation;
}
RefreshDraftQueueUi();
}
private void ClearCompletedDrafts()
{
int removed;
lock (_convLock)
{
removed = _draftQueueProcessor.ClearCompleted(ChatSession, _activeTab, _storage);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
}
RefreshDraftQueueUi();
if (removed > 0)
ShowToast($"완료된 대기열 {removed}개 정리됨");
}
private void ClearFailedDrafts()
{
int removed;
lock (_convLock)
{
removed = _draftQueueProcessor.ClearFailed(ChatSession, _activeTab, _storage);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
}
RefreshDraftQueueUi();
if (removed > 0)
ShowToast($"실패한 대기열 {removed}개 정리됨");
}
private void StartNextQueuedDraftIfAny(string? preferredDraftId = null)
{
if (_streamingTabs.Contains(_activeTab))
return;
DraftQueueItem? next = null;
lock (_convLock)
{
var session = ChatSession;
next = _draftQueueProcessor.TryStartNext(session, _activeTab, _storage, preferredDraftId, _appState.TaskRuns);
if (next == null)
{
// 대기열이 비었을 때 "대기열에 추가됐습니다" 토스트가 남아있으면 즉시 숨김
HideToast();
return;
}
_runningDraftId = next.Id;
_currentConversation = session?.CurrentConversation ?? _currentConversation;
}
HideToast(); // 대기열 실행 시작 — "추가됐습니다" 알림 즉시 숨김
RefreshDraftQueueUi();
var draftRunTab = _activeTab; // 탭 전환 대비: 실행 시점의 탭 캡처
_ = SendMessageAsync(next.Text).ContinueWith(t =>
{
if (t.IsFaulted)
{
Services.LogService.Error($"대기열 메시지 전송 실패: {t.Exception?.InnerException?.Message}");
Dispatcher.BeginInvoke(() =>
{
// 스트리밍 상태 정리 — 실패로 교착 방지
ResetStreamingUiState(draftRunTab);
RefreshDraftQueueUi();
});
}
}, TaskScheduler.Default);
}
private void OpenCommandSkillBrowser(string seedInput)
{
if (InputBox == null)
return;
if (!string.IsNullOrEmpty(_slashPalette.ActiveCommand))
HideSlashChip();
InputBox.Text = seedInput;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
}
private void BtnToggleExecutionLog_Click(object sender, RoutedEventArgs e)
{
bool visible;
lock (_convLock)
{
var session = _appState.ChatSession;
visible = session?.ToggleExecutionHistory(_activeTab, _storage) ?? !(_currentConversation?.ShowExecutionHistory ?? true);
if (session != null)
_currentConversation = session.CurrentConversation;
}
if (ExecutionLogLabel != null)
{
var logLevelLabel = _settings.Settings.Llm.AgentLogLevel switch
{
"hidden" => "숨김",
"simple" => "간략",
"debug" => "디버그",
_ => "상세",
};
ExecutionLogLabel.Text = $"실행이력 · {logLevelLabel}";
}
if (ExecutionLogIcon != null)
ExecutionLogIcon.Text = visible ? "\uE8F8" : "\uE946";
RenderMessages();
}
private bool CanRetryCurrentConversation()
{
return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation());
}
private List<ChatExecutionEvent> GetExecutionEventsForRun(string? runId, int take = 3)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, eventTake: take).Events;
}
private static bool IsBranchContextMessage(string? content)
{
return !string.IsNullOrWhiteSpace(content)
&& content.StartsWith("이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다.", StringComparison.Ordinal);
}
private AppStateService.AgentRunState? GetAgentRunStateById(string? runId)
=> _appState.GetAgentRunById(runId);
private AppStateService.AgentRunState? GetLatestBranchContextRun()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetLatestConversationRun(conv?.AgentRunHistory);
}
private List<string> GetBranchContextFilePaths(string? runId, int take = 3)
{
return GetExecutionEventFilePaths(runId, take);
}
private List<string> GetExecutionEventFilePaths(string? runId, int take = 2)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, fileTake: take).FilePaths;
}
private AppStateService.RunPlanHistoryState GetRunPlanHistory(string? runId)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunPlanHistory(conv?.ExecutionEvents, runId);
}
private Border? BuildRunPlanHistoryCard(AppStateService.RunPlanHistoryState history)
{
if (!history.HasAny)
return null;
var panel = new StackPanel();
panel.Children.Add(new TextBlock
{
Text = "계획 히스토리",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1E3A8A"),
Margin = new Thickness(0, 0, 0, 6),
});
panel.Children.Add(BuildPlanHistorySection("원안", history.OriginalSummary, history.OriginalSteps));
panel.Children.Add(BuildPlanHistorySection("수정안", history.RevisedSummary, history.RevisedSteps));
panel.Children.Add(BuildPlanHistorySection("최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps));
var diffSection = BuildPlanDiffSection(history.OriginalSteps, history.FinalApprovedSteps);
if (diffSection != null)
panel.Children.Add(diffSection);
return new Border
{
Background = BrushFromHex("#EFF6FF"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 8, 0, 0),
Child = panel,
};
}
private static Border BuildPlanHistorySection(string title, string? summary, IReadOnlyList<string> steps)
{
var section = new StackPanel();
section.Children.Add(new TextBlock
{
Text = title,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1D4ED8"),
});
var hasContent = false;
if (!string.IsNullOrWhiteSpace(summary))
{
section.Children.Add(new TextBlock
{
Text = summary,
FontSize = 10,
Foreground = Brushes.DimGray,
Margin = new Thickness(0, 2, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
hasContent = true;
}
if (steps.Count > 0)
{
foreach (var step in steps.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"• {step}",
FontSize = 10,
Foreground = BrushFromHex("#334155"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
if (steps.Count > 4)
{
section.Children.Add(new TextBlock
{
Text = $"• ... 외 {steps.Count - 4}개 단계",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 1, 0, 0),
});
}
hasContent = true;
}
if (!hasContent)
{
section.Children.Add(new TextBlock
{
Text = "기록 없음",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 2, 0, 0),
});
}
return new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#DBEAFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(6, 5, 6, 5),
Margin = new Thickness(0, 0, 0, 4),
Child = section,
};
}
private Border? BuildPlanDiffSection(IReadOnlyList<string> originalSteps, IReadOnlyList<string> finalSteps)
{
if (originalSteps.Count == 0 || finalSteps.Count == 0)
return null;
var originalNormalized = originalSteps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Select(step => step.Trim())
.ToList();
var finalNormalized = finalSteps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Select(step => step.Trim())
.ToList();
var originalTokens = BuildPlanStepTokens(originalNormalized);
var finalTokens = BuildPlanStepTokens(finalNormalized);
var originalTokenMap = originalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
var finalTokenMap = finalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
var added = finalTokens
.Where(token => !originalTokenMap.ContainsKey(token.Id))
.Select(token => token.Text)
.ToList();
var removed = originalTokens
.Where(token => !finalTokenMap.ContainsKey(token.Id))
.Select(token => token.Text)
.ToList();
var moved = GetMovedPlanSteps(originalTokens, finalTokenMap);
if (added.Count == 0 && removed.Count == 0 && moved.Count == 0)
return null;
var section = new StackPanel();
var header = new Grid();
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
header.Children.Add(new TextBlock
{
Text = "변경 포인트 (원안 ↔ 최종승인안)",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1E3A8A"),
});
var severity = GetPlanDiffSeverity(added.Count, removed.Count, moved.Count, originalNormalized.Count, finalNormalized.Count);
var severityBadge = new Border
{
Background = BrushFromHex(severity.BgHex),
BorderBrush = BrushFromHex(severity.BorderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(6, 1, 6, 1),
Child = new TextBlock
{
Text = severity.Label,
FontSize = 9.5,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(severity.FgHex),
}
};
Grid.SetColumn(severityBadge, 1);
header.Children.Add(severityBadge);
section.Children.Add(header);
foreach (var step in added.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"+ {step}",
FontSize = 10,
Foreground = BrushFromHex("#166534"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
foreach (var step in removed.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"- {step}",
FontSize = 10,
Foreground = BrushFromHex("#991B1B"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
foreach (var item in moved.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"~ {item.Step} ({item.FromIndex + 1} -> {item.ToIndex + 1})",
FontSize = 10,
Foreground = BrushFromHex("#1D4ED8"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
var hiddenCount = Math.Max(0, added.Count - 4) + Math.Max(0, removed.Count - 4) + Math.Max(0, moved.Count - 4);
if (hiddenCount > 0)
{
section.Children.Add(new TextBlock
{
Text = $"... 외 {hiddenCount}개 변경",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 1, 0, 0),
});
}
return new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(6, 5, 6, 5),
Margin = new Thickness(0, 0, 0, 2),
Child = section,
};
}
private static List<(string Step, int FromIndex, int ToIndex)> GetMovedPlanSteps(
IReadOnlyList<PlanStepToken> originalTokens,
IReadOnlyDictionary<string, PlanStepToken> finalTokenMap)
{
var moved = new List<(string Step, int FromIndex, int ToIndex)>();
foreach (var token in originalTokens)
{
if (!finalTokenMap.TryGetValue(token.Id, out var finalToken))
continue;
if (token.Index == finalToken.Index)
continue;
moved.Add((finalToken.Text, token.Index, finalToken.Index));
}
return moved
.OrderBy(item => item.ToIndex)
.ToList();
}
private static List<PlanStepToken> BuildPlanStepTokens(IReadOnlyList<string> steps)
{
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
var tokens = new List<PlanStepToken>(steps.Count);
for (var i = 0; i < steps.Count; i++)
{
var text = steps[i].Trim();
if (string.IsNullOrWhiteSpace(text))
continue;
var normalizedKey = text.ToUpperInvariant();
counters.TryGetValue(normalizedKey, out var currentCount);
var nextCount = currentCount + 1;
counters[normalizedKey] = nextCount;
tokens.Add(new PlanStepToken($"{normalizedKey}#{nextCount}", text, i));
}
return tokens;
}
private readonly record struct PlanStepToken(string Id, string Text, int Index);
private (string Label, string BgHex, string BorderHex, string FgHex) GetPlanDiffSeverity(
int addedCount,
int removedCount,
int movedCount,
int originalCount,
int finalCount)
{
var totalChanges = addedCount + removedCount + movedCount;
var baseSize = Math.Max(1, Math.Max(originalCount, finalCount));
var ratio = (double)totalChanges / baseSize;
var llm = _settings.Settings.Llm;
var mediumCount = llm.PlanDiffSeverityMediumCount > 0 ? llm.PlanDiffSeverityMediumCount : 2;
var highCount = llm.PlanDiffSeverityHighCount > 0 ? llm.PlanDiffSeverityHighCount : 5;
if (highCount < mediumCount)
highCount = mediumCount;
var mediumRatioPercent = llm.PlanDiffSeverityMediumRatioPercent > 0 ? llm.PlanDiffSeverityMediumRatioPercent : 25;
var highRatioPercent = llm.PlanDiffSeverityHighRatioPercent > 0 ? llm.PlanDiffSeverityHighRatioPercent : 60;
mediumRatioPercent = Math.Clamp(mediumRatioPercent, 1, 100);
highRatioPercent = Math.Clamp(highRatioPercent, mediumRatioPercent, 100);
var mediumRatio = mediumRatioPercent / 100.0;
var highRatio = highRatioPercent / 100.0;
if (totalChanges >= highCount || ratio >= highRatio)
return ("대폭", "#FEF2F2", "#FCA5A5", "#991B1B");
if (totalChanges >= mediumCount || ratio >= mediumRatio)
return ("중간", "#FFF7ED", "#FDBA74", "#9A3412");
return ("경미", "#ECFDF5", "#86EFAC", "#166534");
}
private void OpenRunFilePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
if (PreviewWindow.IsOpen)
PreviewWindow.RefreshIfOpen(path);
else
TryShowPreview(path);
}
private void OpenRunFileExternal(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
catch { }
}
private void RevealRunFileInFolder(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); } catch { }
}
private string GetRunPrimaryTool(string runId)
{
return GetExecutionEventsForRun(runId, 20)
.Where(evt => !string.IsNullOrWhiteSpace(evt.ToolName))
.GroupBy(evt => evt.ToolName!, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault() ?? "";
}
private static string GetFollowUpAssetHint(IEnumerable<string> files)
{
var ext = files
.Select(path => System.IO.Path.GetExtension(path)?.ToLowerInvariant())
.FirstOrDefault(e => !string.IsNullOrWhiteSpace(e));
return ext switch
{
".html" or ".htm" => "브라우저에서 렌더링과 레이아웃까지 함께 점검해줘.",
".md" => "문서 흐름과 섹션 구성을 함께 다듬어줘.",
".csv" or ".xlsx" => "데이터 구조와 컬럼 구성이 맞는지도 함께 확인해줘.",
".docx" => "문서 형식과 본문 구성을 같이 점검해줘.",
".cs" or ".ts" or ".js" or ".py" => "코드 영향 범위와 후속 수정 포인트를 같이 점검해줘.",
_ => "결과물과 관련 파일을 먼저 검토한 뒤 이어서 진행해줘.",
};
}
private string BuildFollowUpLeadForRun(AppStateService.AgentRunState run)
{
var primaryTool = GetRunPrimaryTool(run.RunId);
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var assetHint = GetFollowUpAssetHint(relatedFiles);
return primaryTool switch
{
"file_write" or "file_edit" or "html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create"
=> $"방금 만든 결과물을 검토하고, 필요한 후속 수정이나 마무리 작업을 이어서 진행해줘. {assetHint}",
"code_review" or "review"
=> "방금 검토한 내용을 바탕으로 우선순위 높은 이슈부터 후속 조치를 이어서 진행해줘. 수정이 필요하면 바로 반영 방향까지 포함해줘.",
"grep" or "glob" or "folder_map"
=> "방금 찾은 내용과 구조를 바탕으로 다음 분석 단계를 이어서 진행해줘. 필요하면 관련 파일을 직접 열어 근거를 더 확인해줘.",
"process" or "build_run"
=> "방금 실행한 명령 결과를 바탕으로 오류나 남은 작업을 정리하고, 필요한 다음 조치를 이어서 진행해줘.",
_ => "방금 완료한 작업을 이어서 진행해줘.",
};
}
private string BuildBranchHintFromRun(AppStateService.AgentRunState run)
{
var primaryTool = GetRunPrimaryTool(run.RunId);
var baseHint = primaryTool switch
{
"file_write" or "file_edit" => "수정안",
"html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create" => "결과 확장",
"code_review" or "review" => "리뷰 후속",
"grep" or "glob" or "folder_map" => "탐색 후속",
_ => TruncateForStatus(run.Summary, 16),
};
return string.IsNullOrWhiteSpace(baseHint) ? "후속안" : baseHint;
}
private string BuildFollowUpPromptFromRun(AppStateService.AgentRunState run)
{
var summary = string.IsNullOrWhiteSpace(run.Summary)
? "방금 완료한 작업"
: run.Summary.Trim();
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var planHistory = GetRunPlanHistory(run.RunId);
var lead = BuildFollowUpLeadForRun(run);
var sb = new System.Text.StringBuilder();
sb.AppendLine(lead);
sb.AppendLine();
sb.AppendLine("[이전 결과 요약]");
sb.AppendLine(summary);
if (relatedFiles.Count > 0)
{
sb.AppendLine();
sb.AppendLine("[관련 파일]");
foreach (var path in relatedFiles)
sb.AppendLine($"- {path}");
}
if (recentEvents.Count > 0)
{
sb.AppendLine();
sb.AppendLine("[최근 실행 로그]");
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
sb.AppendLine($"- {_appState.FormatExecutionEventLine(evt)}");
}
if (planHistory.HasAny)
{
sb.AppendLine();
sb.AppendLine("[계획 히스토리]");
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: "- ");
}
sb.AppendLine();
sb.AppendLine(GetFollowUpAssetHint(relatedFiles));
return sb.ToString().Trim();
}
private string BuildBranchContextMessageFromRun(AppStateService.AgentRunState run)
{
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
var planHistory = GetRunPlanHistory(run.RunId);
var primaryTool = GetRunPrimaryTool(run.RunId);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다. ({BuildBranchHintFromRun(run)})");
sb.AppendLine();
sb.AppendLine($"- 실행 요약: {(string.IsNullOrWhiteSpace(run.Summary) ? " " : run.Summary.Trim())}");
if (!string.IsNullOrWhiteSpace(primaryTool))
sb.AppendLine($"- 주요 도구: {primaryTool}");
if (relatedFiles.Count > 0)
{
sb.AppendLine("- 관련 파일:");
foreach (var path in relatedFiles)
sb.AppendLine($" - {path}");
}
if (recentEvents.Count > 0)
{
sb.AppendLine("- 최근 실행 로그:");
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
sb.AppendLine($" - {_appState.FormatExecutionEventLine(evt)}");
}
if (planHistory.HasAny)
{
sb.AppendLine("- 계획 히스토리:");
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: " - ");
}
sb.AppendLine();
sb.AppendLine("이 문맥을 바탕으로 여기서 별도의 후속 작업을 이어가면 됩니다.");
return sb.ToString().Trim();
}
private static void AppendPlanHistoryLines(System.Text.StringBuilder sb, AppStateService.RunPlanHistoryState history, string bulletPrefix)
{
AppendPlanHistoryLine(sb, bulletPrefix, "원안", history.OriginalSummary, history.OriginalSteps);
AppendPlanHistoryLine(sb, bulletPrefix, "수정안", history.RevisedSummary, history.RevisedSteps);
AppendPlanHistoryLine(sb, bulletPrefix, "최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps);
}
private static void AppendPlanHistoryLine(
System.Text.StringBuilder sb,
string bulletPrefix,
string label,
string? summary,
IReadOnlyList<string> steps)
{
var stepPreview = steps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Take(3)
.ToList();
if (string.IsNullOrWhiteSpace(summary) && stepPreview.Count == 0)
{
sb.AppendLine($"{bulletPrefix}{label}: 기록 없음");
return;
}
var summaryPart = string.IsNullOrWhiteSpace(summary) ? "" : summary.Trim();
var stepPart = stepPreview.Count == 0 ? "" : string.Join(" | ", stepPreview);
if (!string.IsNullOrWhiteSpace(summaryPart) && !string.IsNullOrWhiteSpace(stepPart))
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart} ({stepPart})");
else if (!string.IsNullOrWhiteSpace(summaryPart))
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart}");
else
sb.AppendLine($"{bulletPrefix}{label}: {stepPart}");
}
private void EnqueueFollowUpFromRun(AppStateService.AgentRunState run)
{
var prompt = BuildFollowUpPromptFromRun(run);
EnqueueDraftRequest(prompt, "next", "followup");
RefreshDraftQueueUi();
ShowToast("후속 작업이 대기열에 추가되었습니다.");
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ScrollToRunInTimeline(string runId)
{
if (string.IsNullOrWhiteSpace(runId))
return;
if (_runBannerAnchors.TryGetValue(runId, out var target))
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
Dispatcher.BeginInvoke(() =>
{
target.BringIntoView();
target.Focusable = true;
target.Focus();
}, DispatcherPriority.Background);
return;
}
ShowToast("현재 화면에서 해당 실행 로그를 찾지 못했습니다.", "\uE783");
}
private void BranchConversationFromRun(AppStateService.AgentRunState run)
{
ChatConversation? source;
lock (_convLock) source = _currentConversation;
if (source == null || source.Messages.Count == 0)
return;
var atIndex = source.Messages.FindLastIndex(m => m.Role == "assistant");
if (atIndex < 0)
atIndex = source.Messages.Count - 1;
ForkConversation(
source,
atIndex,
BuildBranchHintFromRun(run),
BuildBranchContextMessageFromRun(run),
run.RunId);
var prompt = BuildFollowUpPromptFromRun(run);
EnqueueDraftRequest(prompt, "next", "followup");
RefreshDraftQueueUi();
ShowToast("새 분기 대화가 생성되고 후속 작업이 대기열에 추가되었습니다.");
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private string? GetLastUserMessageFromConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return conv?.Messages.LastOrDefault(m => m.Role == "user")?.Content;
}
private void RetryLastUserMessageFromConversation()
{
var lastUserMessage = GetLastUserMessageFromConversation();
if (string.IsNullOrWhiteSpace(lastUserMessage))
return;
if (_streamingTabs.Contains(_activeTab))
{
EnqueueDraftRequest(lastUserMessage, "now", "direct");
RefreshDraftQueueUi();
ShowToast("현재 작업 뒤에 같은 요청을 다시 실행하도록 대기열에 추가했습니다.");
return;
}
_ = SendMessageAsync(lastUserMessage);
}
private void FocusCurrentConversation()
{
if (InputBox == null)
return;
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
}
private void ShowRunningConversationsOnly()
{
_runningOnlyFilter = true;
UpdateConversationRunningFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ShowSubAgentTasksOnly()
{
_taskSummaryTaskFilter = "subagent";
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
}
private static string? TryGetDraftIdFromQueueTask(TaskRunStore.TaskRun task)
{
if (!string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(task.Id))
return null;
var parts = task.Id.Split(':');
return parts.Length >= 3 ? parts[^1] : null;
}
private static string? TryGetToolNameFromPermissionTask(TaskRunStore.TaskRun task)
{
if (!string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(task.Id))
return null;
var parts = task.Id.Split(':');
return parts.Length >= 2 ? parts[^1] : null;
}
private void RetryQueueTask(TaskRunStore.TaskRun task)
{
var draftId = TryGetDraftIdFromQueueTask(task);
if (string.IsNullOrWhiteSpace(draftId))
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.ResetDraftToQueued(_activeTab, draftId, _storage);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
}
RefreshDraftQueueUi();
StartNextQueuedDraftIfAny(draftId);
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void RemoveQueueTask(TaskRunStore.TaskRun task)
{
var draftId = TryGetDraftIdFromQueueTask(task);
if (string.IsNullOrWhiteSpace(draftId))
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.RemoveDraft(_activeTab, draftId, _storage);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
}
RefreshDraftQueueUi();
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ApplyPermissionOverrideAndRefreshTaskPopup(TaskRunStore.TaskRun task, string? mode)
{
var toolName = TryGetToolNameFromPermissionTask(task);
if (string.IsNullOrWhiteSpace(toolName))
return;
SetToolPermissionOverride(toolName, mode);
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
}
private Button CreateTaskSummaryActionButton(
string label,
string bg,
string border,
string fg,
RoutedEventHandler onClick,
bool trailingMargin = true)
{
var button = new Button
{
Content = label,
FontSize = 8.25,
MinHeight = 20,
Padding = new Thickness(6, 2, 6, 2),
Margin = trailingMargin ? new Thickness(0, 0, 3, 0) : new Thickness(0),
Background = BrushFromHex(bg),
BorderBrush = BrushFromHex(border),
BorderThickness = new Thickness(1),
Foreground = BrushFromHex(fg),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Left,
};
button.Click += onClick;
return button;
}
private WrapPanel? BuildTaskSummaryActionRow(TaskRunStore.TaskRun task, bool active)
{
if (string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase))
{
var actions = new WrapPanel
{
Margin = new Thickness(0, 6, 0, 0),
};
var primaryButton = CreateTaskSummaryActionButton(
active ? "지금 실행" : "다시 실행",
"#EEF2FF",
"#C7D2FE",
"#3730A3",
(_, _) => RetryQueueTask(task));
actions.Children.Add(primaryButton);
var secondaryButton = CreateTaskSummaryActionButton(
active ? "큐에서 제거" : "정리",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) => RemoveQueueTask(task),
trailingMargin: false);
actions.Children.Add(secondaryButton);
return actions;
}
if (string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase))
{
var actions = new WrapPanel
{
Margin = new Thickness(0, 6, 0, 0),
};
Button BuildPermissionButton(string label, string bg, string border, string fg, string? mode, bool margin = true)
{
return CreateTaskSummaryActionButton(
label, bg, border, fg,
(_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode),
trailingMargin: margin);
}
actions.Children.Add(BuildPermissionButton("권한 요청", "#EFF6FF", "#BFDBFE", "#1D4ED8", PermissionModeCatalog.Default));
actions.Children.Add(BuildPermissionButton("편집 자동 승인", "#ECFDF5", "#BBF7D0", "#166534", PermissionModeCatalog.AcceptEdits));
actions.Children.Add(BuildPermissionButton("권한 건너뛰기", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.BypassPermissions));
actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false));
return actions;
}
return null;
}
private Border BuildHookSummaryCard(AppStateService.HookEventState hook)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var hookCardStack = new StackPanel();
hookCardStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
Children =
{
new TextBlock
{
Text = "\uE756",
FontFamily = s_segoeIconFont,
FontSize = 9,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = "훅",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
}
}
});
hookCardStack.Children.Add(new TextBlock
{
Text = _appState.FormatHookEventLine(hook),
FontSize = 8.75,
TextWrapping = TextWrapping.Wrap,
Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"),
});
var hookActionRow = new WrapPanel
{
Margin = new Thickness(0, 6, 0, 0),
};
var hookFilterButton = CreateTaskSummaryActionButton(
"훅만 보기",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) =>
{
_taskSummaryTaskFilter = "hook";
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
});
hookActionRow.Children.Add(hookFilterButton);
if (!string.IsNullOrWhiteSpace(hook.RunId))
{
var capturedRunId = hook.RunId;
var timelineButton = CreateTaskSummaryActionButton(
"관련 로그로 이동",
hook.Success ? "#EEF2FF" : "#FEF2F2",
hook.Success ? "#C7D2FE" : "#FCA5A5",
hook.Success ? "#3730A3" : "#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
ScrollToRunInTimeline(capturedRunId);
},
trailingMargin: false);
hookActionRow.Children.Add(timelineButton);
}
hookCardStack.Children.Add(hookActionRow);
return new Border
{
Background = hook.Success ? BrushFromHex("#F8FAFC") : BrushFromHex("#FEF2F2"),
BorderBrush = hook.Success ? BrushFromHex("#E2E8F0") : BrushFromHex("#FECACA"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 3),
Child = hookCardStack
};
}
private Border BuildActiveBackgroundSummaryCard(IReadOnlyList<BackgroundJobService.BackgroundJobState> activeBackgroundJobs, int activeBackgroundCount)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var activeBackgroundStack = new StackPanel();
activeBackgroundStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = "\uE9F9",
FontFamily = s_segoeIconFont,
FontSize = 9,
Foreground = BrushFromHex("#1D4ED8"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"백그라운드 {activeBackgroundCount}",
FontSize = 9,
Foreground = BrushFromHex("#1D4ED8"),
FontWeight = FontWeights.SemiBold,
}
}
});
foreach (var job in activeBackgroundJobs)
{
activeBackgroundStack.Children.Add(new TextBlock
{
Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 40)}",
Margin = new Thickness(0, 2, 0, 0),
FontSize = 8.75,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
});
}
return new Border
{
Background = BrushFromHex("#EFF6FF"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 4),
Child = activeBackgroundStack
};
}
private Border BuildRecentBackgroundJobCard(BackgroundJobService.BackgroundJobState job)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var jobCardStack = new StackPanel();
var isFailed = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase);
jobCardStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
Children =
{
new TextBlock
{
Text = "\uE823",
FontFamily = s_segoeIconFont,
FontSize = 9,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
}
}
});
jobCardStack.Children.Add(new TextBlock
{
Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 48)}",
FontSize = 8.75,
TextWrapping = TextWrapping.Wrap,
Foreground = isFailed
? BrushFromHex("#991B1B")
: secondaryText,
});
if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
{
var retryBackgroundButton = CreateTaskSummaryActionButton(
"다시 시도",
"#FEF2F2",
"#FCA5A5",
"#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
RetryLastUserMessageFromConversation();
},
trailingMargin: false);
retryBackgroundButton.Margin = new Thickness(0, 5, 0, 0);
jobCardStack.Children.Add(retryBackgroundButton);
}
return new Border
{
Background = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
? BrushFromHex("#FEF2F2")
: BrushFromHex("#F8FAFC"),
BorderBrush = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
? BrushFromHex("#FECACA")
: BrushFromHex("#E2E8F0"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 3),
Child = jobCardStack
};
}
private static (Brush Background, Brush Border, Brush Foreground) GetPermissionModePalette(string? mode)
{
var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode);
return normalized switch
{
var x when string.Equals(x, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#86EFAC"), BrushFromHex("#166534")),
var x when string.Equals(x, PermissionModeCatalog.AcceptEdits, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
var x when string.Equals(x, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
var x when string.Equals(x, PermissionModeCatalog.BypassPermissions, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")),
_ => (BrushFromHex("#F8FAFC"), BrushFromHex("#CBD5E1"), BrushFromHex("#334155")),
};
}
private static (string Icon, Brush Color) GetTaskKindVisual(string? kind)
{
var normalized = kind?.Trim().ToLowerInvariant() ?? "";
return normalized switch
{
"permission" => ("\uE72E", BrushFromHex("#1D4ED8")),
"queue" => ("\uE14C", BrushFromHex("#7C3AED")),
"tool" => ("\uE90F", BrushFromHex("#A16207")),
"hook" => ("\uE756", BrushFromHex("#0F766E")),
"subagent" => ("\uE902", BrushFromHex("#2563EB")),
_ => ("\uE946", BrushFromHex("#475569")),
};
}
private static (string Label, Brush Background, Brush Border, Brush Foreground) GetPermissionEventStatusDisplay(string? status)
{
var normalized = status?.Trim().ToLowerInvariant() ?? "";
return normalized switch
{
"denied" => ("차단", BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
"granted" => ("허용", BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
"approved" => ("승인", BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
"auto" => ("자동 허용", BrushFromHex("#FFF7ED"), BrushFromHex("#FED7AA"), BrushFromHex("#9A3412")),
_ => ("확인 대기", BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
};
}
private void AddTaskSummaryPermissionSection(StackPanel panel, ChatConversation? currentConversation)
{
var permissionSummary = _appState.GetPermissionSummary(currentConversation);
var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(permissionSummary.EffectiveMode);
var (bg, border, fg) = GetPermissionModePalette(normalizedMode);
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var content = new StackPanel();
content.Children.Add(new TextBlock
{
Text = $"현재 권한 · {PermissionModeCatalog.ToDisplayLabel(normalizedMode)}",
FontWeight = FontWeights.SemiBold,
Foreground = fg,
});
content.Children.Add(new TextBlock
{
Text = permissionSummary.Description,
Margin = new Thickness(0, 3, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
});
content.Children.Add(new TextBlock
{
Text = $"기본 {PermissionModeCatalog.ToDisplayLabel(permissionSummary.DefaultMode)} · 예외 {permissionSummary.OverrideCount}개",
Margin = new Thickness(0, 2, 0, 0),
Foreground = primaryText,
});
panel.Children.Add(new Border
{
Background = bg,
BorderBrush = border,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(9, 7, 9, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = content,
});
}
private void AddTaskSummaryPermissionHistorySection(StackPanel panel)
{
var recentPermissions = _appState.GetRecentPermissionEvents(2);
if (recentPermissions.Count == 0)
return;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
panel.Children.Add(new TextBlock
{
Text = "최근 권한 이력",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var permission in recentPermissions)
{
var (statusLabel, statusBg, statusBorder, statusFg) = GetPermissionEventStatusDisplay(permission.Status);
var card = new StackPanel();
card.Children.Add(new TextBlock
{
Text = $"{permission.Timestamp:HH:mm:ss} · {permission.ToolName}",
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
FontSize = 10.5,
});
card.Children.Add(new TextBlock
{
Text = statusLabel,
Margin = new Thickness(0, 3, 0, 0),
Foreground = statusFg,
FontWeight = FontWeights.SemiBold,
FontSize = 10.5,
});
if (!string.IsNullOrWhiteSpace(permission.Summary))
{
card.Children.Add(new TextBlock
{
Text = TruncateForStatus(permission.Summary, 80),
Margin = new Thickness(0, 2, 0, 0),
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
FontSize = 10,
});
}
panel.Children.Add(new Border
{
Background = statusBg,
BorderBrush = statusBorder,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(9, 6, 9, 6),
Margin = new Thickness(8, 0, 8, 5),
Child = card,
});
}
}
private void AddTaskSummaryHookSection(StackPanel panel)
{
var recentHooks = _appState.GetRecentHookEvents(3);
if (recentHooks.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = "최근 훅 이력",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.DimGray,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var hook in recentHooks)
panel.Children.Add(BuildHookSummaryCard(hook));
}
private void AddTaskSummaryBackgroundSection(StackPanel panel)
{
var activeBackgroundJobs = _appState.GetActiveBackgroundJobs(2);
var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount;
if (activeBackgroundCount > 0)
panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount));
}
private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation)
{
if (!string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase))
return;
AddTaskSummaryPermissionSection(panel, currentConversation);
AddTaskSummaryBackgroundSection(panel);
}
// ─── 공유 정적 리소스 (FontFamily/Brush 반복 할당 방지) ──────────────────
private static readonly FontFamily s_segoeIconFont = new("Segoe MDL2 Assets");
private static readonly FontFamily s_segoeUiFont = new("Segoe UI, Malgun Gothic");
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, System.Windows.Media.SolidColorBrush> _brushCache = new(StringComparer.OrdinalIgnoreCase);
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
{
return _brushCache.GetOrAdd(hex, static h =>
{
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(h)!;
var brush = new System.Windows.Media.SolidColorBrush(c);
brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화
return brush;
});
}
}