diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md
index e147a4b..6e0d354 100644
--- a/docs/AGENT_ROADMAP.md
+++ b/docs/AGENT_ROADMAP.md
@@ -38,6 +38,16 @@
- `dotnet test --filter "Suite=ReplayStability"`: 14/14 통과.
- `dotnet test`: 379/379 통과.
+## 7. 명령/도구 보강 스냅샷 (2026-04-04)
+- 슬래시 명령 고도화: `/chrome`, `/mcp`, `/verify`, `/commit`, `/settings`, `/permissions` 하위 동작 정리.
+- `/mcp` 상태 라벨 표준화: `Connected`, `NeedsAuth`, `Configured`, `Disconnected`, `Disabled`.
+- `/chrome` 런타임 재시도: 초기 probe 실패 시 `/mcp reconnect all` 자동 수행 후 1회 재평가.
+- Git 정책 정렬: `git_tool`의 `commit` 비활성 문구 제거(로컬 커밋 경로와 정책 일치).
+- `/commit` 부분 커밋 지원: `files:path1,path2 :: 메시지` 형식으로 선택 파일만 stage+commit 가능.
+- 테스트 보강:
+ - `ChatWindowSlashPolicyTests`: 슬래시 파서/검증 프롬프트/MCP 상태 라벨 단위 검증 추가.
+ - `AgentParityToolsTests`: `git_tool commit` 레거시 비활성 메시지 회귀 방지 테스트 추가.
+
## 7. 권한 Hook 계약 (P2 마감 기준)
- lifecycle hook 키:
- `__permission_request__` (pre)
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 49c7ae8..d6b5071 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -2690,3 +2690,36 @@ else:
| 현재 (v1.6.1) | 스킬 프롬프트에서 양식 자동 감지 안내 | 완료 |
| 다음 | 시스템 프롬프트에 양식 목록 자동 주입 (BuildCoworkSystemPrompt) | 낮음 |
| 장기 | `.ax/templates/` 공용 양식 폴더 + 프로젝트 규칙에서 기본 양식 지정 | 중간 |
+
+---
+
+## 2026-04-04 진행 기록 (AX Agent 명령/도구 보강)
+
+### 1. 슬래시 명령 체계 보강
+- `/chrome`: 인자 미입력 시 MCP 런타임 진단, 인자 입력 시 브라우저 작업 실행 경로로 라우팅.
+- `/mcp`: `status`, `enable|disable`, `reconnect` 동작 정리 및 상태 라벨 표준화.
+- `/settings`: `model|permissions|mcp|theme` 하위 액션 지원.
+- `/permissions`: `ask|auto|deny|status` 하위 액션 지원.
+- `/verify`: Cowork/Code 탭 전용 검증 프롬프트 경로로 고정.
+- `/commit`: 승인 기반 커밋 실행 + `files:path1,path2 :: message` 부분 커밋 지원.
+- `/compact`: 수동 컨텍스트 압축 명령 유지 및 slash 팝업 힌트/도움말 반영.
+
+### 2. 도구/권한 정책 정렬
+- `GitTool`의 commit 경로에서 레거시 비활성 응답(`커밋 비활성`) 제거.
+- 세션 단위 MCP 활성/비활성 오버라이드 적용(영구 설정 미오염).
+- `/chrome` 진단 실패 시 `/mcp reconnect all` 자동 1회 재시도 후 상태 재평가.
+
+### 3. 테스트 보강
+- `ChatWindowSlashPolicyTests`:
+ - 슬래시 파서(`ParseGenericAction`, `ParseMcpAction`) 검증.
+ - `/verify` 시스템 프롬프트 필수 섹션 검증.
+ - MCP 상태 라벨 매핑 검증.
+ - `/commit` 입력 파서 검증.
+- `AgentParityToolsTests`:
+ - `git_tool commit` 레거시 비활성 메시지 회귀 방지 테스트 추가.
+
+### 4. 품질 게이트 결과
+- `dotnet build AxCopilot.sln` 통과 (경고 0, 오류 0).
+- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과.
+- 기준 시점 전체 테스트: 403 passed, 0 failed.
+
diff --git a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
index 02a164a..3f3e734 100644
--- a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
+++ b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
@@ -200,4 +200,34 @@ public class AgentParityToolsTests
foreach (var name in required)
names.Should().Contain(name);
}
+
+ [Fact]
+ public async Task GitTool_Commit_ShouldNotReturnLegacyDisabledMessage()
+ {
+ var workDir = Path.Combine(Path.GetTempPath(), "ax-git-commit-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(workDir);
+ Directory.CreateDirectory(Path.Combine(workDir, ".git"));
+ try
+ {
+ var context = new AgentContext
+ {
+ WorkFolder = workDir,
+ Permission = "Auto",
+ OperationMode = "external",
+ };
+
+ var tool = new GitTool();
+ var result = await tool.ExecuteAsync(
+ JsonDocument.Parse("""{"action":"commit","args":"테스트 커밋 메시지"}""").RootElement,
+ context,
+ CancellationToken.None);
+
+ result.Output.Should().NotContain("비활성 상태");
+ result.Output.Should().NotContain("향후 버전에서 활성화");
+ }
+ finally
+ {
+ try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
+ }
+ }
}
diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs
new file mode 100644
index 0000000..26c0d0a
--- /dev/null
+++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs
@@ -0,0 +1,77 @@
+using AxCopilot.Views;
+using FluentAssertions;
+using Xunit;
+
+namespace AxCopilot.Tests.Views;
+
+public class ChatWindowSlashPolicyTests
+{
+ [Fact]
+ public void BuildVerifySystemPrompt_ShouldContainStructuredSections()
+ {
+ var prompt = ChatWindow.BuildVerifySystemPrompt("/verify");
+
+ prompt.Should().Contain("[검증결과]");
+ prompt.Should().Contain("[실행요약]");
+ prompt.Should().Contain("[변경파일]");
+ prompt.Should().Contain("[잔여리스크]");
+ }
+
+ [Theory]
+ [InlineData("/mcp", "status", "")]
+ [InlineData("enable all", "enable", "all")]
+ [InlineData("reconnect chrome", "reconnect", "chrome")]
+ [InlineData("status", "status", "")]
+ public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget)
+ {
+ var (action, target) = ChatWindow.ParseMcpAction(displayText);
+
+ action.Should().Be(expectedAction);
+ target.Should().Be(expectedTarget);
+ }
+
+ [Theory]
+ [InlineData("/settings", "open", "")]
+ [InlineData("model", "model", "")]
+ [InlineData("ask", "ask", "")]
+ [InlineData("reconnect all", "reconnect", "all")]
+ public void ParseGenericAction_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument)
+ {
+ var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/settings");
+
+ action.Should().Be(expectedAction);
+ argument.Should().Be(expectedArgument);
+ }
+
+ [Theory]
+ [InlineData(false, "stdio", true, false, null, "Disabled")]
+ [InlineData(true, "stdio", false, false, null, "Enabled")]
+ [InlineData(true, "sse", true, false, null, "Configured")]
+ [InlineData(true, "stdio", true, false, null, "Disconnected")]
+ [InlineData(true, "stdio", true, true, 0, "NeedsAuth (도구 0개)")]
+ [InlineData(true, "stdio", true, true, 3, "Connected (도구 3개)")]
+ public void ResolveMcpDisplayStatus_ShouldReturnNormalizedLabel(
+ bool enabled,
+ string transport,
+ bool runtimeCheck,
+ bool connected,
+ int? toolCount,
+ string expected)
+ {
+ var label = ChatWindow.ResolveMcpDisplayStatus(enabled, transport, runtimeCheck, connected, toolCount);
+ label.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("/commit", 0, "")]
+ [InlineData("feat: 로그인 오류 수정", 0, "feat: 로그인 오류 수정")]
+ [InlineData("files:src/A.cs, src/B.cs :: feat: 부분 커밋", 2, "feat: 부분 커밋")]
+ [InlineData("files:src/A.cs,src/A.cs", 1, "")]
+ public void ParseCommitCommandInput_ShouldParseExpected(string input, int expectedFileCount, string expectedMessage)
+ {
+ var (files, message) = ChatWindow.ParseCommitCommandInput(input);
+
+ files.Count.Should().Be(expectedFileCount);
+ message.Should().Be(expectedMessage);
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/GitTool.cs b/src/AxCopilot/Services/Agent/GitTool.cs
index 40cd6d8..530f6f3 100644
--- a/src/AxCopilot/Services/Agent/GitTool.cs
+++ b/src/AxCopilot/Services/Agent/GitTool.cs
@@ -8,7 +8,7 @@ namespace AxCopilot.Services.Agent;
///
/// Git 버전 관리 도구.
/// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다.
-/// push/force push는 차단되며, 사용자가 직접 수행해야 합니다.
+ /// push/force push는 차단되며, 사용자가 직접 수행해야 합니다.
///
public class GitTool : IAgentTool
{
@@ -126,16 +126,6 @@ public class GitTool : IAgentTool
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
}
- // Git 커밋 — 현재 비활성 (향후 활성화 예정)
- // 의사결정 수준에서 무조건 확인을 받더라도, 커밋 자체를 차단합니다.
- if (action == "commit")
- {
- return ToolResult.Fail(
- "Git 커밋 기능은 현재 비활성 상태입니다.\n" +
- "안전을 위해 커밋은 사용자가 직접 수행하세요.\n" +
- "향후 버전에서 활성화될 예정입니다.");
- }
-
// 명령 실행
try
{
diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs
index 9cc00e2..f020533 100644
--- a/src/AxCopilot/Views/ChatWindow.xaml.cs
+++ b/src/AxCopilot/Views/ChatWindow.xaml.cs
@@ -5,6 +5,7 @@ using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
+using Microsoft.Win32;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
@@ -30,6 +31,18 @@ public partial class ChatWindow : Window
private bool _isStreaming;
private bool _sidebarVisible = true;
private string _selectedCategory = ""; // "" = 전체
+ private readonly Dictionary _tabSelectedCategory = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Chat"] = "",
+ ["Cowork"] = "",
+ ["Code"] = "",
+ };
+ private readonly Dictionary _tabSidebarVisible = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Chat"] = true,
+ ["Cowork"] = true,
+ ["Code"] = true,
+ };
private bool _failedOnlyFilter;
private bool _runningOnlyFilter;
private int _failedConversationCount;
@@ -50,6 +63,8 @@ public partial class ChatWindow : Window
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
private bool _userScrolled; // 사용자가 위로 스크롤했는지
+ private readonly HashSet _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
// 경과 시간 표시
private readonly DispatcherTimer _elapsedTimer;
@@ -59,6 +74,7 @@ public partial class ChatWindow : Window
// 타이핑 효과
private readonly DispatcherTimer _typingTimer;
private int _displayedLength; // 현재 화면에 표시된 글자 수
+ private ResourceDictionary? _agentThemeDictionary;
private sealed class ConversationMeta
{
@@ -93,24 +109,36 @@ public partial class ChatWindow : Window
_toolRegistry = ToolRegistry.CreateDefault();
_agentLoop = new AgentLoopService(_llm, _toolRegistry, settings)
{
- Dispatcher = action => System.Windows.Application.Current.Dispatcher.Invoke(action),
+ Dispatcher = action =>
+ {
+ var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
+ appDispatcher.Invoke(action);
+ },
AskPermissionCallback = async (toolName, filePath) =>
{
- var result = MessageBoxResult.None;
- await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
+ if (IsPermissionAutoApprovedForSession(toolName, filePath))
+ return true;
+
+ PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject;
+ var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
+ await appDispatcher.InvokeAsync(() =>
{
- result = CustomMessageBox.Show(
- $"도구 '{toolName}'이(가) 다음 파일에 접근하려 합니다:\n\n{filePath}\n\n허용하시겠습니까?",
- "AX Agent — 권한 확인",
- MessageBoxButton.YesNo,
- MessageBoxImage.Question);
+ AgentLoopService.PermissionPromptPreview? preview = null;
+ if (_agentLoop != null && _agentLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
+ preview = pendingPreview;
+ decision = PermissionRequestWindow.Show(this, toolName, filePath, preview);
});
- return result == MessageBoxResult.Yes;
+
+ if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession)
+ RememberPermissionRuleForSession(toolName, filePath);
+
+ return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
},
UserAskCallback = async (question, options, defaultValue) =>
{
string? response = null;
- await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
+ var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
+ await appDispatcher.InvokeAsync(() =>
{
response = UserAskDialog.Show(question, options, defaultValue);
});
@@ -138,6 +166,8 @@ public partial class ChatWindow : Window
UpdateConversationRunningFilterUi();
Loaded += (_, _) =>
{
+ ApplyAgentThemeResources();
+
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
SetupUserInfo();
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
@@ -145,6 +175,8 @@ public partial class ChatWindow : Window
UpdateAnalyzerButtonVisibility();
UpdateModelLabel();
RefreshInlineSettingsPanel();
+ ApplyExpressionLevelUi();
+ UpdateSidebarModeMenu();
InputBox.Focus();
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
@@ -248,6 +280,76 @@ public partial class ChatWindow : Window
};
}
+ private bool IsPermissionAutoApprovedForSession(string toolName, string target)
+ {
+ if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
+ return false;
+
+ var normalizedTarget = target.Trim();
+ var pathLikeTool = IsPathLikePermissionTool(toolName);
+ foreach (var rule in _sessionPermissionRules)
+ {
+ 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 void RememberPermissionRuleForSession(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.
+ }
+ }
+
+ _sessionPermissionRules.Add($"{toolName}|{scopedTarget}");
+ }
+
+ 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);
+ }
+
///
/// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림).
/// 앱 종료 시에는 ForceClose()를 사용합니다.
@@ -621,6 +723,85 @@ public partial class ChatWindow : Window
}
}
+ private void UpdateSidebarModeMenu()
+ {
+ if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null)
+ return;
+
+ SidebarChatMenu.Visibility = _activeTab == "Chat" ? Visibility.Visible : Visibility.Collapsed;
+ SidebarCoworkMenu.Visibility = _activeTab == "Cowork" ? Visibility.Visible : Visibility.Collapsed;
+ SidebarCodeMenu.Visibility = _activeTab == "Code" ? Visibility.Visible : 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 (SidebarChatFailedState != null)
+ SidebarChatFailedState.Text = _failedOnlyFilter ? "ON" : "OFF";
+ if (SidebarChatRunningState != null)
+ SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF";
+ }
+
+ private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ _selectedCategory = "";
+ UpdateCategoryLabel();
+ RefreshConversationList();
+ }
+
+ private void SidebarChatFailed_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ => BtnFailedOnlyFilter_Click(this, new RoutedEventArgs());
+
+ 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가 드래그를 처리하므로 별도 핸들러 불필요
@@ -633,6 +814,46 @@ public partial class ChatWindow : Window
source?.AddHook(WndProc);
}
+ private void ApplyAgentThemeResources()
+ {
+ var selected = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
+ var effective = selected switch
+ {
+ "light" => "AgentLight",
+ "dark" => "AgentDark",
+ _ => IsSystemDarkTheme() ? "AgentDark" : "AgentLight",
+ };
+
+ try
+ {
+ if (_agentThemeDictionary != null)
+ Resources.MergedDictionaries.Remove(_agentThemeDictionary);
+
+ _agentThemeDictionary = new ResourceDictionary
+ {
+ Source = new Uri($"pack://application:,,,/Themes/{effective}.xaml"),
+ };
+ Resources.MergedDictionaries.Insert(0, _agentThemeDictionary);
+ }
+ catch
+ {
+ // 테마 로드 실패 시 기본 리소스 유지
+ }
+ }
+
+ 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)
{
// WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보
@@ -711,8 +932,9 @@ public partial class ChatWindow : Window
if (_activeTab == "Chat") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
+ PersistPerTabUiState();
_activeTab = "Chat";
- _selectedCategory = ""; UpdateCategoryLabel();
+ RestorePerTabUiState();
UpdateTabUI();
}
@@ -721,8 +943,9 @@ public partial class ChatWindow : Window
if (_activeTab == "Cowork") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
+ PersistPerTabUiState();
_activeTab = "Cowork";
- _selectedCategory = ""; UpdateCategoryLabel();
+ RestorePerTabUiState();
UpdateTabUI();
}
@@ -731,8 +954,9 @@ public partial class ChatWindow : Window
if (_activeTab == "Code") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
+ PersistPerTabUiState();
_activeTab = "Code";
- _selectedCategory = ""; UpdateCategoryLabel();
+ RestorePerTabUiState();
UpdateTabUI();
}
@@ -764,6 +988,10 @@ public partial class ChatWindow : Window
private void UpdateTabUI()
{
+ ApplyAgentThemeResources();
+ ApplyExpressionLevelUi();
+ ApplySidebarStateForActiveTab(animated: false);
+
// 폴더 바는 Cowork/Code 탭에서만 표시
if (FolderBar != null)
FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed;
@@ -811,6 +1039,7 @@ public partial class ChatWindow : Window
// 탭별 프리셋 버튼 재구성
BuildTopicButtons();
+ UpdateSidebarModeMenu();
// 현재 대화를 해당 탭 대화로 전환
SwitchToTabConversation();
@@ -819,6 +1048,88 @@ public partial class ChatWindow : Window
ShowRandomTip();
}
+ 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" => 220,
+ "simple" => 120,
+ _ => 160,
+ };
+ }
+
+ 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 (BtnTemplateSelector != null)
+ {
+ BtnTemplateSelector.Padding = level == "simple"
+ ? new Thickness(8, 4, 8, 4)
+ : new Thickness(10, 6, 10, 6);
+ }
+
+ // simple/balanced 모드에서는 실패 전용 필터 UI를 숨겨 정보 과밀도를 줄입니다.
+ var showFailureFilter = level == "rich";
+ if (!showFailureFilter && _failedOnlyFilter)
+ {
+ _failedOnlyFilter = false;
+ RefreshConversationList();
+ UpdateConversationFailureFilterUi();
+ }
+
+ if (SidebarChatFailedRow != null)
+ SidebarChatFailedRow.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed;
+ if (BtnFailedOnlyFilter != null)
+ BtnFailedOnlyFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed;
+ if (BtnQuickFailedFilter != null)
+ BtnQuickFailedFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed;
+ }
+
private void SwitchToTabConversation()
{
var session = ChatSession;
@@ -1956,20 +2267,38 @@ public partial class ChatWindow : Window
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;
ToggleSidebarIcon.Text = "\uE76B";
- AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200);
+
+ if (animated)
+ {
+ AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200);
+ }
+ else
+ {
+ SidebarColumn.MinWidth = 200;
+ SidebarColumn.Width = new GridLength(270);
+ }
+ return;
}
- else
+
+ SidebarColumn.MinWidth = 0;
+ ToggleSidebarIcon.Text = "\uE76C";
+ if (animated)
{
- // 사이드바 닫기, 아이콘 바 표시
- SidebarColumn.MinWidth = 0;
- ToggleSidebarIcon.Text = "\uE76C";
AnimateSidebar(270, 0, () =>
{
SidebarPanel.Visibility = Visibility.Collapsed;
@@ -1977,6 +2306,13 @@ public partial class ChatWindow : Window
IconBarPanel.Visibility = Visibility.Visible;
});
}
+ else
+ {
+ SidebarColumn.Width = new GridLength(0);
+ SidebarPanel.Visibility = Visibility.Collapsed;
+ IconBarColumn.Width = new GridLength(52);
+ IconBarPanel.Visibility = Visibility.Visible;
+ }
}
private void AnimateSidebar(double from, double to, Action? onComplete = null)
@@ -2967,7 +3303,7 @@ public partial class ChatWindow : Window
private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e)
{
- _failedOnlyFilter = !_failedOnlyFilter;
+ _failedOnlyFilter = false;
UpdateConversationFailureFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
@@ -2975,7 +3311,7 @@ public partial class ChatWindow : Window
private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e)
{
- _runningOnlyFilter = !_runningOnlyFilter;
+ _runningOnlyFilter = false;
UpdateConversationRunningFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
@@ -3028,6 +3364,7 @@ public partial class ChatWindow : Window
: _failedConversationCount > 0
? $"실패한 에이전트 실행이 있는 대화 {_failedConversationCount}개 보기"
: "실패한 에이전트 실행이 있는 대화만 보기";
+ UpdateSidebarModeMenu();
}
private void UpdateConversationRunningFilterUi()
@@ -3055,6 +3392,7 @@ public partial class ChatWindow : Window
: _runningConversationCount > 0
? $"현재 실행 중인 대화 {_runningConversationCount}개 보기"
: "현재 실행 중인 대화만 보기";
+ UpdateSidebarModeMenu();
}
private void UpdateConversationSortUi()
@@ -3234,10 +3572,18 @@ public partial class ChatWindow : Window
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var itemBg = TryFindResource("ItemBackground") as Brush
+ ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
+ var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
+ var hintBg = TryFindResource("HintBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
if (isUser)
{
- // 사용자: 우측 정렬, 악센트 배경 + 편집 버튼
+ // 사용자: 우측 정렬, 중립 카드 + 편집 버튼
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
@@ -3247,14 +3593,16 @@ public partial class ChatWindow : Window
var bubble = new Border
{
- Background = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
- CornerRadius = new CornerRadius(16, 4, 16, 16),
+ Background = itemBg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(14),
Padding = new Thickness(16, 10, 16, 10),
Child = new TextBlock
{
Text = content,
FontSize = 13.5,
- Foreground = Brushes.White,
+ Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 21,
}
@@ -3270,7 +3618,7 @@ public partial class ChatWindow : Window
Margin = new Thickness(0, 2, 0, 0),
};
var capturedUserContent = content;
- var userBtnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var userBtnColor = secondaryText;
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
{
try { Clipboard.SetText(capturedUserContent); } catch { }
@@ -3308,7 +3656,7 @@ public partial class ChatWindow : Window
}
else
{
- // 어시스턴트: 좌측 정렬, 다크 배경
+ // 어시스턴트: 좌측 정렬, 정돈된 카드
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
@@ -3318,30 +3666,18 @@ public partial class ChatWindow : Window
if (animate) ApplyMessageEntryAnimation(container);
// AI 에이전트 이름 + 아이콘
- var (agentName, agentSymbol, agentColor) = GetAgentIdentity();
- var agentBrush = BrushFromHex(agentColor);
+ var (agentName, _, _) = GetAgentIdentity();
var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
// 다이아몬드 심볼 아이콘 (회전 애니메이션)
var iconBlock = new TextBlock
{
- Text = "◆",
- FontSize = 13,
- Foreground = agentBrush,
+ Text = "\uE8BD",
+ FontFamily = new FontFamily("Segoe MDL2 Assets"),
+ FontSize = 12,
+ Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
- RenderTransformOrigin = new Point(0.5, 0.5),
- RenderTransform = new RotateTransform(0),
};
- if (animate)
- {
- var spin = new System.Windows.Media.Animation.DoubleAnimation
- {
- From = 0, To = 360,
- Duration = TimeSpan.FromSeconds(1.2),
- EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
- };
- ((RotateTransform)iconBlock.RenderTransform).BeginAnimation(RotateTransform.AngleProperty, spin);
- }
headerSp.Children.Add(iconBlock);
headerSp.Children.Add(new TextBlock
@@ -3349,19 +3685,26 @@ public partial class ChatWindow : Window
Text = agentName,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
- Foreground = agentBrush,
+ Foreground = secondaryText,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
container.Children.Add(headerSp);
+ var contentCard = new Border
+ {
+ Background = itemBg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(14),
+ Padding = new Thickness(14, 10, 14, 10),
+ };
+ var contentStack = new StackPanel();
+
// 마크다운 렌더링 (파일 경로 강조 설정 연동)
var app = System.Windows.Application.Current as App;
MarkdownRenderer.EnableFilePathHighlight =
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
- var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
- var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
- var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
if (IsBranchContextMessage(content))
{
@@ -3369,10 +3712,10 @@ public partial class ChatWindow : Window
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
var branchCard = new Border
{
- Background = BrushFromHex("#EEF2FF"),
- BorderBrush = BrushFromHex("#C7D2FE"),
+ Background = hintBg,
+ BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(14),
+ CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(0, 0, 0, 4),
};
@@ -3382,10 +3725,10 @@ public partial class ChatWindow : Window
Text = "분기 컨텍스트",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
- Foreground = BrushFromHex("#4338CA"),
+ Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
- var branchMd = MarkdownRenderer.Render(content, BrushFromHex("#312E81"), secondaryText, accentBrush, BrushFromHex("#E0E7FF"));
+ var branchMd = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
branchStack.Children.Add(branchMd);
if (branchFiles.Count > 0)
{
@@ -3397,8 +3740,8 @@ public partial class ChatWindow : Window
{
var fileButton = new Button
{
- Background = BrushFromHex("#E0E7FF"),
- BorderBrush = BrushFromHex("#C7D2FE"),
+ Background = itemBg,
+ BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
@@ -3409,7 +3752,7 @@ public partial class ChatWindow : Window
Text = System.IO.Path.GetFileName(path),
FontSize = 10,
FontWeight = FontWeights.SemiBold,
- Foreground = BrushFromHex("#4338CA"),
+ Foreground = primaryText,
}
};
var capturedPath = path;
@@ -3428,8 +3771,8 @@ public partial class ChatWindow : Window
var followUpButton = new Button
{
- Background = BrushFromHex("#E0E7FF"),
- BorderBrush = BrushFromHex("#C7D2FE"),
+ Background = itemBg,
+ BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
@@ -3439,7 +3782,7 @@ public partial class ChatWindow : Window
Text = "후속 작업 큐에 넣기",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
- Foreground = BrushFromHex("#4338CA"),
+ Foreground = primaryText,
}
};
var capturedBranchRun = branchRun;
@@ -3448,8 +3791,8 @@ public partial class ChatWindow : Window
var timelineButton = new Button
{
- Background = BrushFromHex("#F5F3FF"),
- BorderBrush = BrushFromHex("#DDD6FE"),
+ Background = itemBg,
+ BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
@@ -3459,7 +3802,7 @@ public partial class ChatWindow : Window
Text = "관련 로그로 이동",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
- Foreground = BrushFromHex("#5B21B6"),
+ Foreground = primaryText,
}
};
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
@@ -3468,25 +3811,26 @@ public partial class ChatWindow : Window
branchStack.Children.Add(actionsWrap);
}
branchCard.Child = branchStack;
- container.Children.Add(branchCard);
+ contentStack.Children.Add(branchCard);
}
else
{
var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
- mdPanel.Margin = new Thickness(0, 0, 0, 4);
- container.Children.Add(mdPanel);
+ contentStack.Children.Add(mdPanel);
}
+ contentCard.Child = contentStack;
+ container.Children.Add(contentCard);
// 액션 버튼 바 (복사 / 좋아요 / 싫어요)
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 6, 0, 0)
+ Margin = new Thickness(0, 6, 0, 0),
+ Opacity = 0
};
- var btnColor = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
- var btnHoverColor = new SolidColorBrush(Color.FromRgb(0x8B, 0x90, 0xB0));
+ var btnColor = secondaryText;
var capturedContent = content;
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
@@ -3509,6 +3853,8 @@ public partial class ChatWindow : Window
});
container.Children.Add(actionBar);
+ container.MouseEnter += (_, _) => actionBar.Opacity = 1;
+ container.MouseLeave += (_, _) => actionBar.Opacity = 0;
// 우클릭 → 메시지 컨텍스트 메뉴
var aiContent = content;
@@ -3528,18 +3874,11 @@ public partial class ChatWindow : Window
/// 현재 테마의 체크 스타일을 반환합니다.
private string GetCheckStyle()
{
- var theme = (_settings.Settings.Launcher.Theme ?? "system").ToLowerInvariant();
+ var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
return theme switch
{
- "dark" or "system" => "circle", // 원 + 체크마크, 바운스
- "oled" => "glow", // 네온 글로우 원, 페이드인
- "light" => "roundrect", // 둥근 사각형, 슬라이드인
- "nord" => "diamond", // 다이아몬드(마름모), 스무스 스케일
- "catppuccin" => "pill", // 필 모양, 스프링 바운스
- "monokai" => "square", // 정사각형, 퀵 팝
- "sepia" => "stamp", // 도장 스타일 원, 회전 등장
- "alfred" => "minimal", // 미니멀 원, 우아한 페이드
- "alfredlight" => "minimal", // 미니멀 원, 우아한 페이드
+ "dark" or "system" => "circle",
+ "light" => "roundrect",
_ => "circle",
};
}
@@ -4396,6 +4735,64 @@ public partial class ChatWindow : Window
private static readonly Dictionary SlashCommands = new(StringComparer.OrdinalIgnoreCase)
{
// 공통
+ ["/clear"] = ("Clear", "__CLEAR__", "all"),
+ ["/new"] = ("New", "__NEW__", "all"),
+ ["/reset"] = ("Reset", "__RESET__", "all"),
+ ["/status"] = ("Status", "__STATUS__", "all"),
+ ["/model"] = ("Model", "__MODEL__", "all"),
+ ["/permissions"] = ("Permissions", "__PERMISSIONS__", "all"),
+ ["/allowed-tools"] = ("Allowed Tools", "__ALLOWED_TOOLS__", "all"),
+ ["/theme"] = ("Theme", "__THEME__", "all"),
+ ["/color"] = ("Color", "현재 테마/색상 구성을 점검하고 가독성 중심 개선안을 제시하세요.", "all"),
+ ["/config"] = ("Config", "현재 설정 상태를 점검하고, 목적에 맞는 권장 설정 변경안을 제시하세요.", "all"),
+ ["/settings"] = ("Settings", "__SETTINGS__", "all"),
+ ["/session"] = ("Session", "현재 세션의 핵심 맥락과 이어서 할 일을 5개 이내로 정리하세요.", "all"),
+ ["/usage"] = ("Usage", "현재 사용 흐름을 개선할 수 있는 사용 팁을 간결히 제시하세요.", "all"),
+ ["/upgrade"] = ("Upgrade", "현재 작업/구성 기준으로 안전한 업그레이드 체크리스트를 제시하세요.", "all"),
+ ["/copy"] = ("Copy", "사용자가 바로 복사해 쓸 수 있는 최종 결과 형태로 간결하게 답변하세요.", "all"),
+ ["/rename"] = ("Rename", "__RENAME__", "all"),
+ ["/feedback"] = ("Feedback", "__FEEDBACK__", "all"),
+ ["/skills"] = ("Skills", "__SKILLS__", "all"),
+ ["/sandbox-toggle"] = ("Sandbox Toggle", "__SANDBOX_TOGGLE__", "all"),
+ ["/statusline"] = ("Statusline", "__STATUSLINE__", "all"),
+ ["/heapdump"] = ("Heap Dump", "__HEAPDUMP__", "all"),
+ ["/passes"] = ("Passes", "__PASSES__", "all"),
+ ["/chrome"] = ("Chrome", "__CHROME__", "all"),
+ ["/stickers"] = ("Stickers", "__STICKERS__", "all"),
+ ["/thinkback"] = ("Thinkback", "__THINKBACK__", "all"),
+ ["/thinkback-play"] = ("Thinkback Play", "__THINKBACK_PLAY__", "all"),
+ ["/exit"] = ("Exit", "현재 대화를 마무리하기 위한 요약과 다음 재개 지점을 3줄 이내로 제시하세요.", "all"),
+ ["/login"] = ("Login", "인증/연결 점검 절차를 단계별로 안내하세요.", "all"),
+ ["/logout"] = ("Logout", "안전한 로그아웃 및 세션 정리 체크리스트를 안내하세요.", "all"),
+ ["/desktop"] = ("Desktop", "데스크톱 실행/연결 환경 점검 체크리스트를 제시하세요.", "all"),
+ ["/mobile"] = ("Mobile", "모바일 연동/사용 시 주의사항과 권장 구성을 제시하세요.", "all"),
+ ["/ide"] = ("IDE", "IDE 연동 상태를 점검하고 생산성 향상 팁을 제시하세요.", "all"),
+ ["/terminal-setup"] = ("Terminal Setup", "터미널 환경 초기 설정 체크리스트를 제시하세요.", "all"),
+ ["/add-dir"] = ("Add Dir", "작업 디렉터리 추가 시 권장 구조와 주의사항을 제시하세요.", "all"),
+ ["/advisor"] = ("Advisor", "현재 요청에 대한 실행 전략을 조언자 관점으로 간결히 제시하세요.", "all"),
+ ["/mcp"] = ("MCP", "__MCP__", "all"),
+ ["/agents"] = ("Agents", "현재 작업에 적합한 에이전트 분담 전략을 제시하세요.", "all"),
+ ["/plugin"] = ("Plugin", "플러그인 사용/구성 상태를 점검하고 권장 구성을 제시하세요.", "all"),
+ ["/reload-plugins"] = ("Reload Plugins", "플러그인 재로드 전후 점검 체크리스트를 제시하세요.", "all"),
+ ["/output-style"] = ("Output Style", "현재 작업 목적에 맞는 출력 스타일 가이드를 제시하세요.", "all"),
+ ["/remote-env"] = ("Remote Env", "원격 환경 연결 시 필수 점검 항목을 제시하세요.", "all"),
+ ["/install-github-app"] = ("Install GitHub App", "GitHub 앱 연동 절차와 점검 항목을 제시하세요.", "all"),
+ ["/install-slack-app"] = ("Install Slack App", "Slack 앱 연동 절차와 점검 항목을 제시하세요.", "all"),
+ ["/btw"] = ("BTW", "현재 맥락에서 추가로 유용한 보조 팁을 짧게 제시하세요.", "all"),
+ ["/keybindings"] = ("Keybindings", "주요 단축키/입력 효율화 팁을 현재 작업 기준으로 정리하세요.", "all"),
+ ["/privacy-settings"] = ("Privacy", "보안/개인정보 관점의 권장 설정을 점검표 형태로 제시하세요.", "all"),
+ ["/rate-limit-options"] = ("Rate Limit", "요청 한도 이슈를 줄이기 위한 설정/사용 전략을 제시하세요.", "all"),
+ ["/release-notes"] = ("Release Notes", "최근 변경사항을 릴리즈 노트 형식으로 정리하세요.", "all"),
+ ["/rewind"] = ("Rewind", "직전 변경 이전 상태로 되돌릴 때의 안전 절차를 제시하세요.", "all"),
+ ["/tag"] = ("Tag", "현재 작업 결과를 태깅/분류하는 기준을 제안하세요.", "all"),
+ ["/vim"] = ("Vim", "Vim 스타일 작업 효율 팁을 현재 작업에 맞춰 제시하세요.", "all"),
+ ["/plan"] = ("Plan", "요청을 바로 실행하지 말고 먼저 3~7단계 실행 계획을 간결히 제시한 뒤, 사용자 승인 후 실행하세요.", "all"),
+ ["/memory"] = ("Memory", "대화 맥락에서 지속적으로 참고할 핵심 사실을 정리하고, 필요한 경우 memory 도구 저장/갱신 관점으로 답변하세요.", "all"),
+ ["/context"] = ("Context", "현재 작업 컨텍스트(목표, 제약, 현재 상태, 다음 액션)를 구조화해 요약하세요.", "all"),
+ ["/stats"] = ("Stats", "__STATS__", "all"),
+ ["/cost"] = ("Cost", "__COST__", "all"),
+ ["/export"] = ("Export", "__EXPORT__", "all"),
+ ["/compact"] = ("Compact", "__COMPACT__", "all"),
["/summary"] = ("Summary", "사용자가 제공한 내용을 핵심 포인트 위주로 간결하게 요약해 주세요. 불릿 포인트 형식을 사용하세요.", "all"),
["/translate"] = ("Translate", "사용자가 제공한 텍스트를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요.", "all"),
["/explain"] = ("Explain", "사용자가 제공한 내용을 쉽고 자세하게 설명해 주세요. 필요하면 예시를 포함하세요.", "all"),
@@ -4403,11 +4800,27 @@ public partial class ChatWindow : Window
// Cowork/Code 전용
["/review"] = ("Code Review", "작업 폴더의 git diff를 분석하여 코드 리뷰를 수행해 주세요. code_review 도구를 사용하세요.", "dev"),
+ ["/commit"] = ("Commit", "__COMMIT__", "dev"),
+ ["/ultrareview"] = ("Ultra Review", "작업 폴더의 변경을 P0/P1 우선순위 기준으로 강도 높게 리뷰하고, 재현 조건/수정 방향/테스트 누락까지 제시하세요.", "dev"),
+ ["/security-review"] = ("Security Review", "작업 폴더 코드를 보안 중심으로 점검하고 취약점/개선안을 제시하세요.", "dev"),
["/pr"] = ("PR Summary", "작업 폴더의 변경사항을 PR 설명 형식으로 요약해 주세요. code_review(action: pr_summary) 도구를 사용하세요.", "dev"),
+ ["/pr-comments"] = ("PR Comments", "변경사항을 검토하고 PR 코멘트 형태로 개선 의견을 작성하세요.", "dev"),
["/test"] = ("Test", "작업 폴더의 코드에 대한 단위 테스트를 생성해 주세요. test_loop 도구를 사용하세요.", "dev"),
+ ["/verify"] = ("Verify", "__VERIFY__", "dev"),
["/structure"] = ("Structure", "작업 폴더의 프로젝트 구조를 분석하고 설명해 주세요. folder_map 도구를 사용하세요.", "dev"),
["/build"] = ("Build", "작업 폴더의 프로젝트를 빌드해 주세요. build_run 도구를 사용하세요.", "dev"),
["/search"] = ("Search", "작업 폴더에서 관련 코드를 검색해 주세요. search_codebase 도구를 사용하세요.", "dev"),
+ ["/diff"] = ("Diff", "현재 작업 폴더의 변경(diff)을 분석해 핵심 변경점과 리스크를 요약하세요.", "dev"),
+ ["/doctor"] = ("Doctor", "현재 프로젝트/환경의 잠재 이슈를 점검하고 우선순위별 개선안을 제시하세요.", "dev"),
+ ["/hooks"] = ("Hooks", "현재 훅/자동화 관련 설정을 점검하고 안전한 운영 가이드를 제시하세요.", "dev"),
+ ["/tasks"] = ("Tasks", "현재 작업을 태스크 단위로 분해해 우선순위와 다음 실행 항목을 제시하세요.", "dev"),
+ ["/branch"] = ("Branch", "현재 변경 기준으로 적절한 브랜치/커밋 전략을 제안하세요.", "dev"),
+ ["/files"] = ("Files", "현재 작업에서 핵심 파일과 변경 후보를 우선순위로 정리하세요.", "dev"),
+ ["/resume"] = ("Resume", "이전 작업 맥락을 복원한다고 가정하고 현재 상태/남은 작업을 이어서 정리하세요.", "dev"),
+ ["/init"] = ("Init", "프로젝트 온보딩 관점에서 초기 점검 체크리스트를 제시하세요.", "dev"),
+ ["/init-verifiers"] = ("Init Verifiers", "검증 자동화(빌드/테스트/리뷰) 초기 구성을 위한 체크리스트를 제시하세요.", "dev"),
+ ["/effort"] = ("Effort", "현재 요청의 난이도와 권장 추론 강도를 제안하세요.", "dev"),
+ ["/fast"] = ("Fast", "속도 우선 모드로 진행할 때의 최소 안전 절차를 제시하세요.", "dev"),
// 특수
["/help"] = ("Help", "__HELP__", "all"),
@@ -4489,8 +4902,8 @@ public partial class ChatWindow : Window
var totalSkills = _slashAllMatches.Count(x => x.IsSkill);
var totalCommands = total - totalSkills;
- SlashPopupTitle.Text = "명령/스킬 브라우저";
- SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개";
+ SlashPopupTitle.Text = "명령 팔레트";
+ SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · /compact 지원";
// 위 화살표
if (start > 0)
@@ -4788,15 +5201,17 @@ public partial class ChatWindow : Window
/// 슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.
private (string? slashSystem, string userText) ParseSlashCommand(string input)
{
- // 내장 명령어 우선
- foreach (var (cmd, (_, prompt, _)) in SlashCommands)
+ var trimmed = input.TrimStart();
+ if (trimmed.StartsWith("/"))
{
- if (input.StartsWith(cmd, StringComparison.OrdinalIgnoreCase))
+ var firstSpace = trimmed.IndexOf(' ');
+ var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim();
+ if (SlashCommands.TryGetValue(commandToken, out var entry))
{
// __HELP__는 특수 처리 (ParseSlashCommand에서는 무시)
- if (prompt == "__HELP__") return (null, input);
- var rest = input[cmd.Length..].Trim();
- return (prompt, string.IsNullOrEmpty(rest) ? cmd : rest);
+ if (entry.SystemPrompt == "__HELP__") return (null, input);
+ var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : "";
+ return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest);
}
}
@@ -5055,33 +5470,81 @@ public partial class ChatWindow : Window
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,
- ("/summary", "텍스트/문서를 핵심 포인트 중심으로 요약합니다."),
- ("/translate", "텍스트를 영어로 번역합니다. 원문의 톤을 유지합니다."),
- ("/explain", "내용을 쉽고 자세하게 설명합니다. 예시를 포함합니다."),
- ("/fix", "맞춤법, 문법, 자연스러운 표현을 교정합니다."));
+ AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg,
+ ("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."),
+ ("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다."),
+ ("/new", "새 대화를 시작합니다."),
+ ("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."),
+ ("/model", "모델 선택 패널을 엽니다."),
+ ("/permissions", "권한 설정 패널을 엽니다."),
+ ("/allowed-tools", "허용 도구(권한) 패널을 엽니다."),
+ ("/settings", "AX Agent 설정 창을 엽니다."),
+ ("/stats", "최근 호출의 토큰 통계를 표시합니다."),
+ ("/cost", "최근 호출의 추정 비용을 표시합니다."),
+ ("/export", "현재 대화를 파일로 내보냅니다."),
+ ("/clear", "현재 대화를 정리하고 새 대화를 시작합니다."));
+
+ AddHelpSection(contentPanel, "작업/운영 명령어", "claw-code 명령 체계를 참고한 운영 명령", 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", "Git diff를 분석하여 버그, 성능, 보안 이슈를 찾습니다."),
- ("/pr", "변경사항을 PR 설명 형식(Summary, Changes, Test Plan)으로 요약합니다."),
- ("/test", "코드에 대한 단위 테스트를 자동 생성합니다."),
- ("/structure", "프로젝트의 폴더/파일 구조를 분석하고 설명합니다."),
- ("/build", "프로젝트를 빌드합니다. 오류 발생 시 분석합니다."),
- ("/search", "자연어로 코드베이스를 시맨틱 검색합니다."));
+ 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", "외부 도구 연결 상태 점검"),
+ ("/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}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems);
+ AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", 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) });
+ tipPanel.Children.Add(new TextBlock { Text = "사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) });
var tips = new[]
{
"/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.",
@@ -5135,6 +5598,890 @@ public partial class ChatWindow : Window
}
}
+ 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();
+ AddMessageBubble("user", commandText);
+ InputBox.Text = "";
+ EmptyState.Visibility = Visibility.Collapsed;
+ ForceScrollToEnd();
+
+ var llm = _settings.Settings.Llm;
+ var beforeTokens = Services.TokenEstimator.EstimateMessages(conv.Messages);
+ var working = conv.Messages.ToList();
+ var condensed = await ContextCondenser.CondenseIfNeededAsync(
+ working,
+ _llm,
+ llm.MaxContextTokens,
+ llm.EnableProactiveContextCompact,
+ llm.ContextCompactTriggerPercent,
+ true,
+ CancellationToken.None);
+ var afterTokens = Services.TokenEstimator.EstimateMessages(working);
+
+ if (condensed)
+ {
+ lock (_convLock)
+ {
+ conv.Messages = working;
+ }
+ }
+
+ var assistantText = condensed
+ ? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}"
+ : "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
+
+ var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
+ lock (_convLock)
+ {
+ var session = ChatSession;
+ if (session != null)
+ {
+ session.AppendMessage(runTab, assistantMsg);
+ _currentConversation = session.CurrentConversation;
+ conv = _currentConversation!;
+ }
+ else
+ {
+ conv.Messages.Add(assistantMsg);
+ }
+ }
+
+ SaveLastConversations();
+ _storage.Save(conv);
+ AddMessageBubble("assistant", assistantText);
+ ForceScrollToEnd();
+ SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
+ }
+
+ 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 };
+ var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
+
+ lock (_convLock)
+ {
+ var session = ChatSession;
+ if (session != null)
+ {
+ session.AppendMessage(runTab, userMsg, useForTitle: true);
+ session.AppendMessage(runTab, assistantMsg);
+ _currentConversation = session.CurrentConversation;
+ conv = _currentConversation!;
+ }
+ else
+ {
+ conv.Messages.Add(userMsg);
+ conv.Messages.Add(assistantMsg);
+ }
+ }
+
+ SaveLastConversations();
+ _storage.Save(conv);
+ ChatSession?.RememberConversation(runTab, conv.Id);
+ UpdateChatTitle();
+ AddMessageBubble("user", commandText);
+ AddMessageBubble("assistant", assistantText);
+ InputBox.Text = "";
+ EmptyState.Visibility = Visibility.Collapsed;
+ ForceScrollToEnd();
+ }
+
+ 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 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 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
+ {
+ "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(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 ServerNames, List ToolNames)> ProbeChromeToolingAsync(CancellationToken ct = default)
+ {
+ var servers = _settings.Settings.Llm.McpServers ?? [];
+ var matches = servers.Where(IsChromeMcpCandidate).ToList();
+ var readyServers = new List();
+ var toolNames = new List();
+ 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(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 serverNames, IEnumerable 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 <= 1)
+ return (parts[0].Trim().ToLowerInvariant(), "");
+ return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
+ }
+
+ internal static (List SelectedFiles, string CommitMessage) ParseCommitCommandInput(string displayText)
+ {
+ var raw = (displayText ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "/commit", StringComparison.OrdinalIgnoreCase))
+ return (new List(), "");
+
+ if (!raw.StartsWith("files:", StringComparison.OrdinalIgnoreCase))
+ return (new List(), 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 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 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(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 { "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),
+ "status" => ("status", target),
+ _ => ("help", text),
+ };
+ }
+
+ private async Task BuildMcpRuntimeStatusTextAsync(
+ IEnumerable? 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
+ {
+ "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();
+ if (!IsMcpServerEnabled(server))
+ {
+ lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}");
+ continue;
+ }
+
+ if (!runtimeCheck)
+ {
+ lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}");
+ continue;
+ }
+
+ if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
+ {
+ lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}");
+ continue;
+ }
+
+ using var client = new McpClientService(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)}");
+ continue;
+ }
+
+ var toolCount = client.Tools.Count;
+ var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount);
+ lines.Add($"- {name} [{transport}] : {statusLabel}");
+ }
+
+ lines.Add("명령: /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all> (enable/disable는 세션 한정)");
+ 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 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 async Task HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
+ {
+ var llm = _settings.Settings.Llm;
+ var servers = llm.McpServers ?? [];
+ var (action, target) = ParseMcpAction(displayText);
+ if (action == "help")
+ return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>";
+
+ if (action == "status")
+ return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
+
+ if (servers.Count == 0)
+ return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요.";
+
+ 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 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>";
+ }
+
+ 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;
+ _settings.Save();
+ 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);
+ UpdateConditionalSkillActivation(reset: true);
+ _settings.Save();
+ _appState.LoadFromSettings(_settings);
+ RefreshInlineSettingsPanel();
+ }
+
+ OpenCommandSkillBrowser("/");
+ }
+
+ private string TogglePermissionModeFromSlash()
+ {
+ var llm = _settings.Settings.Llm;
+ llm.FilePermission = NextPermission(llm.FilePermission);
+ _settings.Save();
+ _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()
{
var rawText = InputBox.Text.Trim();
@@ -5153,6 +6500,283 @@ public partial class ChatWindow : Window
// 슬래시 명령어 처리
var (slashSystem, displayText) = ParseSlashCommand(text);
+ 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))
+ {
+ AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText());
+ return;
+ }
+ if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
+ {
+ var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
+ if (permAction is "ask" or "auto" or "deny")
+ {
+ var next = permAction switch
+ {
+ "ask" => PermissionModeCatalog.Ask,
+ "auto" => PermissionModeCatalog.Auto,
+ _ => PermissionModeCatalog.Deny,
+ };
+ _settings.Settings.Llm.FilePermission = next;
+ _settings.Save();
+ _appState.LoadFromSettings(_settings);
+ UpdatePermissionUI();
+ SaveConversationSettings();
+ RefreshInlineSettingsPanel();
+ AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {next}로 변경했습니다.");
+ return;
+ }
+
+ if (permAction == "status")
+ {
+ var mode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
+ AppendLocalSlashResult(_activeTab, "/permissions", $"현재 권한 모드: {mode}\n사용법: /permissions ask|auto|deny|status");
+ return;
+ }
+
+ BtnPermission_Click(this, new RoutedEventArgs());
+ AppendLocalSlashResult(_activeTab, "/permissions", "권한 설정 팝업을 열었습니다. (사용법: /permissions ask|auto|deny|status)");
+ return;
+ }
+ if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
+ {
+ BtnPermission_Click(this, new RoutedEventArgs());
+ AppendLocalSlashResult(_activeTab, "/allowed-tools", "허용 도구(권한) 설정 팝업을 열었습니다.");
+ 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")
+ {
+ BtnPermission_Click(this, new RoutedEventArgs());
+ AppendLocalSlashResult(_activeTab, "/settings", "권한 설정 팝업을 열었습니다.");
+ 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", $"권한 모드를 {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, _streamCts?.Token ?? CancellationToken.None);
+ AppendLocalSlashResult(_activeTab, "/commit", commitResult);
+ return;
+ }
+ if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal))
+ {
+ await ExecuteManualCompactAsync("/compact", _activeTab);
+ return;
+ }
+
// 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처
var originTab = _activeTab;
var runTab = originTab;
@@ -5310,6 +6934,21 @@ public partial class ChatWindow : Window
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
}
+ // ── 전송 전 컨텍스트 사전 압축 ──
+ {
+ var llm = _settings.Settings.Llm;
+ var condensed = await ContextCondenser.CondenseIfNeededAsync(
+ sendMessages,
+ _llm,
+ llm.MaxContextTokens,
+ llm.EnableProactiveContextCompact,
+ llm.ContextCompactTriggerPercent,
+ false,
+ _streamCts!.Token);
+ if (condensed)
+ SetStatus("컨텍스트를 사전 정리했습니다", spinning: true);
+ }
+
// ── 자동 모델 라우팅 ──
if (_settings.Settings.Llm.EnableAutoRouter)
{
@@ -6084,8 +7723,8 @@ public partial class ChatWindow : Window
private void ApplyConversationListPreferences(ChatConversation? conv)
{
- _failedOnlyFilter = conv?.ConversationFailedOnlyFilter ?? false;
- _runningOnlyFilter = conv?.ConversationRunningOnlyFilter ?? false;
+ _failedOnlyFilter = false;
+ _runningOnlyFilter = false;
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi();
@@ -6414,19 +8053,93 @@ public partial class ChatWindow : Window
/// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.
private void AddDecisionButtons(TaskCompletionSource tcs, List options)
{
+ var expressionLevel = GetAgentUiExpressionLevel();
+ var showDetailedCopy = expressionLevel != "simple";
+ var showRichHint = expressionLevel == "rich";
+
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = ((SolidColorBrush)accentBrush).Color;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var itemBg = TryFindResource("ItemBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
+ var hoverBg = TryFindResource("ItemHoverBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ var borderBrush = TryFindResource("BorderColor") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B));
var container = new Border
{
- Margin = new Thickness(40, 2, 80, 6),
+ Margin = expressionLevel == "simple"
+ ? new Thickness(40, 2, 120, 6)
+ : new Thickness(40, 2, 80, 6),
HorizontalAlignment = HorizontalAlignment.Left,
- MaxWidth = 560,
+ MaxWidth = expressionLevel == "simple" ? 460 : 560,
+ Background = itemBg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(12, 10, 12, 10),
};
var outerStack = new StackPanel();
+ outerStack.Children.Add(new TextBlock
+ {
+ Text = "실행 계획 승인 요청",
+ FontSize = 12.5,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = primaryText,
+ });
+ if (showDetailedCopy)
+ {
+ outerStack.Children.Add(new TextBlock
+ {
+ Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.",
+ FontSize = 11.5,
+ Foreground = secondaryText,
+ Margin = new Thickness(0, 2, 0, 8),
+ TextWrapping = TextWrapping.Wrap,
+ });
+ }
+
+ if (showDetailedCopy && options.Count > 0)
+ {
+ var optionCandidates = new List();
+ foreach (var option in options)
+ {
+ if (string.IsNullOrWhiteSpace(option))
+ continue;
+
+ optionCandidates.Add(option.Trim());
+ if (optionCandidates.Count >= 3)
+ break;
+ }
+ var optionHint = string.Join(" · ", optionCandidates);
+ if (!string.IsNullOrWhiteSpace(optionHint))
+ {
+ outerStack.Children.Add(new TextBlock
+ {
+ Text = $"선택지: {optionHint}",
+ FontSize = 11,
+ Foreground = secondaryText,
+ Margin = new Thickness(0, 0, 0, 8),
+ TextWrapping = TextWrapping.Wrap,
+ });
+ }
+ }
+ if (showRichHint)
+ {
+ outerStack.Children.Add(new TextBlock
+ {
+ Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.",
+ FontSize = 11,
+ Foreground = secondaryText,
+ Margin = new Thickness(0, 0, 0, 8),
+ TextWrapping = TextWrapping.Wrap,
+ });
+ }
+
// 버튼 행
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) };
@@ -6445,7 +8158,13 @@ public partial class ChatWindow : Window
Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
- approveSp.Children.Add(new TextBlock { Text = "승인", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White });
+ approveSp.Children.Add(new TextBlock
+ {
+ Text = "승인 후 실행",
+ FontSize = 12.5,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = Brushes.White
+ });
approveBtn.Child = approveSp;
ApplyMenuItemHover(approveBtn);
approveBtn.MouseLeftButtonUp += (_, _) =>
@@ -6458,7 +8177,7 @@ public partial class ChatWindow : Window
// 수정 요청 버튼
var editBtn = new Border
{
- Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)),
+ Background = Brushes.Transparent,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
@@ -6472,7 +8191,7 @@ public partial class ChatWindow : Window
Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
- editSp.Children.Add(new TextBlock { Text = "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
+ editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
editBtn.Child = editSp;
ApplyMenuItemHover(editBtn);
@@ -6480,7 +8199,9 @@ public partial class ChatWindow : Window
var editInputPanel = new Border
{
Visibility = Visibility.Collapsed,
- Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8F9FC")),
+ Background = Brushes.Transparent,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 8, 0, 0),
@@ -6488,7 +8209,7 @@ public partial class ChatWindow : Window
var editInputStack = new StackPanel();
editInputStack.Children.Add(new TextBlock
{
- Text = "수정 사항을 입력하세요:",
+ Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:",
FontSize = 11.5, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
@@ -6499,7 +8220,9 @@ public partial class ChatWindow : Window
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 12.5,
- Background = Brushes.White,
+ Background = itemBg,
+ Foreground = primaryText,
+ CaretBrush = primaryText,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 6, 8, 6),
@@ -6515,7 +8238,13 @@ public partial class ChatWindow : Window
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
};
- submitEditBtn.Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
+ submitEditBtn.Child = new TextBlock
+ {
+ Text = "피드백 전송",
+ FontSize = 12,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = Brushes.White
+ };
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
submitEditBtn.MouseLeftButtonUp += (_, _) =>
{
@@ -6663,6 +8392,12 @@ public partial class ChatWindow : Window
{
agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix);
}
+ else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(result))
+ {
+ agentDecision = $"수정 요청: {result.Trim()}";
+ }
// 승인된 경우 — 실행 모드로 전환
if (result == null) // null = 승인
@@ -7237,8 +8972,8 @@ public partial class ChatWindow : Window
: evt.Type switch
{
AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"),
- AgentEventType.PermissionRequest => ("\uE897", "Permission", "#FFF7ED", "#C2410C"),
- AgentEventType.PermissionGranted => ("\uE73E", "Approved", "#ECFDF5", "#059669"),
+ AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true),
+ AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false),
AgentEventType.PermissionDenied => ("\uE783", "Denied", "#FEF2F2", "#DC2626"),
AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary),
AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"),
@@ -10163,8 +11898,7 @@ public partial class ChatWindow : Window
private void BtnSettings_Click(object sender, RoutedEventArgs e)
{
- if (System.Windows.Application.Current is App app)
- app.OpenSettingsFromChat();
+ OpenAgentSettingsWindow();
}
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
@@ -10423,13 +12157,35 @@ public partial class ChatWindow : Window
private void BtnModelSelector_Click(object sender, RoutedEventArgs e)
{
- InlineSettingsPanel.Visibility = InlineSettingsPanel.Visibility == Visibility.Visible
- ? Visibility.Collapsed
- : Visibility.Visible;
- if (InlineSettingsPanel.Visibility == Visibility.Visible)
+ OpenAgentSettingsWindow();
+ }
+
+ private void OpenAgentSettingsWindow()
+ {
+ var win = new AgentSettingsWindow(_settings)
{
- RefreshInlineSettingsPanel();
- }
+ Owner = this,
+ };
+ win.Resources.MergedDictionaries.Add(Resources);
+
+ var changed = win.ShowDialog() == true;
+ if (!changed)
+ return;
+
+ _appState.LoadFromSettings(_settings);
+ ApplyAgentThemeResources();
+ UpdateSidebarModeMenu();
+ UpdateModelLabel();
+ RefreshInlineSettingsPanel();
+ UpdateTabUI();
+ ShowToast("AX Agent 설정이 저장되었습니다.");
+ }
+
+ public void OpenAgentSettingsFromExternal()
+ {
+ Show();
+ Activate();
+ OpenAgentSettingsWindow();
}
private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e)
@@ -10546,9 +12302,9 @@ public partial class ChatWindow : Window
{
StopStreamingIfActive();
SaveCurrentTabConversationId();
+ PersistPerTabUiState();
_activeTab = targetTab;
- _selectedCategory = "";
- UpdateCategoryLabel();
+ RestorePerTabUiState();
UpdateTabUI();
if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase))
@@ -11858,6 +13614,30 @@ public partial class ChatWindow : Window
return ("\uE70F", "Plan Decision", "#FFF7ED", "#C2410C");
}
+ private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending)
+ {
+ var tool = toolName?.Trim().ToLowerInvariant() ?? "";
+
+ if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell"))
+ return pending
+ ? ("\uE756", "Command Permission", "#FEF2F2", "#DC2626")
+ : ("\uE73E", "Command Approved", "#ECFDF5", "#059669");
+
+ if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
+ return pending
+ ? ("\uE774", "Network Permission", "#FFF7ED", "#C2410C")
+ : ("\uE73E", "Network Approved", "#ECFDF5", "#059669");
+
+ if (tool.Contains("file"))
+ return pending
+ ? ("\uE8A5", "File Permission", "#FFF7ED", "#C2410C")
+ : ("\uE73E", "File Approved", "#ECFDF5", "#059669");
+
+ return pending
+ ? ("\uE897", "Permission", "#FFF7ED", "#C2410C")
+ : ("\uE73E", "Approved", "#ECFDF5", "#059669");
+ }
+
private static bool IsDecisionPending(string? summary)
{
var text = summary?.Trim() ?? "";