diff --git a/README.md b/README.md index b49fedc..c741f77 100644 --- a/README.md +++ b/README.md @@ -733,6 +733,12 @@ ow + toggle 시각 언어로 통일했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - 업데이트: 2026-04-05 12:06 (KST) - 업데이트: 2026-04-05 12:09 (KST) +- AX Agent 채팅 UI는 `claw-code` 기준으로 다시 정리하기 전에 현재 상태를 `etc/chat-ui-backup/2026-04-05-1215/`에 백업했습니다. 이 백업에는 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 기준본이 포함되어 있습니다. +- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 메시지 컬럼과 빈 상태 폭을 `920px` 축으로 맞추고, 컴포저를 `760px` 기준으로 넓히면서 입력 셸을 하나의 안정적인 하단 컬럼으로 다시 정리했습니다. 같은 수정에서 컴포저 안의 `대화 내보내기` 버튼은 숨겨 `claw-code`처럼 입력과 전송에 더 집중된 구조로 단순화했습니다. +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 입력창 높이 계산은 이제 실제 줄바꿈 수만 기준으로 `Height`를 직접 다시 잡습니다. 전송 후에도 남아 있던 과도한 높이를 줄이고, `Shift+Enter`로 개행이 생길 때만 높이가 커지도록 더 강하게 고정했습니다. +- 같은 파일에서 `메시지 편집 후 재생성`, `피드백 후 재생성` 경로도 직접 `AddMessageBubble(...)`를 꽂지 않고 `RenderMessages()` 축으로 다시 돌리게 맞췄습니다. 재생성 경로 자체도 `Cowork/Code`에서는 일반 LLM 호출이 아니라 `ResolveExecutionMode(...)` + `RunAgentLoopAsync(...)`를 타도록 바꿔, 코워크/코드가 채팅 재생성 때 일반 Chat 경로로 잘못 떨어지던 문제를 줄였습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 +- 업데이트: 2026-04-05 12:24 (KST) --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 95cfc33..3a3ab7f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4482,3 +4482,10 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 이어서 수동 컨텍스트 압축 결과와 `/slash` 로컬 응답 경로도 conversation/session에 먼저 반영한 뒤 `RenderMessages()` 기준으로 다시 그리도록 맞췄습니다. 이 변경으로 Chat/Cowork/Code에서 “일반 전송은 모델 렌더, 로컬 응답은 직접 버블 주입”으로 두 경로가 섞여 있던 상태를 더 줄였고, 코워크/코드 엔진 정상화 작업을 계속 진행할 수 있는 공통 렌더 축을 확보했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0 - 업데이트: 2026-04-05 12:09 (KST) +- 업데이트: 2026-04-05 12:24 (KST) +- AX Agent 채팅 UI/엔진 재작업 전에 현재 기준본을 `etc/chat-ui-backup/2026-04-05-1215/`에 백업했습니다. 백업 범위는 `ChatWindow.xaml`, `ChatWindow.xaml.cs`, `Services/Agent/AxAgentExecutionEngine.cs` 3개 파일이며, `claw-code` 기준 UI/엔진 전환 중 회귀 시 즉시 비교할 수 있도록 남겼습니다. +- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 메시지 컬럼과 빈 상태 영역 폭을 `920px` 기준으로 정리하고, 컴포저는 `760px` 고정 축으로 넓혔습니다. 입력 셸의 `InputBorder`는 라운드/패딩을 단순화하고, 컴포저 안의 `대화 내보내기` 버튼은 숨겨 `claw-code`처럼 메시지와 입력 축 중심의 레이아웃으로 더 가깝게 맞췄습니다. +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `UpdateInputBoxHeight()`는 `TextBox.MinLines/MaxLines`만 믿지 않고 실제 줄바꿈 개수 기준으로 `Height`를 직접 다시 계산하도록 바꿨습니다. 이 수정은 Chat/Cowork/Code 공통으로 “전송 후 빈 상태인데 입력창 높이가 남아 있는” 버그를 더 강하게 억제하기 위한 것입니다. +- 메시지 편집 후 재생성과 수정 피드백 후 재생성도 직접 `AddMessageBubble(...)`를 삽입하지 않고, conversation 모델 갱신 후 `RenderMessages(preserveViewport: true)`로 다시 그리게 정리했습니다. 그래서 재생성 직전에 UI만 앞서 나가면서 모델과 화면이 어긋나는 경로를 더 줄였습니다. +- `SendRegenerateAsync(...)`는 `claw-code` 기준의 단일 실행 축에 더 가깝게 맞췄습니다. 이제 Cowork/Code 재생성도 `ResolveExecutionMode(...)`와 `BuildPromptStack(...)`를 거쳐 필요 시 `RunAgentLoopAsync(...)`를 사용하고, 최종 assistant 텍스트만 커밋합니다. 이전처럼 재생성만 일반 `_llm.SendAsync(...)`로 빠져 코워크/코드가 Chat 경로로 잘못 처리되던 분기를 제거했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0 diff --git a/etc/chat-ui-backup/2026-04-05-1215/AxAgentExecutionEngine.cs b/etc/chat-ui-backup/2026-04-05-1215/AxAgentExecutionEngine.cs new file mode 100644 index 0000000..0638697 --- /dev/null +++ b/etc/chat-ui-backup/2026-04-05-1215/AxAgentExecutionEngine.cs @@ -0,0 +1,128 @@ +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +/// +/// AX Agent execution-prep engine. +/// Inspired by the `claw-code` split between input preparation and session execution, +/// so the UI layer stops owning message assembly and final assistant commit logic. +/// +public sealed class AxAgentExecutionEngine +{ + public sealed record PreparedTurn(List Messages); + public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt); + + public IReadOnlyList BuildPromptStack( + string? conversationSystem, + string? slashSystem, + string? taskSystem = null) + { + var prompts = new List(); + if (!string.IsNullOrWhiteSpace(conversationSystem)) + prompts.Add(conversationSystem.Trim()); + if (!string.IsNullOrWhiteSpace(slashSystem)) + prompts.Add(slashSystem.Trim()); + if (!string.IsNullOrWhiteSpace(taskSystem)) + prompts.Add(taskSystem.Trim()); + return prompts; + } + + public ExecutionMode ResolveExecutionMode( + string runTab, + bool streamingEnabled, + string resolvedService, + string? coworkSystemPrompt, + string? codeSystemPrompt) + { + if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)) + return new ExecutionMode(true, false, coworkSystemPrompt); + + if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) + return new ExecutionMode(true, false, codeSystemPrompt); + + return new ExecutionMode(false, false, null); + } + + public PreparedTurn PrepareTurn( + ChatConversation conversation, + IEnumerable systemPrompts, + string? fileContext = null, + IReadOnlyList? images = null) + { + var outbound = conversation.Messages + .Select(CloneMessage) + .ToList(); + + if (!string.IsNullOrWhiteSpace(fileContext)) + { + var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); + if (lastUserIndex >= 0) + outbound[lastUserIndex].Content = (outbound[lastUserIndex].Content ?? string.Empty) + fileContext; + } + + if (images is { Count: > 0 }) + { + var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); + if (lastUserIndex >= 0) + outbound[lastUserIndex].Images = images.Select(CloneImage).ToList(); + } + + var promptList = systemPrompts + .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) + .Select(prompt => prompt!.Trim()) + .ToList(); + + for (var i = promptList.Count - 1; i >= 0; i--) + outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] }); + + return new PreparedTurn(outbound); + } + + public ChatMessage CommitAssistantMessage( + ChatSessionStateService? session, + ChatConversation conversation, + string tab, + string content, + ChatStorageService? storage = null) + { + var assistant = new ChatMessage + { + Role = "assistant", + Content = content, + }; + + if (session != null) + { + session.AppendMessage(tab, assistant, storage); + return assistant; + } + + conversation.Messages.Add(assistant); + conversation.UpdatedAt = DateTime.Now; + return assistant; + } + + private static ChatMessage CloneMessage(ChatMessage source) + { + return new ChatMessage + { + Role = source.Role, + Content = source.Content, + Timestamp = source.Timestamp, + MetaRunId = source.MetaRunId, + AttachedFiles = source.AttachedFiles?.ToList(), + Images = source.Images?.Select(CloneImage).ToList(), + }; + } + + private static ImageAttachment CloneImage(ImageAttachment source) + { + return new ImageAttachment + { + Base64 = source.Base64, + MimeType = source.MimeType, + FileName = source.FileName, + }; + } +} diff --git a/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml b/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml new file mode 100644 index 0000000..fc5e02f --- /dev/null +++ b/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml @@ -0,0 +1,5175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 현재 대화에서 사용할 AI 서비스 연결을 선택합니다. 사내 모드에서는 내부 연결만 유지하는 데도 함께 쓰입니다. + + + + + + + + + + + + + + 선택한 서비스 안에서 실제 응답을 생성할 모델입니다. 모델에 따라 속도, 문장 길이, 코드 품질이 달라질 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ollama, vLLM 같은 연결형 서비스의 기본 주소입니다. 사내 서버나 프록시를 쓰는 경우 이 값을 바꿔 연결 대상을 맞춥니다. + + + + + + + + + + + + + + + Gemini, Claude처럼 인증이 필요한 서비스만 입력합니다. 빈 값이면 인증 없이 접속 가능한 연결만 사용합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + 호출 간격을 조정해 제한이 있는 환경에서 더 안정적으로 동작하게 합니다. + + + + + + + + + + + + + + + + + + + + + + + + 계획과 확인 단계를 얼마나 신중하게 가져갈지 정합니다. + + + + + + + + + + + + + + + + + + + + + + + + + 실행 전에 계획 단계를 둘지 바로 작업으로 넘어갈지 선택합니다. + + + + + + + + + + + + + + + + + + + + + + + + + 파일 접근과 실행 요청을 어떤 기준으로 승인할지 결정합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 문서나 결과물을 파일로 만들 때 우선 고려할 출력 형식입니다. 자동으로 두면 요청 내용을 보고 가장 맞는 형식을 선택합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HTML 보고서나 미리보기 화면을 만들 때 기본으로 적용할 분위기입니다. 같은 내용도 보고서 톤과 카드 스타일이 달라집니다. + + + + + + + + + + + + + + + + + + + + + 대화 내용을 PDF로 저장할 때 기본으로 제안할 폴더입니다. 비워 두면 바탕화면을 사용합니다. + + + + + + + + + + + + + + + + + + + + + + 스크린샷 붙여넣기와 이미지 파일 첨부를 허용합니다. 끄면 텍스트와 일반 파일만 다룹니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 사내 모드는 외부 검색, 외부 URL 열기, 외부 호출을 더 강하게 제한합니다. 사외 모드는 외부 서비스와 웹 작업까지 허용하는 확장 모드입니다. + + + + + + + + + + + + + + + + + + + + + 현재 폴더의 파일과 문맥을 응답에 얼마나 적극적으로 활용할지 정합니다. 적극 활용은 관련 파일을 더 넓게 참조하고, 활용하지 않음은 현재 입력 중심으로만 답합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + 컨텍스트 사용량이 이 비율에 도달하면 오래된 대화를 먼저 요약해 공간을 비웁니다. 값을 낮추면 더 일찍 압축해 안정성이 올라갑니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 한 번의 작업에서 AX Agent가 유지할 수 있는 최대 문맥 크기입니다. 너무 낮으면 긴 대화가 빨리 잘리고, 너무 높으면 느려질 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 낮을수록 더 일관되고 정확한 답변 쪽으로, 높을수록 더 자유롭고 창의적인 답변 쪽으로 기울어집니다. 일반 업무형 응답은 0.7 전후가 기본값입니다. + + + + + + + + + + + + + + + + + + + + + + + + 도구 실패나 일시 오류가 났을 때 자동으로 다시 시도할 횟수입니다. 높을수록 복구 기회는 늘지만 전체 실행 시간도 길어집니다. + + + + + + + + + + + + + + + + + + + + + + + + 계획, 실행, 검토 단계를 몇 번까지 반복할지 정합니다. 큰 작업일수록 여유를 두는 편이 좋지만, 너무 높으면 과도하게 오래 돌 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + + + 무료 호출 제한이 있는 서비스에서 연속 요청 사이에 둘 최소 대기 시간입니다. 호출이 막히는 환경일수록 조금 넉넉하게 두는 편이 안정적입니다. + + + + + + + + + + + + + + + + + + + + + + + + 큰 작업을 병렬로 나눌 때 동시에 돌릴 최대 서브에이전트 수입니다. 값을 높이면 빠를 수 있지만 CPU와 메모리 사용량이 늘어납니다. + + + + + + + + + + + + + + + + + + + + + + + + 채팅창에 도구 호출과 결과를 얼마나 자세히 보여줄지 정합니다. 디버그는 가장 자세하지만 작업 흐름이 길게 보일 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + + 계획 변경 개수가 어느 정도일 때 중간, 대폭 배지를 붙일지 정합니다. 검토 기준을 팀 스타일에 맞게 조절할 때 씁니다. + + + + + + + + + + + + + + + + + + + + + + + 전체 계획 대비 변경 비율이 어느 수준일 때 중간, 대폭으로 볼지 정합니다. 작업 크기보다 영향도를 기준으로 보고 싶을 때 적합합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 긴 대화에서 오래된 내용을 먼저 요약해 현재 작업에 필요한 맥락만 남깁니다. 컨텍스트 초과로 답변이 끊기는 상황을 줄일 때 유용합니다. + + + + + + + + + + + + + + + + + + + + + + + 로컬 스킬 파일을 읽어 슬래시 명령, 업무 템플릿, 자동 워크플로우를 확장합니다. 끄면 기본 내장 기능만 사용합니다. + + + + + + + + + + + + + + + + + + + + + + + 도구 실행 직전과 직후에 사용자 정의 훅을 연결합니다. 백업, 로그 기록, 검증 스크립트 같은 후처리를 자동화할 때 씁니다. + + + + + + + + + + + + + + + + + + + + + + + 훅이나 확장 규칙이 입력을 더 안전하거나 명확하게 다듬은 경우, 그 수정본을 실제 실행 프롬프트에 반영합니다. + + + + + + + + + + + + + + + + + + + + + + + 확장 규칙이 읽기 전용, 승인 필요 같은 실행 권한을 조정하면 그 결과를 다음 도구 단계에 바로 이어받습니다. + + + + + + + + + + + + + + + + + + + + + + + 문서, 보고서, 표 같은 업무 결과를 한 번 더 확인해 빠진 항목이나 형식 오류를 줄입니다. 완성도는 올라가지만 한 단계 더 검토하므로 시간이 조금 더 듭니다. + + + + + + + + + + + + + + + + + + + + + + + 코드 수정 후 다시 읽고 점검해 누락된 변경, 문법 문제, 테스트 누락 가능성을 줄입니다. 안전성은 오르지만 응답 시간이 약간 늘 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + Git diff 분석, 파일 리뷰, PR 요약 생성 도구를 코드 탭에서 사용할 수 있게 합니다. + + + + + + + + + + + + + + + + + + + + + + + 서로 충돌하지 않는 읽기, 조회, 분석 작업을 동시에 실행해 응답 속도를 높입니다. 순서 보장이 중요한 작업은 자동으로 직렬 처리됩니다. + + + + + + + + + + + + + + + + + + + + + + + 작업 폴더 안의 규칙 문서나 지침 파일을 읽어 코딩 스타일, 금지사항, 작업 순서를 응답에 자동 반영합니다. + + + + + + + + + + + + + + + + + + + + + + + 폴더별 메모와 이전 합의사항을 유지해 반복 요청에서 같은 설명을 다시 하지 않도록 돕습니다. 장기 작업을 이어갈 때 특히 유용합니다. + + + + + + + + + + + + + + + + + + + + + + + 코드 탭에서 작업을 바로 실행하기 전에 계획 단계로 분리해 검토할 수 있게 합니다. 큰 변경이나 위험 작업을 다룰 때 안전장치 역할을 합니다. + + + + + + + + + + + + + + + + + + + + + + + 브랜치와 작업 디렉터리를 분리해 실험 작업을 안전하게 진행하는 도구입니다. 현재 작업을 건드리지 않고 병렬 검토를 하고 싶을 때 유용합니다. + + + + + + + + + + + + + + + + + + + + + + + 큰 작업을 역할별 단위로 쪼개고 병렬 진행 흐름을 관리하는 도구입니다. 리뷰, 구현, 검증을 나눠 다룰 때 도움이 됩니다. + + + + + + + + + + + + + + + + + + + + + + + 정기 실행 작업이나 예약형 자동화를 다룰 때 쓰는 도구입니다. 반복 점검, 예약 생성, 상태 확인 같은 흐름을 연결합니다. + + + + + + + + + + + + + + + + AI가 파일 읽기/쓰기, 검색, Git 같은 도구를 실행할 때 + Pre 훅과 Post 훅으로 사용자 스크립트를 자동 실행할 수 있습니다. + + 동작 흐름: AI 판단 → Pre 훅 → 도구 실행 → Post 훅 → 다음 단계 + + 활용 예시 + • 파일 수정 후 자동 백업 + • Git 커밋 전 린트/테스트 실행 + • 특정 도구 실행 시 로그 기록 또는 알림 표시 + + 훅 실패나 타임아웃이 나더라도 AX Agent 작업은 계속 진행됩니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `.skill.md` 또는 `SKILL.md` 파일을 두면 슬래시 명령(`/`)으로 스킬을 호출할 수 있습니다. + 기본 스킬 폴더: %APPDATA%\AxCopilot\skills\ + + 이 탭에서 함께 관리하는 항목 + • 추가 스킬 폴더 + • 슬래시 팝업 표시 개수 + • 드래그 앤 드롭 AI 액션 + • 응답 실패 대비 폴백 모델 + • MCP 서버 연결 상태와 확장 도구 연동 + + MCP 서버를 연결하면 브라우저, 파일시스템, 외부 업무 도구 같은 확장 기능을 AX Agent가 사용할 수 있습니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml.cs b/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml.cs new file mode 100644 index 0000000..35a09aa --- /dev/null +++ b/etc/chat-ui-backup/2026-04-05-1215/ChatWindow.xaml.cs @@ -0,0 +1,21806 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using System.IO; +using Microsoft.Win32; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +/// AX Agent 창. 데스크톱 코파일럿 스타일 — 사이드바 + 카테고리 분류 + 타임라인. +public partial class ChatWindow : Window +{ + private const string UnifiedAdminPassword = "axgo123!"; + private bool IsOverlayEncryptionEnabled => _settings.Settings.Llm.EncryptionEnabled; + private readonly SettingsService _settings; + private readonly ChatStorageService _storage; + private readonly DraftQueueProcessorService _draftQueueProcessor = new(); + private readonly LlmService _llm; + private readonly ToolRegistry _toolRegistry; + private readonly AgentLoopService _agentLoop; + private readonly AxAgentExecutionEngine _chatEngine; + private readonly ModelRouterService _router; + private AppStateService _appState => (System.Windows.Application.Current as App)?.AppState ?? new AppStateService(); + private readonly object _convLock = new(); + private readonly Dictionary _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase); + private ChatConversation? _currentConversation; + private string? _runningDraftId; + private CancellationTokenSource? _streamCts; + 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 readonly Dictionary _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase); + private bool _failedOnlyFilter; + private bool _runningOnlyFilter; + private int _failedConversationCount; + private int _runningConversationCount; + private int _spotlightConversationCount; + private bool _sortConversationsByRecent = false; + private bool _isInlineSettingsSyncing; + private string? _streamRunTab; + private bool _forceClose = false; // 앱 종료 시 진짜 닫기 플래그 + + // 스트리밍 UI — 커서 깜빡임 + 로딩 아이콘 + private readonly DispatcherTimer _cursorTimer; + private bool _cursorVisible = true; + private TextBlock? _activeStreamText; + private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지 + private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘 + private bool _aiIconPulseStopped; // 펄스 1회만 중지 + 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 Dictionary _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase); + private string _folderMenuSearchText = ""; + + // 경과 시간 표시 + private readonly DispatcherTimer _elapsedTimer; + private DateTime _streamStartTime; + private TextBlock? _elapsedLabel; + + // 타이핑 효과 + private readonly DispatcherTimer _typingTimer; + private readonly DispatcherTimer _gitRefreshTimer; + private readonly DispatcherTimer _conversationSearchTimer; + private readonly DispatcherTimer _inputUiRefreshTimer; + private CancellationTokenSource? _gitStatusRefreshCts; + private int _displayedLength; // 현재 화면에 표시된 글자 수 + private ResourceDictionary? _agentThemeDictionary; + private bool _isOverlaySettingsSyncing; + private string? _currentGitBranchName; + private string? _currentGitTooltip; + private string? _currentGitRoot; + private int _currentGitChangedFileCount; + private int _currentGitInsertions; + private int _currentGitDeletions; + private List _currentGitBranches = new(); + private string? _currentGitUpstreamStatus; + private readonly List _recentGitBranches = new(); + private string _gitBranchSearchText = ""; + private StackPanel? _selectedMessageActionBar; + private Border? _selectedMessageBorder; + private bool _isRefreshingFromSettings; + private int? _lastCompactionBeforeTokens; + private int? _lastCompactionAfterTokens; + private DateTime? _lastCompactionAt; + private bool _lastCompactionWasAutomatic; + private string _lastCompactionStageSummary = ""; + private int _sessionCompactionCount; + private int _sessionAutomaticCompactionCount; + private int _sessionManualCompactionCount; + private int _sessionCompactionSavedTokens; + private int _sessionMemoryCompactionCount; + private int _sessionMicrocompactBoundaryCount; + private int _sessionSnipCompactionCount; + private bool _pendingPostCompaction; + private int _sessionPostCompactionResponseCount; + private int _sessionPostCompactionPromptTokens; + private int _sessionPostCompactionCompletionTokens; + private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg) + { + if (button?.Content is not string text) + return; + + button.Background = active ? BrushFromHex(activeBg) : Brushes.Transparent; + button.BorderBrush = active ? BrushFromHex(activeBg) : Brushes.Transparent; + button.BorderThickness = new Thickness(active ? 1 : 0); + button.Foreground = active ? BrushFromHex(activeFg) : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); + button.Content = text; + } + + private sealed class ConversationMeta + { + public string Id { get; init; } = ""; + public string Title { get; init; } = ""; + public string UpdatedAtText { get; init; } = ""; + public bool Pinned { get; init; } + public string Category { get; init; } = ChatCategory.General; + public string Symbol { get; init; } = "\uE8BD"; + public string ColorHex { get; init; } = "#6B7280"; + public string Tab { get; init; } = "Chat"; + public DateTime UpdatedAt { get; init; } + /// 첫 사용자 메시지 요약 (검색용, 최대 100자). + public string Preview { get; init; } = ""; + /// 분기 원본 대화 ID. null이면 원본 대화. + public string? ParentId { get; init; } + public int AgentRunCount { get; init; } + public int FailedAgentRunCount { get; init; } + public string LastAgentRunSummary { get; init; } = ""; + public DateTime? LastFailedAt { get; init; } + public DateTime? LastCompletedAt { get; init; } + public bool IsRunning { get; init; } + public string WorkFolder { get; init; } = ""; + } + + public ChatWindow(SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _settings.SettingsChanged += Settings_SettingsChanged; + _storage = new ChatStorageService(); + _llm = new LlmService(settings); + _router = new ModelRouterService(settings); + _toolRegistry = ToolRegistry.CreateDefault(); + _chatEngine = new AxAgentExecutionEngine(); + _agentLoop = new AgentLoopService(_llm, _toolRegistry, settings) + { + Dispatcher = action => + { + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + appDispatcher.Invoke(action); + }, + AskPermissionCallback = async (toolName, filePath) => + { + if (IsPermissionAutoApprovedForSession(toolName, filePath)) + return true; + + PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject; + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + await appDispatcher.InvokeAsync(() => + { + AgentLoopService.PermissionPromptPreview? preview = null; + if (_agentLoop != null && _agentLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview)) + preview = pendingPreview; + decision = PermissionRequestWindow.Show(this, toolName, filePath, preview); + }); + + if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession) + RememberPermissionRuleForSession(toolName, filePath); + + return decision != PermissionRequestWindow.PermissionPromptResult.Reject; + }, + UserAskCallback = async (question, options, defaultValue) => + { + string? response = null; + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + await appDispatcher.InvokeAsync(() => + { + response = UserAskDialog.Show(question, options, defaultValue); + }); + return response; + }, + }; + SubAgentTool.StatusChanged += OnSubAgentStatusChanged; + + // 설정에서 초기값 로드 (Loaded 전에도 null 방지) + _selectedMood = settings.Settings.Llm.DefaultMood ?? "modern"; + _folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "none"; + + _cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) }; + _cursorTimer.Tick += CursorTimer_Tick; + + _elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _elapsedTimer.Tick += ElapsedTimer_Tick; + + _typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(12) }; + _typingTimer.Tick += TypingTimer_Tick; + _gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) }; + _gitRefreshTimer.Tick += async (_, _) => + { + _gitRefreshTimer.Stop(); + await RefreshGitBranchStatusAsync(); + }; + _conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) }; + _conversationSearchTimer.Tick += (_, _) => + { + _conversationSearchTimer.Stop(); + RefreshConversationList(); + }; + _inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) }; + _inputUiRefreshTimer.Tick += (_, _) => + { + _inputUiRefreshTimer.Stop(); + RefreshContextUsageVisual(); + RefreshDraftQueueUi(); + }; + + KeyDown += ChatWindow_KeyDown; + UpdateConversationFailureFilterUi(); + UpdateConversationSortUi(); + UpdateConversationRunningFilterUi(); + Loaded += (_, _) => + { + ApplyAgentThemeResources(); + + // ── 즉시 필요한 UI 초기화만 동기 실행 ── + SetupUserInfo(); + _selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern"; + _folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "none"; + UpdateAnalyzerButtonVisibility(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + ApplyExpressionLevelUi(); + UpdateSidebarModeMenu(); + RefreshContextUsageVisual(); + UpdateTopicPresetScrollMode(); + UpdateInputBoxHeight(); + InputBox.Focus(); + MessageScroll.ScrollChanged += MessageScroll_ScrollChanged; + + // ── 무거운 작업은 유휴 시점에 비동기 실행 ── + Dispatcher.BeginInvoke(() => + { + TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); + BuildTopicButtons(); + RestoreLastConversations(); + RefreshConversationList(); + UpdateTaskSummaryIndicators(); + ScheduleGitBranchRefresh(); + + // 데이터 정리 (디스크 I/O) + _ = Task.Run(() => + { + var retention = _settings.Settings.Llm.RetentionDays; + if (retention > 0) _storage.PurgeExpired(retention); + _storage.PurgeForDiskSpace(); + }); + }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); + + // 입력 바 포커스 글로우 효과 + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = accentBrush; + InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush; + + // 드래그 앤 드롭 파일 첨부 + AI 액션 팝업 + InputBorder.AllowDrop = true; + InputBorder.DragOver += (_, de) => + { + de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; + de.Handled = true; + }; + InputBorder.Drop += (_, de) => + { + if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0) + { + var enableAi = _settings.Settings.Llm.EnableDragDropAiActions; + if (enableAi && files.Length <= 5) + ShowDropActionMenu(files); + else + foreach (var f in files) AddAttachedFile(f); + } + }; + + // 스킬 시스템 초기화 + if (_settings.Settings.Llm.EnableSkillSystem) + { + SkillService.EnsureSkillFolder(); + SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath); + UpdateConditionalSkillActivation(reset: true); + } + + // 슬래시 명령어 칩 닫기 (× 버튼) + SlashChipClose.MouseLeftButtonUp += (_, _) => + { + HideSlashChip(restoreText: true); + InputBox.Focus(); + }; + + // InputBox에서 슬래시 팝업 열린 상태로 마우스 휠 → 팝업 스크롤 + InputBox.PreviewMouseWheel += (_, me) => + { + if (!SlashPopup.IsOpen) return; + me.Handled = true; + SlashPopup_ScrollByDelta(me.Delta); + }; + + // 탭 UI 초기 상태 + UpdateFolderBar(); + + // 호버 애니메이션 — 독립 공간이 있는 버튼에만 Scale 적용 + // (GhostBtn 스타일 버튼은 XAML에서 배경색+opacity 호버 처리) + ApplyHoverBounceAnimation(BtnModelSelector); + ApplyHoverBounceAnimation(BtnTemplateSelector, -1.5); + ApplyHoverScaleAnimation(BtnSend, 1.12); + ApplyHoverScaleAnimation(BtnStop, 1.12); + }; + SizeChanged += (_, _) => UpdateTopicPresetScrollMode(); + Closed += (_, _) => + { + _settings.SettingsChanged -= Settings_SettingsChanged; + SubAgentTool.StatusChanged -= OnSubAgentStatusChanged; + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + _conversationSearchTimer.Stop(); + _inputUiRefreshTimer.Stop(); + _llm.Dispose(); + }; + } + + private void Settings_SettingsChanged(object? sender, EventArgs e) + { + if (_forceClose || !IsLoaded || _isRefreshingFromSettings) + return; + + Dispatcher.BeginInvoke(new Action(() => + { + if (_forceClose || !IsLoaded) + return; + + _isRefreshingFromSettings = true; + try + { + RefreshFromSavedSettings(); + } + finally + { + _isRefreshingFromSettings = false; + } + }), DispatcherPriority.Input); + } + + private bool IsPermissionAutoApprovedForSession(string 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()를 사용합니다. + /// + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) + { + if (!_forceClose) + { + e.Cancel = true; + Hide(); + return; + } + base.OnClosing(e); + } + + /// 앱 종료 시 창을 실제로 닫습니다. + public void ForceClose() + { + // 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장 + lock (_convLock) + { + ChatSession?.SaveCurrentConversation(_storage, _activeTab); + _currentConversation = ChatSession?.CurrentConversation ?? _currentConversation; + SyncTabConversationIdsFromSession(); + } + SaveLastConversations(); + + _forceClose = true; + Close(); + } + + // ─── 사용자 정보 ──────────────────────────────────────────────────── + + private void SetupUserInfo() + { + var userName = Environment.UserName; + // AD\, AD/, AD: 접두사 제거 + var cleanName = userName; + foreach (var sep in new[] { '\\', '/', ':' }) + { + var idx = cleanName.LastIndexOf(sep); + if (idx >= 0) cleanName = cleanName[(idx + 1)..]; + } + + var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U"; + var pcName = Environment.MachineName; + + UserInitialSidebar.Text = initial; + UserInitialIconBar.Text = initial; + UserNameText.Text = cleanName; + UserPcText.Text = pcName; + BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})"; + } + + // ─── 스크롤 동작 ────────────────────────────────────────────────── + + private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + // 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥 + if (MessageScroll.ScrollableHeight <= 1) + { + _userScrolled = false; + return; + } + + // 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지 + if (Math.Abs(e.ExtentHeightChange) > 0.5) + return; + + var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40; + _userScrolled = !atBottom; + } + + private void AutoScrollIfNeeded() + { + if (!_userScrolled) + SmoothScrollToEnd(); + } + + /// 새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋). + private void ForceScrollToEnd() + { + _userScrolled = false; + Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background); + } + + /// 부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다. + private void SmoothScrollToEnd() + { + var targetOffset = MessageScroll.ScrollableHeight; + var currentOffset = MessageScroll.VerticalOffset; + var diff = targetOffset - currentOffset; + + // 차이가 작으면 즉시 이동 (깜빡임 방지) + if (diff <= 60) + { + MessageScroll.ScrollToEnd(); + return; + } + + // 부드럽게 스크롤 (DoubleAnimation) + var animation = new DoubleAnimation + { + From = currentOffset, + To = targetOffset, + Duration = TimeSpan.FromMilliseconds(200), + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset); + + // ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간 + var startTime = DateTime.UtcNow; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps + EventHandler tickHandler = null!; + tickHandler = (_, _) => + { + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + var progress = Math.Min(elapsed / 200.0, 1.0); + var eased = 1.0 - Math.Pow(1.0 - progress, 3); + var offset = currentOffset + diff * eased; + MessageScroll.ScrollToVerticalOffset(offset); + + if (progress >= 1.0) + { + timer.Stop(); + timer.Tick -= tickHandler; + } + }; + timer.Tick += tickHandler; + timer.Start(); + } + + // ─── 대화 제목 인라인 편집 ────────────────────────────────────────── + + private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e) + { + lock (_convLock) + { + if (_currentConversation == null) return; + } + ChatTitle.Visibility = Visibility.Collapsed; + ChatTitleEdit.Text = ChatTitle.Text; + ChatTitleEdit.Visibility = Visibility.Visible; + ChatTitleEdit.Focus(); + ChatTitleEdit.SelectAll(); + } + + private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit(); + private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; } + if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; } + } + + private void CommitTitleEdit() + { + var newTitle = ChatTitleEdit.Text.Trim(); + ChatTitleEdit.Visibility = Visibility.Collapsed; + ChatTitle.Visibility = Visibility.Visible; + + if (string.IsNullOrEmpty(newTitle)) return; + + lock (_convLock) + { + if (_currentConversation == null) return; + var session = ChatSession; + if (session != null) + _currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage); + else + _currentConversation.Title = newTitle; + } + + ChatTitle.Text = newTitle; + try { if (ChatSession == null) { ChatConversation conv; lock (_convLock) conv = _currentConversation!; _storage.Save(conv); } } catch { } + RefreshConversationList(); + } + + private void CancelTitleEdit() + { + ChatTitleEdit.Visibility = Visibility.Collapsed; + ChatTitle.Visibility = Visibility.Visible; + } + + // ─── 카테고리 드롭다운 ────────────────────────────────────────────── + + private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)); + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + var popup = new Popup + { + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + PlacementTarget = BtnCategoryDrop, + Placement = PlacementMode.Bottom, + HorizontalOffset = 0, + VerticalOffset = 4, + }; + + var container = new Border + { + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(6), + MinWidth = 180, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black + }, + }; + + var stack = new StackPanel(); + + Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick) + { + var item = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + }; + var g = new Grid(); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); + + var iconTb = new TextBlock + { + Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconTb, 0); + g.Children.Add(iconTb); + + var textTb = new TextBlock + { + Text = text, FontSize = 12.5, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(textTb, 1); + g.Children.Add(textTb); + + if (isSelected) + { + var check = CreateSimpleCheck(accentBrush, 14); + Grid.SetColumn(check, 2); + g.Children.Add(check); + } + + item.Child = g; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); }; + return item; + } + + Border CreateSep() => new() + { + Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4), + }; + + // 전체 보기 + var allLabel = _activeTab switch + { + "Cowork" => "모든 작업 유형", + "Code" => "모든 프로젝트", + _ => "모든 주제", + }; + stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText, + string.IsNullOrEmpty(_selectedCategory), + () => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); })); + + stack.Children.Add(CreateSep()); + + if (_activeTab == "Cowork") + { + // 코워크: 작업 유형 기반 필터 + var presets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets); + var categoryItems = presets + .Select(p => (Category: p.Category, Label: p.Label, Symbol: p.Symbol, Color: p.Color)) + .Concat(_storage.LoadAllMeta() + .Where(c => string.Equals(NormalizeTabName(c.Tab), "Cowork", StringComparison.OrdinalIgnoreCase)) + .Select(c => (Category: c.Category?.Trim() ?? "", Label: c.Category?.Trim() ?? "", Symbol: "\uE8BD", Color: "#6B7280"))) + .Where(item => !string.IsNullOrWhiteSpace(item.Category)) + .DistinctBy(item => item.Category, StringComparer.OrdinalIgnoreCase) + .OrderBy(item => item.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var categoryItem in categoryItems) + { + var capturedCategory = categoryItem.Category; + var brush = BrushFromHex(categoryItem.Color); + stack.Children.Add(CreateCatItem(categoryItem.Symbol, categoryItem.Label, brush, + string.Equals(_selectedCategory, capturedCategory, StringComparison.OrdinalIgnoreCase), + () => { _selectedCategory = capturedCategory; UpdateCategoryLabel(); RefreshConversationList(); })); + } + } + else if (_activeTab == "Code") + { + // 코드: 워크스페이스 기반 필터 + var workspaces = _storage.LoadAllMeta() + .Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase)) + .Select(c => c.WorkFolder?.Trim() ?? "") + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => System.IO.Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) + .ThenBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var workspace in workspaces) + { + var displayName = System.IO.Path.GetFileName(workspace.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)); + if (string.IsNullOrWhiteSpace(displayName)) + displayName = workspace; + + var capturedWorkspace = workspace; + var item = CreateCatItem("\uE8B7", displayName, accentBrush, + string.Equals(_selectedCategory, capturedWorkspace, StringComparison.OrdinalIgnoreCase), + () => { _selectedCategory = capturedWorkspace; UpdateCategoryLabel(); RefreshConversationList(); }); + item.ToolTip = workspace; + stack.Children.Add(item); + } + } + else + { + // Chat: 기존 ChatCategory 기반 + foreach (var (key, label, symbol, color) in ChatCategory.All) + { + var capturedKey = key; + stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color), + _selectedCategory == capturedKey, + () => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); })); + } + // 커스텀 프리셋 통합 필터 (Chat) + var chatCustom = _settings.Settings.Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList(); + if (chatCustom.Count > 0) + { + stack.Children.Add(CreateSep()); + stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, + _selectedCategory == "__custom__", + () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); + } + } + + container.Child = stack; + popup.Child = container; + popup.IsOpen = true; + } + + private void UpdateCategoryLabel() + { + if (string.IsNullOrEmpty(_selectedCategory)) + { + CategoryLabel.Text = _activeTab switch + { + "Cowork" => "모든 작업 유형", + "Code" => "모든 프로젝트", + _ => "주제 선택", + }; + CategoryIcon.Text = "\uE8BD"; + } + else if (_activeTab == "Code") + { + var displayName = System.IO.Path.GetFileName(_selectedCategory.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)); + CategoryLabel.Text = string.IsNullOrWhiteSpace(displayName) ? _selectedCategory : displayName; + CategoryIcon.Text = "\uE8B7"; + } + else if (_activeTab == "Cowork") + { + var preset = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets) + .FirstOrDefault(p => string.Equals(p.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)); + CategoryLabel.Text = preset?.Label ?? _selectedCategory; + CategoryIcon.Text = preset?.Symbol ?? "\uE8BD"; + } + else if (_selectedCategory == "__custom__") + { + CategoryLabel.Text = "커스텀 프리셋"; + CategoryIcon.Text = "\uE710"; + } + else + { + // ChatCategory에서 찾기 + foreach (var (key, label, symbol, _) in ChatCategory.All) + { + if (key == _selectedCategory) + { + CategoryLabel.Text = label; + CategoryIcon.Text = symbol; + return; + } + } + // 프리셋 카테고리에서 찾기 (Cowork/Code) + var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets); + var match = presets.FirstOrDefault(p => p.Category == _selectedCategory); + if (match != null) + { + CategoryLabel.Text = match.Label; + CategoryIcon.Text = match.Symbol; + } + else + { + CategoryLabel.Text = _selectedCategory; + CategoryIcon.Text = "\uE8BD"; + } + } + } + + private void UpdateSidebarModeMenu() + { + if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null) + return; + + var chatVisible = _activeTab == "Chat" ? Visibility.Visible : Visibility.Collapsed; + var coworkVisible = _activeTab == "Cowork" ? Visibility.Visible : Visibility.Collapsed; + var codeVisible = _activeTab == "Code" ? Visibility.Visible : Visibility.Collapsed; + if (SidebarChatMenu.Visibility != chatVisible) SidebarChatMenu.Visibility = chatVisible; + if (SidebarCoworkMenu.Visibility != coworkVisible) SidebarCoworkMenu.Visibility = coworkVisible; + if (SidebarCodeMenu.Visibility != codeVisible) SidebarCodeMenu.Visibility = codeVisible; + + if (SidebarModeBadgeTitle != null && SidebarModeBadgeIcon != null) + { + if (_activeTab == "Cowork") + { + SidebarModeBadgeTitle.Text = "Cowork 메뉴"; + SidebarModeBadgeIcon.Text = "\uE8FD"; + } + else if (_activeTab == "Code") + { + SidebarModeBadgeTitle.Text = "Code 메뉴"; + SidebarModeBadgeIcon.Text = "\uE943"; + } + else + { + SidebarModeBadgeTitle.Text = "Chat 메뉴"; + SidebarModeBadgeIcon.Text = "\uE8BD"; + } + } + + if (SidebarChatRunningState != null) + SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF"; + } + + private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _selectedCategory = ""; + UpdateCategoryLabel(); + RefreshConversationList(); + BuildTopicButtons(); + if (EmptyState != null) + EmptyState.Visibility = Visibility.Visible; + if (TopicButtonPanel != null) + TopicButtonPanel.Visibility = Visibility.Visible; + } + + private void SidebarChatRunning_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnRunningOnlyFilter_Click(this, new RoutedEventArgs()); + + private void SidebarCoworkCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnCategoryDrop_Click(this, new RoutedEventArgs()); + + private void SidebarCoworkPreset_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + BuildTopicButtons(); + ShowToast("프리셋 카드가 갱신되었습니다."); + } + + private void SidebarCoworkExecution_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnToggleExecutionLog_Click(this, new RoutedEventArgs()); + + private void SidebarCodeCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnCategoryDrop_Click(this, new RoutedEventArgs()); + + private void SidebarCodeLanguage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + BuildCodeBottomBar(); + ShowToast("코드 옵션 메뉴를 갱신했습니다."); + } + + private void SidebarCodeFiles_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (FileBrowserPanel == null) + return; + + var visible = FileBrowserPanel.Visibility == Visibility.Visible; + FileBrowserPanel.Visibility = visible ? Visibility.Collapsed : Visibility.Visible; + if (!visible) + BuildFileTree(); + } + + // ─── 창 컨트롤 ────────────────────────────────────────────────────── + + // WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요 + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + var source = System.Windows.Interop.HwndSource.FromHwnd( + new System.Windows.Interop.WindowInteropHelper(this).Handle); + source?.AddHook(WndProc); + } + + private void ApplyAgentThemeResources() + { + var themeUri = BuildAgentThemeDictionaryUri(); + + try + { + if (_agentThemeDictionary != null) + Resources.MergedDictionaries.Remove(_agentThemeDictionary); + + _agentThemeDictionary = new ResourceDictionary + { + Source = themeUri, + }; + Resources.MergedDictionaries.Insert(0, _agentThemeDictionary); + } + catch + { + // 테마 로드 실패 시 기본 리소스 유지 + } + } + + private Uri BuildAgentThemeDictionaryUri() + { + var mode = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant(); + var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").Trim().ToLowerInvariant() switch + { + "codex" => "Codex", + "slate" => "Slate", + _ => "Claw", + }; + var effectiveMode = mode switch + { + "light" => "Light", + "dark" => "Dark", + _ => "System", + }; + + var candidate = $"pack://application:,,,/Themes/Agent{preset}{effectiveMode}.xaml"; + return new Uri(candidate); + } + + private static bool IsSystemDarkTheme() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("AppsUseLightTheme") is int v && v == 0; + } + catch + { + return true; + } + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + // WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보 + if (msg == 0x0024) + { + var screen = System.Windows.Forms.Screen.FromHandle(hwnd); + var workArea = screen.WorkingArea; + var monitor = screen.Bounds; + + var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd); + var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0; + + // MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36) + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x + System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y + handled = true; + } + return IntPtr.Zero; + } + + private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; + private void BtnMaximize_Click(object sender, RoutedEventArgs e) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘 + } + private void BtnClose_Click(object sender, RoutedEventArgs e) => Close(); + + // ─── 탭 전환 ────────────────────────────────────────────────────────── + + private string _activeTab = "Chat"; + + private ChatSessionStateService? ChatSession => _appState.ChatSession; + + private void SaveCurrentTabConversationId() + { + lock (_convLock) + { + ChatSession?.SaveCurrentConversation(_storage, _activeTab); + _currentConversation = ChatSession?.CurrentConversation ?? _currentConversation; + SyncTabConversationIdsFromSession(); + } + // 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용) + SaveLastConversations(); + } + + /// 탭 전환 전 스트리밍 중이면 즉시 중단합니다. + private void StopStreamingIfActive() + { + if (!_isStreaming) return; + // 스트리밍 중단 + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + StopRainbowGlow(); + HideStickyProgress(); + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnPause.Visibility = Visibility.Collapsed; + PauseIcon.Text = "\uE769"; // 리셋 + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + private void TabChat_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Chat") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + PersistPerTabUiState(); + _activeTab = "Chat"; + RestorePerTabUiState(); + UpdateTabUI(); + } + + private void TabCowork_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Cowork") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + PersistPerTabUiState(); + _activeTab = "Cowork"; + RestorePerTabUiState(); + UpdateTabUI(); + } + + private void TabCode_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Code") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + PersistPerTabUiState(); + _activeTab = "Code"; + RestorePerTabUiState(); + UpdateTabUI(); + } + + /// 탭별로 마지막으로 활성화된 대화 ID를 기억. + private readonly Dictionary _tabConversationId = new() + { + ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, + }; + + private void SyncTabConversationIdsFromSession() + { + var session = ChatSession; + if (session == null) + return; + + foreach (var key in _tabConversationId.Keys.ToList()) + _tabConversationId[key] = session.GetConversationId(key); + } + + private void SyncTabConversationIdsToSession() + { + var session = ChatSession; + if (session == null) + return; + + foreach (var kv in _tabConversationId) + session.RememberConversation(kv.Key, kv.Value); + } + + private void UpdateTabUI() + { + ApplyAgentThemeResources(); + ApplyExpressionLevelUi(); + ApplySidebarStateForActiveTab(animated: false); + if (CurrentTabTitle != null) + { + CurrentTabTitle.Text = _activeTab switch + { + "Cowork" => "AX Agent · Cowork", + "Code" => "AX Agent · Code", + _ => "AX Agent · Chat", + }; + } + + // 폴더 바는 Cowork/Code 탭에서만 표시 + if (FolderBar != null) + FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; + + // 탭별 입력 안내 문구 + if (InputWatermark != null) + { + InputWatermark.Text = _activeTab switch + { + "Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)", + "Code" => "코드 관련 작업을 요청하세요...", + _ => _promptCardPlaceholder, + }; + } + + // 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용) + ApplyTabDefaultPermission(); + + // 포맷/디자인 드롭다운은 Cowork 탭에서만 표시 + if (_activeTab == "Cowork") + { + BuildBottomBar(); + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; + } + else if (_activeTab == "Code") + { + // Code 탭: 언어 선택기 + BuildCodeBottomBar(); + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; + } + else + { + MoodIconPanel.Children.Clear(); + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; + } + + // 탭별 프리셋 버튼 재구성 + BuildTopicButtons(); + UpdateSidebarModeMenu(); + + // 현재 대화를 해당 탭 대화로 전환 + SwitchToTabConversation(); + + // Cowork/Code 탭 전환 시 팁 표시 + ShowRandomTip(); + } + + private void PersistPerTabUiState() + { + _tabSelectedCategory[_activeTab] = _selectedCategory; + _tabSidebarVisible[_activeTab] = _sidebarVisible; + } + + private void RestorePerTabUiState() + { + if (_tabSelectedCategory.TryGetValue(_activeTab, out var category)) + _selectedCategory = category ?? ""; + else + _selectedCategory = ""; + UpdateCategoryLabel(); + + if (_tabSidebarVisible.TryGetValue(_activeTab, out var visible)) + _sidebarVisible = visible; + else + _sidebarVisible = true; + } + + private string GetAgentUiExpressionLevel() + { + var raw = _settings.Settings.Llm.AgentUiExpressionLevel; + return (raw ?? "balanced").Trim().ToLowerInvariant() switch + { + "rich" => "rich", + "simple" => "simple", + _ => "balanced", + }; + } + + private void ApplyExpressionLevelUi() + { + var level = GetAgentUiExpressionLevel(); + + if (InputBox != null) + { + InputBox.FontSize = level == "simple" ? 13 : 14; + InputBox.MaxHeight = level switch + { + "rich" => 120, + "simple" => 96, + _ => 108, + }; + UpdateInputBoxHeight(); + } + + if (InputWatermark != null) + { + InputWatermark.FontSize = level == "simple" ? 13 : 14; + InputWatermark.Opacity = level == "rich" ? 0.8 : 0.7; + } + + if (InlineSettingsHintText != null) + InlineSettingsHintText.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible; + + if (InlineSettingsQuickActions != null) + InlineSettingsQuickActions.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible; + + if (BtnTemplateSelector != null) + { + BtnTemplateSelector.Padding = level == "simple" + ? new Thickness(8, 4, 8, 4) + : new Thickness(9, 4, 9, 4); + } + + if (_failedOnlyFilter) + { + _failedOnlyFilter = false; + RefreshConversationList(); + } + } + + private void SwitchToTabConversation() + { + var session = ChatSession; + if (session != null) + { + var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings); + lock (_convLock) _currentConversation = conv; + SyncTabConversationIdsFromSession(); + SaveLastConversations(); + MessagePanel.Children.Clear(); + RenderMessages(); + EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + return; + } + + // 기억된 대화가 없으면 새 대화 + lock (_convLock) + { + _currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings) + ?? new ChatConversation { Tab = _activeTab }; + } + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + UpdateConditionalSkillActivation(reset: true); + } + + // ─── 작업 폴더 ───────────────────────────────────────────────────────── + + private readonly List _attachedFiles = new(); + private readonly List _pendingImages = new(); + + private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu(); + private void FolderMenuSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + _folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? ""; + RenderFolderMenuItems(_folderMenuSearchText); + } + + private void ShowFolderMenu() + { + _folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? ""; + RenderFolderMenuItems(_folderMenuSearchText); + FolderMenuPopup.IsOpen = true; + } + + private void RenderFolderMenuItems(string? searchText) + { + FolderMenuItems.Children.Clear(); + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var query = (searchText ?? "").Trim(); + + var maxDisplay = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30); + var currentFolder = GetCurrentWorkFolder(); + var conversationFolders = _storage.LoadAllMeta() + .Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase)) + .Select(c => c.WorkFolder?.Trim() ?? "") + .Where(p => IsPathAllowed(p) && Directory.Exists(p)); + var recentFolders = _settings.Settings.Llm.RecentWorkFolders + .Where(p => IsPathAllowed(p) && System.IO.Directory.Exists(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var workspaceFolders = recentFolders + .Concat(conversationFolders) + .Concat(string.IsNullOrWhiteSpace(currentFolder) ? Enumerable.Empty() : new[] { currentFolder }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(path => + string.IsNullOrWhiteSpace(query) + || path.Contains(query, StringComparison.OrdinalIgnoreCase) + || System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(maxDisplay * 3) + .ToList(); + + var filteredRecent = recentFolders + .Where(path => + string.IsNullOrWhiteSpace(query) + || path.Contains(query, StringComparison.OrdinalIgnoreCase) + || System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(maxDisplay) + .ToList(); + + var currentWorkspaceName = string.IsNullOrWhiteSpace(currentFolder) + ? "없음" + : System.IO.Path.GetFileName(currentFolder.TrimEnd('\\', '/')); + FolderMenuItems.Children.Add(CreatePopupSummaryStrip(new[] + { + ("현재", currentWorkspaceName, "#F8FAFC", "#E2E8F0", "#475569"), + ("최근", filteredRecent.Count.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"), + ("전체", workspaceFolders.Count.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"), + })); + + void AddWorkspaceRows(IEnumerable folders) + { + foreach (var folder in folders) + { + var isActive = folder.Equals(currentFolder, StringComparison.OrdinalIgnoreCase); + var displayName = System.IO.Path.GetFileName(folder); + if (string.IsNullOrEmpty(displayName)) displayName = folder; + var detailText = isActive ? $"현재 선택 · {folder}" : folder; + var itemBorder = CreatePopupMenuRow( + isActive ? "\uE73E" : "\uE8B7", + displayName, + detailText, + isActive, + accentBrush, + secondaryText, + primaryText, + () => + { + FolderMenuPopup.IsOpen = false; + SetWorkFolder(folder); + }); + itemBorder.ToolTip = folder; + var capturedPath = folder; + // 우클릭 → 컨텍스트 메뉴 (삭제, 폴더 열기) + itemBorder.MouseRightButtonUp += (_, re) => + { + re.Handled = true; + ShowRecentFolderContextMenu(capturedPath); + }; + FolderMenuItems.Children.Add(itemBorder); + } + } + + if (filteredRecent.Count > 0) + { + FolderMenuItems.Children.Add(CreatePopupSectionLabel($"최근 워크스페이스 · {filteredRecent.Count}", new Thickness(10, 6, 10, 4))); + AddWorkspaceRows(filteredRecent); + } + + var remainingFolders = workspaceFolders + .Where(path => !filteredRecent.Contains(path, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (remainingFolders.Count > 0) + { + var workspaceLabel = filteredRecent.Count > 0 + ? $"전체 워크스페이스 · {remainingFolders.Count}" + : $"워크스페이스 · {remainingFolders.Count}"; + FolderMenuItems.Children.Add(CreatePopupSectionLabel(workspaceLabel, new Thickness(10, 6, 10, 4))); + AddWorkspaceRows(remainingFolders); + + FolderMenuItems.Children.Add(new Border + { + Height = 1, + Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + Margin = new Thickness(8, 4, 8, 4), + Opacity = 0.5, + }); + } + else if (filteredRecent.Count == 0) + { + FolderMenuItems.Children.Add(new TextBlock + { + Text = "검색 결과가 없습니다.", + FontSize = 11.5, + Foreground = secondaryText, + Margin = new Thickness(10, 8, 10, 10), + }); + } + + // 폴더 찾아보기 버튼 + var browseSp = new StackPanel { Orientation = Orientation.Horizontal }; + browseSp.Children.Add(new TextBlock + { + Text = "\uED25", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 14, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + browseSp.Children.Add(new TextBlock + { + Text = "폴더 찾아보기...", + FontSize = 14, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var browseBorder = new Border + { + Child = browseSp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(10, 7, 10, 7), + }; + browseBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; + browseBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + browseBorder.MouseLeftButtonUp += (_, _) => + { + FolderMenuPopup.IsOpen = false; + BrowseWorkFolder(); + }; + FolderMenuItems.Children.Add(browseBorder); + } + + private void BrowseWorkFolder() + { + var dlg = new System.Windows.Forms.FolderBrowserDialog + { + Description = "작업 폴더를 선택하세요", + ShowNewFolderButton = false, + UseDescriptionForTitle = true, + }; + + var currentFolder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(currentFolder) && System.IO.Directory.Exists(currentFolder)) + dlg.SelectedPath = currentFolder; + + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + if (!IsPathAllowed(dlg.SelectedPath)) + { + CustomMessageBox.Show("이 경로는 작업 폴더로 선택할 수 없습니다.", "경로 제한", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + SetWorkFolder(dlg.SelectedPath); + } + + /// 경로 유효성 검사 — 차단 대상 경로 필터링. + private static bool IsPathAllowed(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + // C:\ 루트 차단 + var normalized = path.TrimEnd('\\', '/'); + if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false; + // "Document" 포함 경로 차단 (대소문자 무시) + if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false; + return true; + } + + private void SetWorkFolder(string path) + { + // 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단 + // 예: "C:\", "D:\", "E:\" 등 + var fullPath = System.IO.Path.GetFullPath(path); + var root = System.IO.Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase)) + { + ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000); + return; + } + + FolderPathLabel.Text = path; + FolderPathLabel.ToolTip = path; + + lock (_convLock) + { + if (_currentConversation != null) + { + var session = ChatSession; + if (session != null) + _currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = path, _storage); + else + _currentConversation.WorkFolder = path; + } + } + + // 최근 폴더 목록에 추가 (차단 경로 제외) + var recent = _settings.Settings.Llm.RecentWorkFolders; + recent.RemoveAll(p => !IsPathAllowed(p)); + recent.Remove(path); + recent.Insert(0, path); + var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30); + if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent); + _settings.Settings.Llm.WorkFolder = path; + _settings.Save(); + RefreshContextUsageVisual(); + ScheduleGitBranchRefresh(); + + UpdateConditionalSkillActivation(reset: true); + } + + private string GetCurrentWorkFolder() + { + lock (_convLock) + { + if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder)) + return _currentConversation.WorkFolder; + } + return _settings.Settings.Llm.WorkFolder; + } + + /// + /// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로 + /// 조건부 paths 스킬 활성화를 갱신합니다. + /// + private void UpdateConditionalSkillActivation(bool reset = false) + { + if (!_settings.Settings.Llm.EnableSkillSystem) return; + var cwd = GetCurrentWorkFolder(); + if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd)) return; + if (reset) SkillService.ResetConditionalSkillActivation(); + SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd); + } + + private Popup? _sharedContextPopup; + + private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu( + UIElement? placementTarget = null, + PlacementMode placement = PlacementMode.MousePoint, + double minWidth = 200) + { + _sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + + var bg = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var panel = new StackPanel { Margin = new Thickness(2) }; + var container = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(6), + MinWidth = minWidth, + Child = panel, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 3, + Opacity = 0.18, + Color = Colors.Black, + Direction = 270, + }, + }; + + var popup = new Popup + { + Child = container, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + Placement = placement, + PlacementTarget = placementTarget, + }; + + _sharedContextPopup = popup; + return (popup, panel); + } + + private Border CreatePopupMenuItem( + Popup popup, + string icon, + string label, + Brush iconBrush, + Brush labelBrush, + Brush hoverBrush, + Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12.5, + Foreground = iconBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 9, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 12.5, + Foreground = labelBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + + var item = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(10, 7, 12, 7), + Margin = new Thickness(0, 1, 0, 1), + }; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => + { + popup.SetCurrentValue(Popup.IsOpenProperty, false); + action(); + }; + + return item; + } + + private static void AddPopupMenuSeparator(Panel panel, Brush brush) + { + panel.Children.Add(new Border + { + Height = 1, + Margin = new Thickness(10, 4, 10, 4), + Background = brush, + Opacity = 0.35, + }); + } + + /// 최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다. + private void ShowRecentFolderContextMenu(string folderPath) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); + var (popup, panel) = CreateThemedPopupMenu(); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = folderPath, + UseShellExecute = true, + }); + } + catch { } + })); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () => + { + try { Clipboard.SetText(folderPath); } catch { } + })); + + AddPopupMenuSeparator(panel, borderBrush); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () => + { + _settings.Settings.Llm.RecentWorkFolders.RemoveAll( + p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase)); + _settings.Save(); + // 메뉴 새로고침 + if (FolderMenuPopup.IsOpen) + ShowFolderMenu(); + })); + + Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); + } + + private void BtnFolderClear_Click(object sender, RoutedEventArgs e) + { + FolderPathLabel.Text = "폴더를 선택하세요"; + FolderPathLabel.ToolTip = null; + lock (_convLock) + { + if (_currentConversation != null) + { + var session = ChatSession; + if (session != null) + _currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = "", _storage); + else + _currentConversation.WorkFolder = ""; + } + } + } + + private void UpdateFolderBar() + { + if (FolderBar == null) return; + if (_activeTab == "Chat") + { + FolderBar.Visibility = Visibility.Collapsed; + UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed); + RefreshContextUsageVisual(); + return; + } + FolderBar.Visibility = Visibility.Visible; + var folder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(folder)) + { + FolderPathLabel.Text = folder; + FolderPathLabel.ToolTip = folder; + } + else + { + FolderPathLabel.Text = "폴더를 선택하세요"; + FolderPathLabel.ToolTip = null; + } + // 대화별 설정 복원 (없으면 전역 기본값) + LoadConversationSettings(); + LoadCompactionMetricsFromConversation(); + UpdatePermissionUI(); + UpdateDataUsageUI(); + RefreshContextUsageVisual(); + ScheduleGitBranchRefresh(); + } + + /// 현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용. + private void LoadConversationSettings() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + var llm = _settings.Settings.Llm; + + var fallbackPermission = _activeTab == "Chat" + ? PermissionModeCatalog.Deny + : PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission); + var conversationPermission = !string.IsNullOrWhiteSpace(conv?.Permission) + ? PermissionModeCatalog.NormalizeGlobalMode(conv.Permission) + : fallbackPermission; + _settings.Settings.Llm.FilePermission = conversationPermission; + + _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "none"; + _selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern"; + } + + private void LoadCompactionMetricsFromConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + + _sessionCompactionCount = conv?.CompactionCount ?? 0; + _sessionAutomaticCompactionCount = conv?.AutomaticCompactionCount ?? 0; + _sessionManualCompactionCount = conv?.ManualCompactionCount ?? 0; + _sessionCompactionSavedTokens = conv?.CompactionSavedTokens ?? 0; + _sessionMemoryCompactionCount = conv?.SessionMemoryCompactionCount ?? 0; + _sessionMicrocompactBoundaryCount = conv?.MicrocompactBoundaryCount ?? 0; + _sessionSnipCompactionCount = conv?.SnipCompactionCount ?? 0; + _pendingPostCompaction = conv?.PendingPostCompaction ?? false; + _sessionPostCompactionResponseCount = conv?.PostCompactionResponseCount ?? 0; + _sessionPostCompactionPromptTokens = conv?.PostCompactionPromptTokens ?? 0; + _sessionPostCompactionCompletionTokens = conv?.PostCompactionCompletionTokens ?? 0; + _lastCompactionAt = conv?.LastCompactionAt; + _lastCompactionWasAutomatic = conv?.LastCompactionWasAutomatic ?? false; + _lastCompactionBeforeTokens = conv?.LastCompactionBeforeTokens; + _lastCompactionAfterTokens = conv?.LastCompactionAfterTokens; + _lastCompactionStageSummary = conv?.LastCompactionStageSummary ?? ""; + } + + /// 현재 하단 바 설정을 대화에 저장합니다. + private void SaveConversationSettings() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + try + { + var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + var dataUsage = _folderDataUsage ?? "none"; + var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern"); + var outputFormat = conv.OutputFormat ?? "auto"; + + var unchanged = + string.Equals(conv.Permission ?? "", normalizedPermission, StringComparison.OrdinalIgnoreCase) && + string.Equals(conv.DataUsage ?? "", dataUsage, StringComparison.OrdinalIgnoreCase) && + string.Equals(conv.Mood ?? "", mood, StringComparison.OrdinalIgnoreCase) && + string.Equals(conv.OutputFormat ?? "auto", outputFormat, StringComparison.OrdinalIgnoreCase); + + if (unchanged) + return; + + conv.Permission = normalizedPermission; + conv.DataUsage = dataUsage; + conv.Mood = mood; + var session = ChatSession; + if (session != null) + { + lock (_convLock) + _currentConversation = session.SaveConversationSettings(_activeTab, normalizedPermission, dataUsage, outputFormat, mood, _storage); + } + else + { + _storage.Save(conv); + } + } + catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + } + + // ─── 권한 메뉴 ───────────────────────────────────────────────────────── + + private void BtnPermission_Click(object sender, RoutedEventArgs e) + { + if (PermissionPopup == null) return; + PermissionItems.Children.Clear(); + + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var summary = _appState.GetPermissionSummary(currentConversation); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + Border CreateCollapsibleSection(string sectionKey, string icon, string title, UIElement content, bool expanded, string accentHex = "#334155") + { + var body = new Border + { + Margin = new Thickness(0, 5, 0, 0), + Visibility = expanded ? Visibility.Visible : Visibility.Collapsed, + Child = content, + }; + var caret = new TextBlock + { + Text = expanded ? "\uE70D" : "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = BrushFromHex(accentHex), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + var titleBlock = new TextBlock + { + Text = title, + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(titleBlock, 1); + headerGrid.Children.Add(titleBlock); + Grid.SetColumn(caret, 2); + headerGrid.Children.Add(caret); + + var headerBorder = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 5, 8, 5), + Cursor = Cursors.Hand, + Focusable = true, + Child = headerGrid, + }; + KeyboardNavigation.SetIsTabStop(headerBorder, true); + headerBorder.MouseEnter += (_, _) => headerBorder.Background = BrushFromHex("#F8FAFC"); + headerBorder.MouseLeave += (_, _) => headerBorder.Background = Brushes.Transparent; + void ToggleSection() + { + var show = body.Visibility != Visibility.Visible; + body.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + caret.Text = show ? "\uE70D" : "\uE76C"; + SetPermissionPopupSectionExpanded(sectionKey, show); + } + headerBorder.MouseLeftButtonUp += (_, _) => ToggleSection(); + headerBorder.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + ToggleSection(); + } + }; + + return new Border + { + Background = Brushes.Transparent, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(0, 2, 0, 2), + Margin = new Thickness(0, 0, 0, 2), + Child = new StackPanel + { + Children = + { + headerBorder, + body, + } + } + }; + } + + var summaryCard = new StackPanel + { + Margin = new Thickness(0, 0, 0, 4), + Children = + { + CreateFlatPopupRow( + "\uE946", + $"현재 모드 · {PermissionModeCatalog.ToDisplayLabel(summary.EffectiveMode)}", + summary.Description, + string.Equals(summary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) ? "#C2410C" : + string.Equals(summary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) ? "#475569" : "#4338CA", + false, + null), + CreateFlatPopupRow( + "\uE8D7", + $"기본값 · {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)}", + $"예외 {summary.OverrideCount}개", + "#64748B", + false, + null) + } + }; + + StackPanel? overrideSection = null; + if (summary.TopOverrides.Count > 0) + { + var overrideWrap = new StackPanel + { + Margin = new Thickness(0, 0, 0, 5), + }; + + foreach (var overrideEntry in summary.TopOverrides) + { + overrideWrap.Children.Add(CreateFlatPopupRow( + "\uE72E", + overrideEntry.Key, + PermissionModeCatalog.ToDisplayLabel(overrideEntry.Value), + "#2563EB", + false, + null)); + } + + overrideSection = new StackPanel + { + Margin = new Thickness(0, 0, 0, 8), + Children = + { + new TextBlock + { + Text = "도구별 예외", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(2, 0, 0, 3), + }, + overrideWrap + } + }; + } + + var latestDenied = _appState.GetLatestDeniedPermission(); + Border? deniedCard = null; + if (latestDenied != null) + { + var deniedStack = new StackPanel(); + deniedStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uEA39", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = "최근 권한 거부", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#991B1B"), + } + } + }); + deniedStack.Children.Add(new TextBlock + { + Text = _appState.FormatPermissionEventLine(latestDenied), + FontSize = 10, + Foreground = BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 0, 0), + TextWrapping = TextWrapping.Wrap, + LineHeight = 14, + MaxWidth = 250, + }); + + if (!string.IsNullOrWhiteSpace(latestDenied.ToolName)) + { + deniedStack.Children.Add(new TextBlock + { + Text = $"도구 {latestDenied.ToolName}에 바로 적용", + FontSize = 9.5, + Margin = new Thickness(0, 4, 0, 0), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }); + + var actionRow = new StackPanel + { + Margin = new Thickness(0, 6, 0, 0), + }; + + actionRow.Children.Add(CreateFlatPopupRow( + "\uE711", + "읽기 전용", + "이 도구의 쓰기 작업을 차단합니다", + "#991B1B", + true, + () => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny); RefreshPermissionPopup(); })); + actionRow.Children.Add(CreateFlatPopupRow( + "\uE8D7", + "권한 요청", + "실행 전 항상 확인받도록 되돌립니다", + "#1D4ED8", + true, + () => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default); RefreshPermissionPopup(); })); + actionRow.Children.Add(CreateFlatPopupRow( + "\uE73E", + "편집 자동 승인", + "이 도구의 편집 작업을 자동 허용합니다", + "#166534", + true, + () => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits); RefreshPermissionPopup(); })); + actionRow.Children.Add(CreateFlatPopupRow( + "\uE74D", + "예외 해제", + "도구별 예외를 제거하고 기본값을 사용합니다", + "#374151", + true, + () => { SetToolPermissionOverride(latestDenied.ToolName!, null); RefreshPermissionPopup(); })); + deniedStack.Children.Add(actionRow); + } + + deniedCard = new Border + { + Background = Brushes.Transparent, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(8, 8, 8, 8), + Margin = new Thickness(0, 0, 0, 4), + Child = deniedStack, + }; + } + + var coreLevels = PermissionModePresentationCatalog.Ordered.ToList(); + var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + void AddPermissionRows(Panel container, IEnumerable levels) + { + foreach (var item in levels) + { + var level = item.Mode; + var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); + var rowBorder = new Border + { + Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(8, 8, 8, 8), + Margin = new Thickness(0, 0, 0, 0), + Cursor = Cursors.Hand, + Focusable = true, + }; + KeyboardNavigation.SetIsTabStop(rowBorder, true); + + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + row.Children.Add(new TextBlock + { + Text = item.Icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = BrushFromHex(item.ColorHex), + Margin = new Thickness(1, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = item.Title, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + }); + textStack.Children.Add(new TextBlock + { + Text = item.Description, + FontSize = 10.5, + Margin = new Thickness(0, 1, 0, 0), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + LineHeight = 15, + MaxWidth = 240, + }); + Grid.SetColumn(textStack, 1); + row.Children.Add(textStack); + + var check = new TextBlock + { + Text = isActive ? "\uE73E" : "", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + FontWeight = FontWeights.Bold, + Foreground = BrushFromHex("#2563EB"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 1, 0), + }; + Grid.SetColumn(check, 2); + row.Children.Add(check); + + rowBorder.Child = row; + rowBorder.MouseEnter += (_, _) => + { + rowBorder.Background = BrushFromHex("#F8FAFC"); + rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0"); + }; + rowBorder.MouseLeave += (_, _) => + { + rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent; + rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"); + }; + + var capturedLevel = level; + void ApplyPermission() + { + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); + try { _settings.Save(); } catch { } + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + RefreshOverlayModeButtons(); + PermissionPopup.IsOpen = false; + } + rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission(); + rowBorder.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + ApplyPermission(); + } + }; + + container.Children.Add(rowBorder); + } + } + + PermissionItems.Children.Add(CreatePopupSectionLabel("핵심 권한 모드")); + AddPermissionRows(PermissionItems, coreLevels); + + // claw-code 기준 UX 정렬: 기본 화면은 핵심 모드 중심, 부가 정보는 단일 상세 섹션으로 제공. + var detailsPanel = new StackPanel(); + detailsPanel.Children.Add(summaryCard); + if (overrideSection != null) + detailsPanel.Children.Add(overrideSection); + if (deniedCard != null) + detailsPanel.Children.Add(deniedCard); + + PermissionItems.Children.Add(CreateCollapsibleSection( + "permission_details", + "\uE946", + "상세 정보", + detailsPanel, + expanded: GetPermissionPopupSectionExpanded("permission_details", false))); + + PermissionPopup.IsOpen = true; + Dispatcher.BeginInvoke(() => + { + TryFocusFirstPermissionElement(PermissionItems); + }, DispatcherPriority.Input); + } + + private static bool TryFocusFirstPermissionElement(DependencyObject root) + { + if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible) + return ui.Focus(); + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var i = 0; i < childCount; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (TryFocusFirstPermissionElement(child)) + return true; + } + + return false; + } + + private void SetToolPermissionOverride(string toolName, string? mode) + { + if (string.IsNullOrWhiteSpace(toolName)) return; + var toolPermissions = _settings.Settings.Llm.ToolPermissions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + var existingKey = toolPermissions.Keys.FirstOrDefault(x => string.Equals(x, toolName, StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(mode)) + { + if (!string.IsNullOrWhiteSpace(existingKey)) + toolPermissions.Remove(existingKey!); + } + else + { + toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode); + } + + try { _settings.Save(); } catch { } + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + } + + private void RefreshPermissionPopup() + { + if (PermissionPopup == null) return; + BtnPermission_Click(this, new RoutedEventArgs()); + } + + private string _lastPermissionBannerMode = ""; + + private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false) + { + var map = _settings.Settings.Llm.PermissionPopupSections; + if (map != null && map.TryGetValue(sectionKey, out var expanded)) + return expanded; + return defaultValue; + } + + private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded) + { + var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + map[sectionKey] = expanded; + try { _settings.Save(); } catch { } + } + + private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e) + { + if (PermissionTopBanner != null) + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + + private void UpdatePermissionUI() + { + if (PermissionLabel == null || PermissionIcon == null) return; + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var summary = _appState.GetPermissionSummary(currentConversation); + var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); + PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm); + PermissionIcon.Text = perm switch + { + "AcceptEdits" => "\uE73E", + "Plan" => "\uE7C3", + "BypassPermissions" => "\uE7BA", + "Deny" => "\uE711", + _ => "\uE8D7", + }; + if (BtnPermission != null) + { + var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode); + BtnPermission.ToolTip = $"{summary.Description}\n운영 모드: {operationMode}\n기본값 {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개"; + BtnPermission.Background = Brushes.Transparent; + BtnPermission.BorderThickness = new Thickness(1); + } + + if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase)) + { + _lastPermissionBannerMode = perm; + } + + // 모드별 색상 + 상단 권한 배너 표시 + if (perm == PermissionModeCatalog.AcceptEdits) + { + var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10)); + PermissionLabel.Foreground = activeColor; + PermissionIcon.Foreground = activeColor; + if (BtnPermission != null) + BtnPermission.BorderBrush = BrushFromHex("#86EFAC"); + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC"); + PermissionTopBannerIcon.Text = "\uE73E"; + PermissionTopBannerIcon.Foreground = activeColor; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#166534"); + PermissionTopBannerText.Text = "모든 파일 편집을 자동 승인합니다. 명령 실행은 계속 확인합니다."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + } + else if (perm == PermissionModeCatalog.Deny) + { + var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10)); + PermissionLabel.Foreground = denyColor; + PermissionIcon.Foreground = denyColor; + if (BtnPermission != null) + BtnPermission.BorderBrush = BrushFromHex("#86EFAC"); + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC"); + PermissionTopBannerIcon.Text = "\uE73E"; + PermissionTopBannerIcon.Foreground = denyColor; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용"; + PermissionTopBannerTitle.Foreground = denyColor; + PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + } + else if (perm == PermissionModeCatalog.BypassPermissions) + { + var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C)); + PermissionLabel.Foreground = autoColor; + PermissionIcon.Foreground = autoColor; + if (BtnPermission != null) + BtnPermission.BorderBrush = BrushFromHex("#FDBA74"); + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74"); + PermissionTopBannerIcon.Text = "\uE814"; + PermissionTopBannerIcon.Foreground = autoColor; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기"; + PermissionTopBannerTitle.Foreground = autoColor; + PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + } + else + { + var defaultFg = BrushFromHex("#2563EB"); + var iconFg = perm switch + { + "Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)), + _ => new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)), + }; + PermissionLabel.Foreground = defaultFg; + PermissionIcon.Foreground = iconFg; + if (BtnPermission != null) + BtnPermission.BorderBrush = perm == PermissionModeCatalog.Plan + ? BrushFromHex("#C7D2FE") + : BrushFromHex("#BFDBFE"); + if (PermissionTopBanner != null) + { + if (perm == PermissionModeCatalog.Plan) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE"); + PermissionTopBannerIcon.Text = "\uE7C3"; + PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerText.Text = "변경 전에 계획을 먼저 만들고 승인 흐름을 우선합니다."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + else if (perm == PermissionModeCatalog.Default) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#BFDBFE"); + PermissionTopBannerIcon.Text = "\uE8D7"; + PermissionTopBannerIcon.Foreground = BrushFromHex("#1D4ED8"); + PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 요청"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#1D4ED8"); + PermissionTopBannerText.Text = "변경하기 전에 항상 확인합니다."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + else + { + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + } + } + } + + private bool TryApplyPermissionModeFromAction(string action, out string appliedMode) + { + appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + var next = action switch + { + "ask" => PermissionModeCatalog.Default, + "default" => PermissionModeCatalog.Default, + "acceptedits" => PermissionModeCatalog.AcceptEdits, + "accept" => PermissionModeCatalog.AcceptEdits, + "auto" => PermissionModeCatalog.AcceptEdits, + "plan" => PermissionModeCatalog.Plan, + "bypass" => PermissionModeCatalog.BypassPermissions, + "bypasspermissions" => PermissionModeCatalog.BypassPermissions, + "fullauto" => PermissionModeCatalog.BypassPermissions, + "dontask" => PermissionModeCatalog.BypassPermissions, + "silent" => PermissionModeCatalog.BypassPermissions, + _ => null, + }; + + if (string.IsNullOrWhiteSpace(next)) + return false; + + _settings.Settings.Llm.FilePermission = next!; + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + appliedMode = next!; + return true; + } + + private string BuildPermissionStatusText() + { + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var summary = _appState.GetPermissionSummary(currentConversation); + var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); + var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode); + var overrides = summary.TopOverrides.Count > 0 + ? string.Join(", ", summary.TopOverrides.Take(3).Select(x => $"{x.Key}:{PermissionModeCatalog.ToDisplayLabel(x.Value)}")) + : "없음"; + return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개\n예외 미리보기: {overrides}"; + } + + private void OpenPermissionPanelFromSlash(string command, string usageText) + { + BtnPermission_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})"); + } + + // ──── 데이터 활용 수준 메뉴 ──── + + private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (DataUsagePopup == null) return; + DataUsageItems.Children.Clear(); + + var options = new (string Key, string Sym, string Label, string Desc, string Color, string CheckColor)[] + { + ("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽기만 포함해 참조하지 않습니다", "#6B7280", "#6B7280"), + ("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706", "#D97706"), + ("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10", "#107C10"), + }; + + foreach (var (key, sym, label, desc, color, checkColor) in options) + { + var isActive = key.Equals(_folderDataUsage, StringComparison.OrdinalIgnoreCase); + var row = new Border + { + Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 9, 8, 9), + Margin = new Thickness(0), + }; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + var checkIcon = CreateCheckIcon(isActive); + if (checkIcon is TextBlock checkText) + checkText.Foreground = isActive ? BrushFromHex(checkColor) : Brushes.Transparent; + sp.Children.Add(checkIcon); + sp.Children.Add(new TextBlock + { + Text = sym, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, + Foreground = BrushFromHex(color), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + }); + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = label, FontSize = 11.5, FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(color), + }); + textStack.Children.Add(new TextBlock + { + Text = desc, FontSize = 10.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 240, + }); + sp.Children.Add(textStack); + row.Child = sp; + + var capturedKey = key; + row.MouseEnter += (_, _) => + { + row.Background = BrushFromHex("#F8FAFC"); + row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0"); + }; + row.MouseLeave += (_, _) => + { + row.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent; + row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"); + }; + row.MouseLeftButtonDown += (_, _) => + { + _folderDataUsage = capturedKey; + UpdateDataUsageUI(); + SaveConversationSettings(); + RefreshOverlayModeButtons(); + DataUsagePopup.IsOpen = false; + }; + DataUsageItems.Children.Add(row); + } + DataUsagePopup.IsOpen = true; + } + + private void UpdateDataUsageUI() + { + if (DataUsageLabel == null || DataUsageIcon == null) return; + var (label, icon, color) = _folderDataUsage switch + { + "none" => ("데이터 미활용", "\uE8D8", "#6B7280"), + "passive" => ("데이터 소극", "\uE8FD", "#D97706"), + _ => ("데이터 적극", "\uE9F5", "#107C10"), + }; + DataUsageLabel.Text = label; + DataUsageIcon.Text = icon; + DataUsageIcon.Foreground = BrushFromHex(color); + if (BtnDataUsage != null) + { + BtnDataUsage.Background = Brushes.Transparent; + BtnDataUsage.BorderBrush = Brushes.Transparent; + BtnDataUsage.BorderThickness = new Thickness(0); + BtnDataUsage.ToolTip = _folderDataUsage switch + { + "none" => "폴더 문서와 파일을 읽지 않거나 참조하지 않습니다.", + "passive" => "사용자가 요청할 때만 폴더 데이터를 참조합니다.", + _ => "폴더 문서와 파일을 자동 탐색해 작업에 적극 활용합니다." + }; + } + } + + /// Cowork/Code 탭 진입 시 설정의 기본 권한을 적용. + private void ApplyTabDefaultPermission() + { + if (_activeTab == "Chat") + { + // Chat 탭: 경고 배너 숨기고 기본 제한 모드로 복원 + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.Deny; + UpdatePermissionUI(); + return; + } + var defaultPerm = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.DefaultAgentPermission); + if (!string.IsNullOrEmpty(defaultPerm)) + { + _settings.Settings.Llm.FilePermission = defaultPerm; + UpdatePermissionUI(); + } + } + + // ─── 파일 첨부 ───────────────────────────────────────────────────────── + + private void BtnAttach_Click(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Multiselect = true, + Title = "첨부할 파일을 선택하세요", + Filter = "모든 파일 (*.*)|*.*|텍스트 (*.txt;*.md;*.csv)|*.txt;*.md;*.csv|코드 (*.cs;*.py;*.js;*.ts)|*.cs;*.py;*.js;*.ts", + }; + + // 작업 폴더가 있으면 초기 경로 설정 + var workFolder = GetCurrentWorkFolder(); + if (!string.IsNullOrEmpty(workFolder) && System.IO.Directory.Exists(workFolder)) + dlg.InitialDirectory = workFolder; + + if (dlg.ShowDialog() != true) return; + + foreach (var file in dlg.FileNames) + AddAttachedFile(file); + } + + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" + }; + + private void AddAttachedFile(string filePath) + { + if (_attachedFiles.Contains(filePath)) return; + + // 파일 크기 제한 (10MB) + try + { + var fi = new System.IO.FileInfo(filePath); + if (fi.Length > 10 * 1024 * 1024) + { + CustomMessageBox.Show($"파일이 너무 큽니다 (10MB 초과):\n{fi.Name}", "첨부 제한", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // 이미지 파일 → Vision API용 base64 변환 + var ext = fi.Extension.ToLowerInvariant(); + if (ImageExtensions.Contains(ext) && _settings.Settings.Llm.EnableImageInput) + { + var maxKb = _settings.Settings.Llm.MaxImageSizeKb; + if (maxKb <= 0) maxKb = 5120; + if (fi.Length > maxKb * 1024) + { + CustomMessageBox.Show($"이미지가 너무 큽니다 ({fi.Length / 1024}KB, 최대 {maxKb}KB).", + "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var bytes = System.IO.File.ReadAllBytes(filePath); + var mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".webp" => "image/webp", + _ => "image/png", + }; + var attachment = new ImageAttachment + { + Base64 = Convert.ToBase64String(bytes), + MimeType = mimeType, + FileName = fi.Name, + }; + + // 중복 확인 + if (_pendingImages.Any(i => i.FileName == attachment.FileName)) return; + + _pendingImages.Add(attachment); + AddImagePreview(attachment); + return; + } + } + catch { return; } + + _attachedFiles.Add(filePath); + RefreshAttachedFilesUI(); + UpdateConditionalSkillActivation(); + } + + private void RemoveAttachedFile(string filePath) + { + _attachedFiles.Remove(filePath); + RefreshAttachedFilesUI(); + UpdateConditionalSkillActivation(); + } + + private void RefreshAttachedFilesUI() + { + AttachedFilesPanel.Items.Clear(); + if (_attachedFiles.Count == 0) + { + AttachedFilesPanel.Visibility = Visibility.Collapsed; + return; + } + + AttachedFilesPanel.Visibility = Visibility.Visible; + var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray; + + foreach (var file in _attachedFiles.ToList()) + { + var fileName = System.IO.Path.GetFileName(file); + var capturedFile = file; + + var chip = new Border + { + Background = hintBg, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 4, 4, 4), + Margin = new Thickness(0, 0, 4, 4), + Cursor = Cursors.Hand, + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = "\uE8A5", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10, + Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), + }); + sp.Children.Add(new TextBlock + { + Text = fileName, FontSize = 11, Foreground = secondaryBrush, + VerticalAlignment = VerticalAlignment.Center, MaxWidth = 150, TextTrimming = TextTrimming.CharacterEllipsis, + ToolTip = file, + }); + var removeBtn = new Button + { + Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush }, + Background = Brushes.Transparent, BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), + }; + removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile); + sp.Children.Add(removeBtn); + chip.Child = sp; + AttachedFilesPanel.Items.Add(chip); + } + } + + /// 첨부 파일 내용을 시스템 메시지로 변환합니다. + private string BuildFileContextPrompt() + { + if (_attachedFiles.Count == 0) return ""; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n[첨부 파일 컨텍스트]"); + + foreach (var file in _attachedFiles) + { + try + { + var ext = System.IO.Path.GetExtension(file).ToLowerInvariant(); + var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz" + or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg" + or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt" + or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac" + or ".psd" or ".ai" or ".sketch" or ".fig" + or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite"; + if (isBinary) + { + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---"); + continue; + } + + var content = System.IO.File.ReadAllText(file); + // 최대 8000자로 제한 + if (content.Length > 8000) + content = content[..8000] + "\n... (이하 생략)"; + + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---"); + sb.AppendLine(content); + } + catch (Exception ex) + { + sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---"); + } + } + + return sb.ToString(); + } + + private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e) + { + var newW = Width + e.HorizontalChange; + var newH = Height + e.VerticalChange; + if (newW >= MinWidth) Width = newW; + if (newH >= MinHeight) Height = newH; + } + + // ─── 사이드바 토글 ─────────────────────────────────────────────────── + + private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e) + { + _sidebarVisible = !_sidebarVisible; + _tabSidebarVisible[_activeTab] = _sidebarVisible; + ApplySidebarStateForActiveTab(animated: true); + } + + private void ApplySidebarStateForActiveTab(bool animated) + { + var targetVisible = _tabSidebarVisible.TryGetValue(_activeTab, out var visible) ? visible : true; + _sidebarVisible = targetVisible; + + if (_sidebarVisible) + { + IconBarColumn.Width = new GridLength(0); + IconBarPanel.Visibility = Visibility.Collapsed; + SidebarPanel.Visibility = Visibility.Visible; + + if (animated) + { + AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200); + } + else + { + SidebarColumn.MinWidth = 200; + SidebarColumn.Width = new GridLength(270); + } + return; + } + + SidebarColumn.MinWidth = 0; + if (animated) + { + AnimateSidebar(270, 0, () => + { + SidebarPanel.Visibility = Visibility.Collapsed; + IconBarColumn.Width = new GridLength(52); + 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) + { + var duration = 200.0; + var start = DateTime.UtcNow; + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; + EventHandler tickHandler = null!; + tickHandler = (_, _) => + { + var elapsed = (DateTime.UtcNow - start).TotalMilliseconds; + var t = Math.Min(elapsed / duration, 1.0); + t = 1 - (1 - t) * (1 - t); + SidebarColumn.Width = new GridLength(from + (to - from) * t); + if (elapsed >= duration) + { + timer.Stop(); + timer.Tick -= tickHandler; + SidebarColumn.Width = new GridLength(to); + onComplete?.Invoke(); + } + }; + timer.Tick += tickHandler; + timer.Start(); + } + + // ─── 대화 목록 ──────────────────────────────────────────────────────── + + public void RefreshConversationList() + { + var metas = _storage.LoadAllMeta(); + // 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원) + var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets) + .Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets)) + .Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.Llm.CustomPresets)); + var presetMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in allPresets) + presetMap.TryAdd(p.Category, (p.Symbol, p.Color)); + + var items = metas.Select(c => + { + var symbol = ChatCategory.GetSymbol(c.Category); + var color = ChatCategory.GetColor(c.Category); + // ChatCategory 기본값이면 프리셋에서 검색 + if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General) + { + if (presetMap.TryGetValue(c.Category, out var pm)) + { + symbol = pm.Symbol; + color = pm.Color; + } + } + var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory); + return new ConversationMeta + { + Id = c.Id, + Title = c.Title, + Pinned = c.Pinned, + Category = c.Category, + Symbol = symbol, + ColorHex = color, + Tab = NormalizeTabName(c.Tab), + UpdatedAtText = FormatDate(c.UpdatedAt), + UpdatedAt = c.UpdatedAt, + Preview = c.Preview ?? "", + ParentId = c.ParentId, + AgentRunCount = runSummary.AgentRunCount, + FailedAgentRunCount = runSummary.FailedAgentRunCount, + LastAgentRunSummary = runSummary.LastAgentRunSummary, + LastFailedAt = runSummary.LastFailedAt, + LastCompletedAt = runSummary.LastCompletedAt, + WorkFolder = c.WorkFolder ?? "", + IsRunning = _currentConversation?.Id == c.Id + && !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId) + && !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase), + }; + }).ToList(); + + // 탭 필터 — 현재 활성 탭의 대화만 표시 + items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList(); + // 탭 전환 과정에서 저장된 "빈 새 대화" 노이즈 항목은 목록에서 숨김 + items = items.Where(i => + i.Pinned + || !string.IsNullOrWhiteSpace(i.ParentId) + || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(i.Preview) + || i.AgentRunCount > 0 + || i.FailedAgentRunCount > 0 + || !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase) + ).ToList(); + _failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0); + _runningConversationCount = items.Count(i => i.IsRunning); + _spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3); + UpdateConversationFailureFilterUi(); + UpdateConversationRunningFilterUi(); + UpdateConversationQuickStripUi(); + + // 상단 필터 적용 + if (_activeTab == "Cowork") + { + if (!string.IsNullOrEmpty(_selectedCategory)) + { + items = items.Where(i => string.Equals(i.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + } + else if (_activeTab == "Code") + { + if (!string.IsNullOrEmpty(_selectedCategory)) + { + items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + } + else + { + if (_selectedCategory == "__custom__") + { + // 커스텀 프리셋으로 만든 대화만 표시 + var customCats = _settings.Settings.Llm.CustomPresets + .Select(c => $"custom_{c.Id}").ToHashSet(); + items = items.Where(i => customCats.Contains(i.Category)).ToList(); + } + else if (!string.IsNullOrEmpty(_selectedCategory)) + { + items = items.Where(i => i.Category == _selectedCategory).ToList(); + } + } + + // 검색 필터 (제목 + 내용 미리보기) + var search = SearchBox?.Text?.Trim() ?? ""; + if (!string.IsNullOrEmpty(search)) + items = items.Where(i => + i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || + i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) || + i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + if (_runningOnlyFilter) + items = items.Where(i => i.IsRunning).ToList(); + + items = (_sortConversationsByRecent + ? items.OrderByDescending(i => i.Pinned) + .ThenByDescending(i => i.UpdatedAt) + .ThenByDescending(i => i.FailedAgentRunCount > 0) + .ThenByDescending(i => i.AgentRunCount) + : items.OrderByDescending(i => i.Pinned) + .ThenByDescending(i => i.FailedAgentRunCount > 0) + .ThenByDescending(i => i.AgentRunCount) + .ThenByDescending(i => i.UpdatedAt)) + .ToList(); + + RenderConversationList(items); + } + + private const int ConversationPageSize = 50; + private List? _pendingConversations; + + private void RenderConversationList(List items) + { + ConversationPanel.Children.Clear(); + _pendingConversations = null; + + if (items.Count == 0) + { + var emptyText = _activeTab switch + { + "Cowork" => "Cowork 탭 대화가 없습니다", + "Code" => "Code 탭 대화가 없습니다", + _ => "Chat 탭 대화가 없습니다", + }; + var empty = new TextBlock + { + Text = emptyText, + FontSize = 12, + Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 20, 0, 0) + }; + ConversationPanel.Children.Add(empty); + return; + } + + var spotlightItems = BuildConversationSpotlightItems(items); + if (spotlightItems.Count > 0) + { + AddGroupHeader("집중 필요"); + foreach (var item in spotlightItems) + AddConversationItem(item); + + ConversationPanel.Children.Add(new Border + { + Height = 1, + Margin = new Thickness(10, 8, 10, 4), + Background = BrushFromHex("#E5E7EB"), + Opacity = 0.7, + }); + } + + var allOrdered = new List<(string Group, ConversationMeta Item)>(); + foreach (var item in items) + allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), item)); + + // 첫 페이지만 렌더링 + var firstPage = allOrdered.Take(ConversationPageSize).ToList(); + string? lastGroup = null; + foreach (var (group, item) in firstPage) + { + if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; } + AddConversationItem(item); + } + + // 나머지가 있으면 "더 보기" 버튼 + if (allOrdered.Count > ConversationPageSize) + { + _pendingConversations = items; + AddLoadMoreButton(allOrdered.Count - ConversationPageSize); + } + } + + private void AddLoadMoreButton(int remaining) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var btn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 10, 8, 10), + Margin = new Thickness(6, 4, 6, 4), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + sp.Children.Add(new TextBlock + { + Text = $"더 보기 ({remaining}개 남음)", + FontSize = 12, + Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, + }); + btn.Child = sp; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + btn.MouseLeftButtonUp += (_, _) => + { + // 전체 목록 렌더링 + if (_pendingConversations != null) + { + var all = _pendingConversations; + _pendingConversations = null; + ConversationPanel.Children.Clear(); + + string? lastGroup = null; + foreach (var item in all) + { + var group = GetConversationDateGroup(item.UpdatedAt); + if (!string.Equals(lastGroup, group, StringComparison.Ordinal)) + { + AddGroupHeader(group); + lastGroup = group; + } + + AddConversationItem(item); + } + } + }; + ConversationPanel.Children.Add(btn); + } + + private static string GetConversationDateGroup(DateTime updatedAt) + { + var today = DateTime.Today; + var date = updatedAt.Date; + if (date == today) + return "오늘"; + if (date == today.AddDays(-1)) + return "어제"; + return "이전"; + } + + private List BuildConversationSpotlightItems(List items) + { + if (_failedOnlyFilter || _runningOnlyFilter) + return new List(); + + var search = SearchBox?.Text?.Trim() ?? ""; + if (!string.IsNullOrEmpty(search)) + return new List(); + + return items + .Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) + .OrderByDescending(i => i.FailedAgentRunCount) + .ThenByDescending(i => i.AgentRunCount) + .ThenByDescending(i => i.UpdatedAt) + .Take(3) + .ToList(); + } + + private void AddGroupHeader(string text) + { + var header = new TextBlock + { + Text = text, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + Margin = new Thickness(8, 10, 0, 3) + }; + ConversationPanel.Children.Add(header); + } + + private void AddConversationItem(ConversationMeta item) + { + var isSelected = false; + lock (_convLock) + isSelected = _currentConversation?.Id == item.Id; + + var isBranch = !string.IsNullOrEmpty(item.ParentId); + var border = new Border + { + Background = isSelected + ? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)) + : Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = isBranch ? new Thickness(14, 1, 0, 1) : new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상) + Brush iconBrush; + if (item.Pinned) + iconBrush = Brushes.Orange; + else + { + try { iconBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); } + catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; } + } + var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol; + if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색 + var icon = new TextBlock + { + Text = iconText, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, + Foreground = iconBrush, + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(icon, 0); + grid.Children.Add(icon); + + // 제목 + 날짜 (선택 시 약간 밝게) + var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray; + + var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var title = new TextBlock + { + Text = item.Title, + FontSize = 12, + Foreground = titleColor, + TextTrimming = TextTrimming.CharacterEllipsis + }; + var date = new TextBlock + { + Text = item.UpdatedAtText, + FontSize = 9.5, + Foreground = dateColor, + Margin = new Thickness(0, 1, 0, 0) + }; + stack.Children.Add(title); + stack.Children.Add(date); + if (item.IsRunning) + { + stack.Children.Add(new Border + { + Background = BrushFromHex("#DBEAFE"), + BorderBrush = BrushFromHex("#93C5FD"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(6, 1, 6, 1), + Margin = new Thickness(0, 4, 0, 0), + HorizontalAlignment = HorizontalAlignment.Left, + Child = new TextBlock + { + Text = _appState.ActiveTasks.Count > 0 + ? $"진행 중 {_appState.ActiveTasks.Count}" + : "진행 중", + FontSize = 9, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#1D4ED8"), + } + }); + } + if (item.AgentRunCount > 0) + { + var runSummaryPanel = new DockPanel + { + Margin = new Thickness(0, 3, 0, 0), + LastChildFill = true, + }; + + if (item.FailedAgentRunCount > 0 && item.LastFailedAt.HasValue) + { + var failedBadge = new Border + { + Background = BrushFromHex("#FEF2F2"), + BorderBrush = BrushFromHex("#FECACA"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(6, 1, 6, 1), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"실패 {FormatDate(item.LastFailedAt.Value)}", + FontSize = 9, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#991B1B"), + } + }; + DockPanel.SetDock(failedBadge, Dock.Right); + runSummaryPanel.Children.Add(failedBadge); + } + else if (item.LastCompletedAt.HasValue) + { + var completedBadge = new Border + { + Background = BrushFromHex("#ECFDF5"), + BorderBrush = BrushFromHex("#BBF7D0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(6, 1, 6, 1), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"성공 {FormatDate(item.LastCompletedAt.Value)}", + FontSize = 9, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#166534"), + } + }; + DockPanel.SetDock(completedBadge, Dock.Right); + runSummaryPanel.Children.Add(completedBadge); + } + + var runSummaryText = new TextBlock + { + Text = item.FailedAgentRunCount > 0 + ? $"실행 {item.AgentRunCount} · 실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}" + : $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 32)}", + FontSize = 9.5, + Foreground = item.FailedAgentRunCount > 0 + ? BrushFromHex("#B91C1C") + : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + Margin = new Thickness(0, 3, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary)) + { + runSummaryText.ToolTip = item.FailedAgentRunCount > 0 + ? $"최근 실패 포함\n{item.LastAgentRunSummary}" + : item.LastAgentRunSummary; + } + runSummaryPanel.Children.Add(runSummaryText); + stack.Children.Add(runSummaryPanel); + } + Grid.SetColumn(stack, 1); + grid.Children.Add(stack); + + // 카테고리 변경 버튼 (호버 시 표시) + var catBtn = new Button + { + Content = new TextBlock + { + Text = "\uE70F", // Edit + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray) + }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Visibility = Visibility.Collapsed, + Padding = new Thickness(4), + ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경" + }; + var capturedId = item.Id; + catBtn.Click += (_, _) => ShowConversationMenu(capturedId); + Grid.SetColumn(catBtn, 2); + grid.Children.Add(catBtn); + + // 선택 시 좌측 액센트 바 + if (isSelected) + { + border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + border.BorderThickness = new Thickness(2, 0, 0, 0); + } + + border.Child = grid; + + // 호버 이벤트 — 배경 + 미세 확대 + border.RenderTransformOrigin = new Point(0.5, 0.5); + border.RenderTransform = new ScaleTransform(1, 1); + var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)); + border.MouseEnter += (_, _) => + { + if (!isSelected) + border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); + catBtn.Visibility = Visibility.Visible; + var st = border.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + }; + border.MouseLeave += (_, _) => + { + if (!isSelected) + border.Background = Brushes.Transparent; + catBtn.Visibility = Visibility.Collapsed; + var st = border.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + }; + + // 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환 + border.MouseLeftButtonDown += (_, _) => + { + try + { + if (isSelected) + { + // 이미 선택된 대화 → 제목 편집 모드 + EnterTitleEditMode(title, item.Id, titleColor); + return; + } + // 스트리밍 중이면 취소 + if (_isStreaming) + { + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _typingTimer.Stop(); + _elapsedTimer.Stop(); + _activeStreamText = null; + _elapsedLabel = null; + _isStreaming = false; + } + var conv = _storage.Load(item.Id); + if (conv != null) + { + lock (_convLock) + { + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; + SyncTabConversationIdsFromSession(); + } + SaveLastConversations(); + UpdateChatTitle(); + RenderMessages(); + RefreshConversationList(); + RefreshDraftQueueUi(); + } + } + catch (Exception ex) + { + LogService.Error($"대화 전환 오류: {ex.Message}"); + } + }; + + // 우클릭 → 대화 관리 메뉴 바로 표시 + border.MouseRightButtonUp += (_, me) => + { + me.Handled = true; + // 선택되지 않은 대화를 우클릭하면 먼저 선택 + if (!isSelected) + { + var conv = _storage.Load(item.Id); + if (conv != null) + { + lock (_convLock) + { + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; + SyncTabConversationIdsFromSession(); + } + SaveLastConversations(); + UpdateChatTitle(); + RenderMessages(); + RefreshDraftQueueUi(); + } + } + // Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기 + Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input); + }; + + ConversationPanel.Children.Add(border); + } + + // ─── 대화 제목 인라인 편집 ──────────────────────────────────────────── + + private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor) + { + try + { + // titleTb가 이미 부모에서 분리된 경우(편집 중) 무시 + var parent = titleTb.Parent as StackPanel; + if (parent == null) return; + + var idx = parent.Children.IndexOf(titleTb); + if (idx < 0) return; + + var editBox = new TextBox + { + Text = titleTb.Text, + FontSize = 12.5, + Foreground = titleColor, + Background = Brushes.Transparent, + BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + BorderThickness = new Thickness(0, 0, 0, 1), + CaretBrush = titleColor, + Padding = new Thickness(0), + Margin = new Thickness(0), + }; + + // 안전하게 자식 교체: 먼저 제거 후 삽입 + parent.Children.RemoveAt(idx); + parent.Children.Insert(idx, editBox); + + var committed = false; + void CommitEdit() + { + if (committed) return; + committed = true; + + var newTitle = editBox.Text.Trim(); + if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text; + + titleTb.Text = newTitle; + // editBox가 아직 parent에 있는지 확인 후 교체 + try + { + var currentIdx = parent.Children.IndexOf(editBox); + if (currentIdx >= 0) + { + parent.Children.RemoveAt(currentIdx); + parent.Children.Insert(currentIdx, titleTb); + } + } + catch { /* 부모가 이미 해제된 경우 무시 */ } + + var conv = _storage.Load(conversationId); + if (conv != null) + { + conv.Title = newTitle; + _storage.Save(conv); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + _currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage) ?? _currentConversation; + } + UpdateChatTitle(); + } + } + + void CancelEdit() + { + if (committed) return; + committed = true; + try + { + var currentIdx = parent.Children.IndexOf(editBox); + if (currentIdx >= 0) + { + parent.Children.RemoveAt(currentIdx); + parent.Children.Insert(currentIdx, titleTb); + } + } + catch { /* 부모가 이미 해제된 경우 무시 */ } + } + + editBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); } + if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); } + }; + editBox.LostFocus += (_, _) => CommitEdit(); + + editBox.Focus(); + editBox.SelectAll(); + } + catch (Exception ex) + { + LogService.Error($"제목 편집 오류: {ex.Message}"); + } + } + + // ─── 카테고리 변경 팝업 ────────────────────────────────────────────── + + private void ShowConversationMenu(string conversationId) + { + var conv = _storage.Load(conversationId); + var isPinned = conv?.Pinned ?? false; + + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)); + + var popup = new Popup + { + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + Placement = PlacementMode.MousePoint, + }; + + var container = new Border + { + Background = bgBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(6), + MinWidth = 200, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black + }, + }; + + var stack = new StackPanel(); + + // 메뉴 항목 헬퍼 + Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick) + { + var item = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + }; + var g = new Grid(); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var iconTb = new TextBlock + { + Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconTb, 0); + g.Children.Add(iconTb); + + var textTb = new TextBlock + { + Text = text, FontSize = 12.5, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(textTb, 1); + g.Children.Add(textTb); + + item.Child = g; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); }; + return item; + } + + Border CreateSeparator() => new() + { + Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4), + }; + + // 고정/해제 + stack.Children.Add(CreateMenuItem( + isPinned ? "\uE77A" : "\uE718", + isPinned ? "고정 해제" : "상단 고정", + TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + () => + { + var c = _storage.Load(conversationId); + if (c != null) + { + c.Pinned = !c.Pinned; + _storage.Save(c); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + { + _currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current => current.Pinned = c.Pinned, _storage) ?? _currentConversation; + } + } + RefreshConversationList(); + } + })); + + // 이름 변경 + stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () => + { + // 대화 목록에서 해당 항목 찾아서 편집 모드 진입 + foreach (UIElement child in ConversationPanel.Children) + { + if (child is Border b && b.Child is Grid g) + { + foreach (UIElement gc in g.Children) + { + if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) + { + // title과 매칭 + if (conv != null && tb.Text == conv.Title) + { + var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + EnterTitleEditMode(tb, conversationId, titleColor); + return; + } + } + } + } + } + })); + + // Cowork/Code 탭: 작업 유형 읽기 전용 표시 + if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null) + { + var catKey = conv.Category ?? ChatCategory.General; + // ChatCategory 또는 프리셋에서 아이콘/라벨 검색 + string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280"; + var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey); + if (chatCat != default && chatCat.Key != ChatCategory.General) + { + catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color; + } + else + { + var preset = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets) + .FirstOrDefault(p => p.Category == catKey); + if (preset != null) + { + catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color; + } + } + + stack.Children.Add(CreateSeparator()); + var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) }; + try + { + var catBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(catColor)); + infoSp.Children.Add(new TextBlock + { + Text = catSymbol, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = catBrush, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + infoSp.Children.Add(new TextBlock + { + Text = catLabel, FontSize = 12, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + } + catch + { + infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText }); + } + stack.Children.Add(infoSp); + } + + // Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요) + var showCategorySection = _activeTab == "Chat"; + + if (showCategorySection) + { + stack.Children.Add(CreateSeparator()); + + // 분류 헤더 + stack.Children.Add(new TextBlock + { + Text = "분류 변경", + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(10, 4, 0, 4), + FontWeight = FontWeights.SemiBold, + }); + + var currentCategory = conv?.Category ?? ChatCategory.General; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + foreach (var (key, label, symbol, color) in ChatCategory.All) + { + var capturedKey = key; + var isCurrentCat = capturedKey == currentCategory; + + // 카테고리 항목 (체크 표시 포함) + var catItem = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + }; + var catGrid = new Grid(); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); + + var catIcon = new TextBlock + { + Text = symbol, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(catIcon, 0); + catGrid.Children.Add(catIcon); + + var catText = new TextBlock + { + Text = label, FontSize = 12.5, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal, + }; + Grid.SetColumn(catText, 1); + catGrid.Children.Add(catText); + + if (isCurrentCat) + { + var check = CreateSimpleCheck(accentBrush, 14); + Grid.SetColumn(check, 2); + catGrid.Children.Add(check); + } + + catItem.Child = catGrid; + catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + catItem.MouseLeftButtonUp += (_, _) => + { + popup.IsOpen = false; + var c = _storage.Load(conversationId); + if (c != null) + { + c.Category = capturedKey; + var preset = Services.PresetService.GetByCategory(capturedKey); + if (preset != null) + c.SystemCommand = preset.SystemPrompt; + _storage.Save(c); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + { + _currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current => + { + current.Category = capturedKey; + if (preset != null) + current.SystemCommand = preset.SystemPrompt; + }, _storage) ?? _currentConversation; + } + } + // 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신 + bool isCurrent; + lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; } + if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder)) + { + _promptCardPlaceholder = preset.Placeholder; + UpdateWatermarkVisibility(); + if (string.IsNullOrEmpty(InputBox.Text)) + { + InputWatermark.Text = preset.Placeholder; + InputWatermark.Visibility = Visibility.Visible; + } + } + else if (isCurrent) + { + ClearPromptCardPlaceholder(); + } + RefreshConversationList(); + } + }; + stack.Children.Add(catItem); + } + } // end showCategorySection + + stack.Children.Add(CreateSeparator()); + + // 삭제 + stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => + { + var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", + MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) return; + _storage.Delete(conversationId); + lock (_convLock) + { + if (_currentConversation?.Id == conversationId) + { + _currentConversation = null; + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + UpdateChatTitle(); + } + } + RefreshConversationList(); + })); + + container.Child = stack; + popup.Child = container; + popup.IsOpen = true; + } + + // ─── 검색 ──────────────────────────────────────────────────────────── + + private void SidebarSearchTrigger_MouseEnter(object sender, MouseEventArgs e) + { + if (SidebarSearchTrigger != null) + SidebarSearchTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + if (SidebarSearchShortcutHint != null) + SidebarSearchShortcutHint.Visibility = Visibility.Visible; + } + + private void SidebarSearchTrigger_MouseLeave(object sender, MouseEventArgs e) + { + if (SidebarSearchTrigger != null) + SidebarSearchTrigger.Background = Brushes.Transparent; + if (SidebarSearchShortcutHint != null) + SidebarSearchShortcutHint.Visibility = Visibility.Collapsed; + } + + private void SidebarSearchTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + OpenSidebarSearch(); + } + + private void SidebarNewChatTrigger_MouseEnter(object sender, MouseEventArgs e) + { + if (SidebarNewChatTrigger != null) + SidebarNewChatTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + if (SidebarNewChatShortcutHint != null) + SidebarNewChatShortcutHint.Visibility = Visibility.Visible; + } + + private void SidebarNewChatTrigger_MouseLeave(object sender, MouseEventArgs e) + { + if (SidebarNewChatTrigger != null) + SidebarNewChatTrigger.Background = Brushes.Transparent; + if (SidebarNewChatShortcutHint != null) + SidebarNewChatShortcutHint.Visibility = Visibility.Collapsed; + } + + private void SidebarNewChatTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + BtnNewChat_Click(sender, new RoutedEventArgs()); + } + + private void BtnSidebarSearchClose_Click(object sender, RoutedEventArgs e) + { + CloseSidebarSearch(clearText: true); + } + + private void OpenSidebarSearch() + { + if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null) + return; + + SidebarSearchTrigger.Visibility = Visibility.Collapsed; + SidebarSearchEditor.Visibility = Visibility.Visible; + SidebarSearchEditor.Opacity = 0; + SidebarSearchEditorScale.ScaleX = 0.85; + + var duration = TimeSpan.FromMilliseconds(160); + SidebarSearchEditor.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, duration)); + SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(0.85, 1, duration) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }); + + Dispatcher.BeginInvoke(new Action(() => + { + SearchBox?.Focus(); + SearchBox?.SelectAll(); + }), DispatcherPriority.Background); + } + + private void CloseSidebarSearch(bool clearText) + { + if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null) + return; + + if (clearText && SearchBox != null && !string.IsNullOrEmpty(SearchBox.Text)) + SearchBox.Text = ""; + + var duration = TimeSpan.FromMilliseconds(140); + var opacityAnim = new DoubleAnimation(1, 0, duration); + opacityAnim.Completed += (_, _) => + { + SidebarSearchEditor.Visibility = Visibility.Collapsed; + SidebarSearchTrigger.Visibility = Visibility.Visible; + SidebarSearchTrigger.Background = Brushes.Transparent; + if (SidebarSearchShortcutHint != null) + SidebarSearchShortcutHint.Visibility = Visibility.Collapsed; + }; + SidebarSearchEditor.BeginAnimation(OpacityProperty, opacityAnim); + SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(1, 0.85, duration) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } + }); + } + + private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + _conversationSearchTimer.Stop(); + _conversationSearchTimer.Start(); + } + + private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { } + + private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e) + { + _runningOnlyFilter = false; + UpdateConversationRunningFilterUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + } + + private void BtnQuickRunningFilter_Click(object sender, RoutedEventArgs e) + => BtnRunningOnlyFilter_Click(sender, e); + + private void BtnQuickHotSort_Click(object sender, RoutedEventArgs e) + { + _sortConversationsByRecent = false; + UpdateConversationSortUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + } + + private void BtnConversationSort_Click(object sender, RoutedEventArgs e) + { + _sortConversationsByRecent = !_sortConversationsByRecent; + UpdateConversationSortUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + } + + private void UpdateConversationFailureFilterUi() + { + _failedOnlyFilter = false; + UpdateSidebarModeMenu(); + } + + private void UpdateConversationRunningFilterUi() + { + if (BtnRunningOnlyFilter == null || RunningOnlyFilterLabel == null) + return; + + BtnRunningOnlyFilter.Background = _runningOnlyFilter + ? BrushFromHex("#DBEAFE") + : Brushes.Transparent; + BtnRunningOnlyFilter.BorderBrush = _runningOnlyFilter + ? BrushFromHex("#93C5FD") + : Brushes.Transparent; + BtnRunningOnlyFilter.BorderThickness = _runningOnlyFilter + ? new Thickness(1) + : new Thickness(0); + RunningOnlyFilterLabel.Foreground = _runningOnlyFilter + ? BrushFromHex("#1D4ED8") + : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); + RunningOnlyFilterLabel.Text = _runningConversationCount > 0 + ? $"진행 {_runningConversationCount}" + : "진행"; + BtnRunningOnlyFilter.ToolTip = _runningOnlyFilter + ? "실행 중인 대화만 표시 중" + : _runningConversationCount > 0 + ? $"현재 실행 중인 대화 {_runningConversationCount}개 보기" + : "현재 실행 중인 대화만 보기"; + UpdateSidebarModeMenu(); + } + + private void UpdateConversationSortUi() + { + if (BtnConversationSort == null || ConversationSortLabel == null) + return; + + ConversationSortLabel.Text = _sortConversationsByRecent ? "최근" : "활동"; + BtnConversationSort.Background = _sortConversationsByRecent + ? BrushFromHex("#EFF6FF") + : BrushFromHex("#F8FAFC"); + BtnConversationSort.BorderBrush = _sortConversationsByRecent + ? BrushFromHex("#93C5FD") + : BrushFromHex("#E2E8F0"); + BtnConversationSort.BorderThickness = new Thickness(1); + ConversationSortLabel.Foreground = _sortConversationsByRecent + ? BrushFromHex("#1D4ED8") + : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); + BtnConversationSort.ToolTip = _sortConversationsByRecent + ? "최신 업데이트 순으로 보는 중" + : "에이전트 활동량과 실패를 우선으로 보는 중"; + } + + private static string FormatDate(DateTime dt) + { + var diff = DateTime.Now - dt; + if (diff.TotalMinutes < 1) return "방금 전"; + if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전"; + if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전"; + return dt.ToString("MM/dd"); + } + + private void UpdateChatTitle() + { + lock (_convLock) + { + ChatTitle.Text = _currentConversation?.Title ?? ""; + } + } + + // ─── 메시지 렌더링 ─────────────────────────────────────────────────── + + private const int TimelineRenderPageSize = 180; + private string? _lastRenderedConversationId; + private int _timelineRenderLimit = TimelineRenderPageSize; + + private void RenderMessages(bool preserveViewport = false) + { + var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0; + var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0; + + MessagePanel.Children.Clear(); + _runBannerAnchors.Clear(); + + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); + + var visibleMessages = conv?.Messages?.Where(msg => + { + if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase)) + return false; + + if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(msg.Content)) + return false; + + return true; + }).ToList() ?? new List(); + var visibleEvents = (conv?.ShowExecutionHistory ?? true) + ? conv?.ExecutionEvents?.ToList() ?? new List() + : new List(); + + if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0)) + { + EmptyState.Visibility = Visibility.Visible; + return; + } + + if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase)) + { + _lastRenderedConversationId = conv.Id; + _timelineRenderLimit = TimelineRenderPageSize; + } + + EmptyState.Visibility = Visibility.Collapsed; + + var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(); + foreach (var msg in visibleMessages) + timeline.Add((msg.Timestamp, 0, () => AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg))); + + foreach (var executionEvent in visibleEvents) + { + var restoredEvent = ToAgentEvent(executionEvent); + timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent))); + } + + var orderedTimeline = timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList(); + var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit); + if (hiddenCount > 0) + MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount)); + + foreach (var item in orderedTimeline.Skip(hiddenCount)) + item.Render(); + + if (!preserveViewport) + { + _ = Dispatcher.InvokeAsync(() => + { + if (MessageScroll != null) + MessageScroll.ScrollToEnd(); + }, DispatcherPriority.Background); + return; + } + + _ = Dispatcher.InvokeAsync(() => + { + if (MessageScroll == null) + return; + + var newScrollableHeight = MessageScroll.ScrollableHeight; + var delta = newScrollableHeight - previousScrollableHeight; + var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta)); + MessageScroll.ScrollToVerticalOffset(targetOffset); + }, DispatcherPriority.Background); + } + + private Border CreateTimelineLoadMoreCard(int hiddenCount) + { + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); + var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155"); + var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"); + + var loadMoreBtn = new Button + { + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 5, 10, 5), + Cursor = System.Windows.Input.Cursors.Hand, + Foreground = primaryText, + HorizontalAlignment = HorizontalAlignment.Center, + }; + loadMoreBtn.Template = BuildMinimalIconButtonTemplate(); + loadMoreBtn.Content = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE70D", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"이전 대화 {hiddenCount:N0}개 더 보기", + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + } + } + }; + loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg; + loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent; + loadMoreBtn.Click += (_, _) => + { + _timelineRenderLimit += TimelineRenderPageSize; + RenderMessages(preserveViewport: true); + }; + + return new Border + { + CornerRadius = new CornerRadius(16), + Margin = new Thickness(0, 2, 0, 12), + Padding = new Thickness(0), + Background = Brushes.Transparent, + BorderBrush = Brushes.Transparent, + BorderThickness = new Thickness(0), + HorizontalAlignment = HorizontalAlignment.Center, + Child = loadMoreBtn, + }; + } + + private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent) + { + var parsedType = Enum.TryParse(executionEvent.Type, out var eventType) + ? eventType + : AgentEventType.Thinking; + + return new AgentEvent + { + Timestamp = executionEvent.Timestamp, + RunId = executionEvent.RunId, + Type = parsedType, + ToolName = executionEvent.ToolName, + Summary = executionEvent.Summary, + FilePath = executionEvent.FilePath, + Success = executionEvent.Success, + StepCurrent = executionEvent.StepCurrent, + StepTotal = executionEvent.StepTotal, + Steps = executionEvent.Steps, + ElapsedMs = executionEvent.ElapsedMs, + InputTokens = executionEvent.InputTokens, + OutputTokens = executionEvent.OutputTokens, + }; + } + + private static bool IsCompactionMetaMessage(ChatMessage? message) + { + var kind = message?.MetaKind ?? ""; + return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase) + || kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase) + || kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase); + } + + private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) + { + var icon = "\uE9CE"; + var title = message.MetaKind switch + { + "session_memory_compaction" => "세션 메모리 압축", + "collapsed_boundary" => "압축 경계 병합", + _ => "Microcompact 경계", + }; + + var wrapper = new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(10, 4, 150, 4), + MaxWidth = GetMessageMaxWidth(), + HorizontalAlignment = HorizontalAlignment.Left, + }; + + var stack = new StackPanel(); + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 6), + }; + header.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + header.Children.Add(new TextBlock + { + Text = title, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + stack.Children.Add(header); + + var lines = (message.Content ?? "") + .Replace("\r\n", "\n") + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + foreach (var line in lines) + { + var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal); + stack.Children.Add(new TextBlock + { + Text = isHeaderLine ? line.Trim('[', ']') : line, + FontSize = isHeaderLine ? 10.5 : 10.5, + FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = isHeaderLine ? primaryText : secondaryText, + TextWrapping = TextWrapping.Wrap, + Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2), + }); + } + + if (!string.IsNullOrWhiteSpace(message.MetaRunId)) + { + stack.Children.Add(new TextBlock + { + Text = $"run {message.MetaRunId}", + FontSize = 9.5, + Foreground = secondaryText, + Opacity = 0.7, + Margin = new Thickness(0, 6, 0, 0), + }); + } + + wrapper.Child = stack; + return wrapper; + } + + private Border CreateCompactEventPill(string summary, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) + { + return new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(10, 5, 10, 5), + Margin = new Thickness(10, 4, 150, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE9CE", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = summary, + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + } + } + } + }; + } + + 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; + var userBubbleBg = hintBg; + var assistantBubbleBg = itemBg; + + if (isUser) + { + // 사용자: 우측 정렬, 얇고 단정한 카드 + var wrapper = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Right, + MaxWidth = GetMessageMaxWidth(), + Margin = new Thickness(150, 3, 16, 3), + }; + + var bubble = new Border + { + Background = userBubbleBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(13, 9, 13, 9), + Child = new TextBlock + { + Text = content, + TextAlignment = TextAlignment.Left, + FontSize = 12.5, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + LineHeight = 20, + } + }; + wrapper.Children.Add(bubble); + + // 액션 버튼 바 (복사 + 편집, hover 시 표시) + var userActionBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Opacity = 0, + Margin = new Thickness(0, 1, 0, 0), + }; + var capturedUserContent = content; + var userBtnColor = secondaryText; + userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () => + { + try { Clipboard.SetText(capturedUserContent); } catch { } + })); + userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor, + () => EnterEditMode(wrapper, capturedUserContent))); + + // 타임스탬프 + 액션 바 + var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) }; + var timestamp = message?.Timestamp ?? DateTime.Now; + userBottomBar.Children.Add(new TextBlock + { + Text = timestamp.ToString("HH:mm"), + FontSize = 9, Opacity = 0.52, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + userBottomBar.Children.Add(userActionBar); + wrapper.Children.Add(userBottomBar); + wrapper.MouseEnter += (_, _) => ShowMessageActionBar(userActionBar); + wrapper.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(userActionBar); + wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble); + + // 우클릭 → 메시지 컨텍스트 메뉴 + var userContent = content; + wrapper.MouseRightButtonUp += (_, re) => + { + re.Handled = true; + ShowMessageContextMenu(userContent, "user"); + }; + + if (animate) ApplyMessageEntryAnimation(wrapper); + MessagePanel.Children.Add(wrapper); + } + else + { + if (message != null && IsCompactionMetaMessage(message)) + { + var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush); + if (animate) ApplyMessageEntryAnimation(compactCard); + MessagePanel.Children.Add(compactCard); + return; + } + + // 어시스턴트: 좌측 정렬, 정돈된 카드 + var container = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = GetMessageMaxWidth(), + Margin = new Thickness(10, 3, 150, 3) + }; + if (animate) ApplyMessageEntryAnimation(container); + + // AI 에이전트 이름 + 아이콘 + var (agentName, _, _) = GetAgentIdentity(); + var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 2) }; + + var iconBlock = new TextBlock + { + Text = "\uE945", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 8, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + headerSp.Children.Add(iconBlock); + + headerSp.Children.Add(new TextBlock + { + Text = agentName, + FontSize = 9, + FontWeight = FontWeights.Medium, + Foreground = secondaryText, + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + container.Children.Add(headerSp); + + var contentCard = new Border + { + Background = assistantBubbleBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(13, 10, 13, 10), + }; + var contentStack = new StackPanel(); + + // 마크다운 렌더링 (파일 경로 강조 설정 연동) + var app = System.Windows.Application.Current as App; + MarkdownRenderer.EnableFilePathHighlight = + app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; + var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; + if (IsBranchContextMessage(content)) + { + var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun(); + var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3); + var branchCard = new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(0, 0, 0, 6), + }; + var branchStack = new StackPanel(); + branchStack.Children.Add(new TextBlock + { + Text = "분기 컨텍스트", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + var branchMd = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); + branchStack.Children.Add(branchMd); + if (branchFiles.Count > 0) + { + var filesWrap = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + foreach (var path in branchFiles) + { + var fileButton = new Button + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 6), + Cursor = Cursors.Hand, + ToolTip = path, + Content = new TextBlock + { + Text = System.IO.Path.GetFileName(path), + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + } + }; + var capturedPath = path; + fileButton.Click += (_, _) => OpenRunFilePath(capturedPath); + filesWrap.Children.Add(fileButton); + } + branchStack.Children.Add(filesWrap); + } + + if (branchRun != null) + { + var actionsWrap = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + var followUpButton = new Button + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 6), + Cursor = Cursors.Hand, + Content = new TextBlock + { + Text = "후속 작업 큐에 넣기", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + } + }; + var capturedBranchRun = branchRun; + followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun); + actionsWrap.Children.Add(followUpButton); + + var timelineButton = new Button + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 6), + Cursor = Cursors.Hand, + Content = new TextBlock + { + Text = "관련 로그로 이동", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + } + }; + timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId); + actionsWrap.Children.Add(timelineButton); + + branchStack.Children.Add(actionsWrap); + } + branchCard.Child = branchStack; + contentStack.Children.Add(branchCard); + } + else + { + var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); + contentStack.Children.Add(mdPanel); + } + contentCard.Child = contentStack; + container.Children.Add(contentCard); + + // 액션 버튼 바 (복사 / 좋아요 / 싫어요) + var actionBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(2, 2, 0, 0), + Opacity = 0 + }; + + var btnColor = secondaryText; + var capturedContent = content; + + actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => + { + try { Clipboard.SetText(capturedContent); } catch { } + })); + actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); + actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); + AddLinkedFeedbackButtons(actionBar, btnColor, message); + + // 타임스탬프 + var aiTimestamp = message?.Timestamp ?? DateTime.Now; + actionBar.Children.Add(new TextBlock + { + Text = aiTimestamp.ToString("HH:mm"), + FontSize = 9, Opacity = 0.52, + Foreground = btnColor, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(6, 0, 0, 0), + }); + + container.Children.Add(actionBar); + container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar); + container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar); + container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard); + + // 우클릭 → 메시지 컨텍스트 메뉴 + var aiContent = content; + container.MouseRightButtonUp += (_, re) => + { + re.Handled = true; + ShowMessageContextMenu(aiContent, "assistant"); + }; + + MessagePanel.Children.Add(container); + } + } + + // ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ───────────────────────── + + /// 커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션. + /// 현재 테마의 체크 스타일을 반환합니다. + private string GetCheckStyle() + { + var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant(); + return theme switch + { + "dark" or "system" => "circle", + "light" => "roundrect", + _ => "circle", + }; + } + + private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null) + { + var accent = accentBrush ?? TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + // 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간 + if (isChecked) + { + return CreateSimpleCheck(accent, 14); + } + + // 미선택: 동일 크기 빈 공간 (정렬 유지) + return new System.Windows.Shapes.Rectangle + { + Width = 14, Height = 14, + Fill = Brushes.Transparent, + Margin = new Thickness(0, 0, 10, 0), + }; + } + + /// ScaleTransform 바운스/스케일 애니메이션 헬퍼. + private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease) + { + if (el.RenderTransform is TransformGroup tg) + { + var st = tg.Children.OfType().FirstOrDefault(); + if (st != null) + { + var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease }; + st.BeginAnimation(ScaleTransform.ScaleXProperty, anim); + st.BeginAnimation(ScaleTransform.ScaleYProperty, anim); + return; + } + } + if (el.RenderTransform is ScaleTransform scale) + { + var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease }; + scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim); + scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim); + } + } + + /// 마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다. + /// + /// 마우스 오버 시 살짝 확대하는 호버 애니메이션. + /// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다. + /// 독립적 공간이 있는 버튼에만 적용하세요. + /// + private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08) + { + // Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능 + void EnsureTransform() + { + element.RenderTransformOrigin = new Point(0.5, 0.5); + // 봉인(frozen)된 Transform이면 새로 생성하여 교체 + if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen) + element.RenderTransform = new ScaleTransform(1, 1); + } + + element.Loaded += (_, _) => EnsureTransform(); + + element.MouseEnter += (_, _) => + { + EnsureTransform(); + var st = (ScaleTransform)element.RenderTransform; + var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; + st.BeginAnimation(ScaleTransform.ScaleXProperty, grow); + st.BeginAnimation(ScaleTransform.ScaleYProperty, grow); + }; + element.MouseLeave += (_, _) => + { + EnsureTransform(); + var st = (ScaleTransform)element.RenderTransform; + var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; + st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink); + st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink); + }; + } + + /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다. + /// + /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션. + /// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다. + /// + private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5) + { + void EnsureTransform() + { + if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen) + element.RenderTransform = new TranslateTransform(0, 0); + } + + element.Loaded += (_, _) => EnsureTransform(); + + element.MouseEnter += (_, _) => + { + EnsureTransform(); + var tt = (TranslateTransform)element.RenderTransform; + tt.BeginAnimation(TranslateTransform.YProperty, + new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); + }; + element.MouseLeave += (_, _) => + { + EnsureTransform(); + var tt = (TranslateTransform)element.RenderTransform; + tt.BeginAnimation(TranslateTransform.YProperty, + new DoubleAnimation(0, TimeSpan.FromMilliseconds(250)) + { EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } }); + }; + } + + /// 심플한 V 체크 아이콘을 생성합니다 (디자인 통일용). + private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14) + { + return new System.Windows.Shapes.Path + { + Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"), + Stroke = color, + StrokeThickness = 2, + StrokeStartLineCap = PenLineCap.Round, + StrokeEndLineCap = PenLineCap.Round, + StrokeLineJoin = PenLineJoin.Round, + Width = size, + Height = size, + Margin = new Thickness(0, 0, 10, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + } + + /// 팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다. + private static void ApplyMenuItemHover(Border item) + { + var originalBg = item.Background?.Clone() ?? Brushes.Transparent; + if (originalBg.CanFreeze) originalBg.Freeze(); + item.RenderTransformOrigin = new Point(0.5, 0.5); + item.RenderTransform = new ScaleTransform(1, 1); + item.MouseEnter += (s, _) => + { + if (s is Border b) + { + // 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경 + if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20) + b.Opacity = 0.85; + else + b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + } + var st = item.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); + }; + item.MouseLeave += (s, _) => + { + if (s is Border b) + { + b.Opacity = 1.0; + b.Background = originalBg; + } + var st = item.RenderTransform as ScaleTransform; + st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); + }; + } + + private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick) + { + var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var icon = new TextBlock + { + Text = symbol, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center + }; + var label = new TextBlock + { + Text = tooltip, + FontSize = 10.5, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0), + }; + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { icon, label } + }; + var btn = new Button + { + Content = content, + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 4, 0), + ToolTip = tooltip + }; + btn.Template = BuildMinimalIconButtonTemplate(); + btn.MouseEnter += (_, _) => + { + icon.Foreground = hoverBrush; + label.Foreground = hoverBrush; + btn.Background = hoverBg; + }; + btn.MouseLeave += (_, _) => + { + icon.Foreground = foreground; + label.Foreground = foreground; + btn.Background = Brushes.Transparent; + }; + btn.Click += (_, _) => onClick(); + ApplyHoverScaleAnimation(btn, 1.04); + return btn; + } + + private void ShowMessageActionBar(StackPanel actionBar) + { + if (actionBar == null) + return; + + actionBar.Opacity = 1; + } + + private void HideMessageActionBarIfNotSelected(StackPanel actionBar) + { + if (actionBar == null) + return; + + if (!ReferenceEquals(_selectedMessageActionBar, actionBar)) + actionBar.Opacity = 0; + } + + private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null) + { + if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar)) + _selectedMessageActionBar.Opacity = 0; + + if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder)) + ApplyMessageSelectionStyle(_selectedMessageBorder, false); + + _selectedMessageActionBar = actionBar; + _selectedMessageActionBar.Opacity = 1; + _selectedMessageBorder = messageBorder; + if (_selectedMessageBorder != null) + ApplyMessageSelectionStyle(_selectedMessageBorder, true); + } + + private void ApplyMessageSelectionStyle(Border border, bool selected) + { + if (border == null) + return; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + border.BorderBrush = selected ? accent : defaultBorder; + border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1); + border.Effect = selected + ? new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 0, + Opacity = 0.10, + Color = Colors.Black, + } + : null; + } + + private static ControlTemplate BuildMinimalIconButtonTemplate() + { + var template = new ControlTemplate(typeof(Button)); + var border = new FrameworkElementFactory(typeof(Border)); + border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); + border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty)); + border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); + border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); + var presenter = new FrameworkElementFactory(typeof(ContentPresenter)); + presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + border.AppendChild(presenter); + template.VisualTree = border; + return template; + } + + /// 좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장) + private Button CreateFeedbackButton(string outline, string filled, string tooltip, + Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", + Action? resetSibling = null, Action? registerReset = null) + { + var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var isActive = message?.Feedback == feedbackType; + var icon = new TextBlock + { + Text = isActive ? filled : outline, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = isActive ? activeColor : normalColor, + VerticalAlignment = VerticalAlignment.Center, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1) + }; + var btn = new Button + { + Content = icon, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + ToolTip = tooltip + }; + // 상대 버튼이 리셋할 수 있도록 등록 + registerReset?.Invoke(() => + { + isActive = false; + icon.Text = outline; + icon.Foreground = normalColor; + }); + btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; }; + btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; }; + btn.Click += (_, _) => + { + isActive = !isActive; + icon.Text = isActive ? filled : outline; + icon.Foreground = isActive ? activeColor : normalColor; + + // 상호 배타: 활성화 시 반대쪽 리셋 + if (isActive) resetSibling?.Invoke(); + + // 피드백 상태 저장 + if (message != null) + { + try + { + var feedback = isActive ? feedbackType : null; + var session = ChatSession; + if (session != null) + { + lock (_convLock) + { + session.UpdateMessageFeedback(_activeTab, message, feedback, _storage); + _currentConversation = session.CurrentConversation; + } + } + else + { + message.Feedback = feedback; + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv != null) _storage.Save(conv); + } + } + catch { } + } + + // 바운스 애니메이션 + var scale = (ScaleTransform)icon.RenderTransform; + var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250)) + { EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } }; + scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce); + scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce); + }; + return btn; + } + + /// 좋아요/싫어요 버튼을 상호 배타로 연결하여 추가 + private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message) + { + // resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조 + Action? resetLikeAction = null; + Action? resetDislikeAction = null; + + var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor, + new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like", + resetSibling: () => resetDislikeAction?.Invoke(), + registerReset: reset => resetLikeAction = reset); + var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor, + new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike", + resetSibling: () => resetLikeAction?.Invoke(), + registerReset: reset => resetDislikeAction = reset); + + actionBar.Children.Add(likeBtn); + actionBar.Children.Add(dislikeBtn); + } + + // ─── 메시지 등장 애니메이션 ────────────────────────────────────────── + + private static void ApplyMessageEntryAnimation(FrameworkElement element) + { + element.Opacity = 0; + element.RenderTransform = new TranslateTransform(0, 16); + element.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); + ((TranslateTransform)element.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) + { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); + } + + // ─── 메시지 편집 ────────────────────────────────────────────────────── + + private bool _isEditing; // 편집 모드 중복 방지 + + private void EnterEditMode(StackPanel wrapper, string originalText) + { + if (_isStreaming || _isEditing) return; + _isEditing = true; + + // wrapper 위치(인덱스) 기억 + var idx = MessagePanel.Children.IndexOf(wrapper); + if (idx < 0) { _isEditing = false; return; } + + // 편집 UI 생성 + var editPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Right, + MaxWidth = 540, + Margin = wrapper.Margin, + }; + + var editBox = new TextBox + { + Text = originalText, + FontSize = 13.5, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray, + CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + BorderThickness = new Thickness(1.5), + Padding = new Thickness(14, 10, 14, 10), + TextWrapping = TextWrapping.Wrap, + AcceptsReturn = false, + MaxHeight = 200, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + // 둥근 모서리 + var editBorder = new Border + { + CornerRadius = new CornerRadius(14), + Child = editBox, + ClipToBounds = true, + }; + editPanel.Children.Add(editBorder); + + // 버튼 바 + var btnBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 6, 0, 0), + }; + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + // 취소 버튼 + var cancelBtn = new Button + { + Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 0, 6, 0), + }; + cancelBtn.Click += (_, _) => + { + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + MessagePanel.Children[idx] = wrapper; // 원래 버블 복원 + }; + btnBar.Children.Add(cancelBtn); + + // 전송 버튼 + var sendBtn = new Button + { + Cursor = Cursors.Hand, + Padding = new Thickness(0), + }; + sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( + "" + + "" + + "" + + ""); + sendBtn.Click += (_, _) => + { + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + _ = SubmitEditAsync(idx, newText); + }; + btnBar.Children.Add(sendBtn); + + editPanel.Children.Add(btnBar); + + // 기존 wrapper → editPanel 교체 + MessagePanel.Children[idx] = editPanel; + + // Enter 키로도 전송 + editBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) + { + ke.Handled = true; + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + _ = SubmitEditAsync(idx, newText); + } + if (ke.Key == Key.Escape) + { + ke.Handled = true; + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + MessagePanel.Children[idx] = wrapper; + } + }; + + editBox.Focus(); + editBox.SelectAll(); + } + + private async Task SubmitEditAsync(int bubbleIndex, string newText) + { + _isEditing = false; + if (_isStreaming) return; + + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // bubbleIndex에 해당하는 user 메시지 찾기 + // UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨 + // 데이터 모델에서 해당 위치의 user 메시지 찾기 + int userMsgIdx = -1; + int uiIdx = 0; + lock (_convLock) + { + for (int i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Role == "system") continue; + if (uiIdx == bubbleIndex) + { + userMsgIdx = i; + break; + } + uiIdx++; + } + } + + if (userMsgIdx < 0) return; + + // 데이터 모델에서 편집된 메시지 이후 모두 제거 + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages[userMsgIdx].Content = newText; + while (conv.Messages.Count > userMsgIdx + 1) + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + } + + // UI에서 편집된 버블 이후 모두 제거 + while (MessagePanel.Children.Count > bubbleIndex + 1) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 편집된 메시지를 새 버블로 교체 + MessagePanel.Children.RemoveAt(bubbleIndex); + AddMessageBubble("user", newText, animate: false); + + // 마지막 위치에 삽입되도록 조정 (AddMessageBubble은 끝에 추가됨) + // bubbleIndex가 끝이 아니면 이동 — 이 경우 이후가 다 제거되었으므로 끝에 추가됨 + + // AI 재응답 + await SendRegenerateAsync(conv); + try { if (ChatSession == null) _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RefreshConversationList(); + } + + // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── + + private void StopAiIconPulse() + { + if (_aiIconPulseStopped || _activeAiIcon == null) return; + _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null); + _activeAiIcon.Opacity = 1.0; + _activeAiIcon = null; + _aiIconPulseStopped = true; + } + + private void CursorTimer_Tick(object? sender, EventArgs e) + { + _cursorVisible = !_cursorVisible; + // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당 + if (_activeStreamText != null && _displayedLength > 0) + { + var displayed = _cachedStreamContent.Length > 0 + ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)] + : ""; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + } + } + + private void ElapsedTimer_Tick(object? sender, EventArgs e) + { + var elapsed = DateTime.UtcNow - _streamStartTime; + var sec = (int)elapsed.TotalSeconds; + if (_elapsedLabel != null) + _elapsedLabel.Text = $"{sec}s"; + + // 하단 상태바 시간 갱신 + if (StatusElapsed != null) + StatusElapsed.Text = $"{sec}초"; + } + + private void TypingTimer_Tick(object? sender, EventArgs e) + { + if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return; + + var targetLen = _cachedStreamContent.Length; + if (_displayedLength >= targetLen) return; + + // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응 + var pending = targetLen - _displayedLength; + int step; + if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기 + else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속 + else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자 + + _displayedLength += step; + + var displayed = _cachedStreamContent[.._displayedLength]; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + + // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발) + if (!_userScrolled) + MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); + } + + // ─── 전송 ────────────────────────────────────────────────────────────── + + public void SendInitialMessage(string message) + { + StartNewConversation(); + InputBox.Text = message; + QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true); + } + + private void StartNewConversation() + { + // 현재 대화가 있으면 저장 후 새 대화 시작 + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.SaveCurrentConversation(_storage, _activeTab); + session.ClearCurrentConversation(_activeTab); + _currentConversation = session.LoadOrCreateConversation(_activeTab, _storage, _settings); + SyncTabConversationIdsFromSession(); + } + else + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + try { _storage.Save(_currentConversation); } catch { } + _currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings) + ?? new ChatConversation { Tab = _activeTab }; + } + } + // 탭 기억 초기화 (새 대화이므로) + _tabConversationId[_activeTab] = null; + SyncTabConversationIdsToSession(); + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + RefreshDraftQueueUi(); + if (_activeTab == "Cowork") BuildBottomBar(); + } + + /// 설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다. + private void RestoreLastConversations() + { + var session = ChatSession; + if (session == null) + return; + + _activeTab = NormalizeTabName(session.ActiveTab); + if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)) + TabChat.IsChecked = true; + else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)) + TabCowork.IsChecked = true; + else if (TabCode.IsEnabled) + TabCode.IsChecked = true; + + SyncTabConversationIdsFromSession(); + var hasRememberedConversation = !string.IsNullOrEmpty(session.GetConversationId(_activeTab)); + if (!hasRememberedConversation) + { + var latestMeta = _storage.LoadAllMeta() + .Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(c => c.Pinned) + .ThenByDescending(c => c.UpdatedAt) + .FirstOrDefault(); + if (latestMeta != null) + { + session.RememberConversation(_activeTab, latestMeta.Id); + SyncTabConversationIdsFromSession(); + hasRememberedConversation = true; + } + } + + if (hasRememberedConversation) + { + var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings); + lock (_convLock) _currentConversation = conv; + MessagePanel.Children.Clear(); + RenderMessages(); + EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + UpdateChatTitle(); + UpdateFolderBar(); + LoadConversationSettings(); + SyncAppStateWithCurrentConversation(); + RefreshConversationList(); + RefreshDraftQueueUi(); + SaveLastConversations(); + } + } + + /// 현재 _tabConversationId를 설정에 저장합니다. + private void SaveLastConversations() + { + var session = ChatSession; + if (session != null) + { + SyncTabConversationIdsToSession(); + session.ActiveTab = _activeTab; + session.Save(_settings); + SyncTabConversationIdsFromSession(); + return; + } + + var dict = new Dictionary(); + foreach (var kv in _tabConversationId) + { + if (!string.IsNullOrEmpty(kv.Value)) + dict[kv.Key] = kv.Value; + } + _settings.Settings.Llm.LastConversationIds = dict; + try { _settings.Save(); } catch { } + } + + private void BtnSend_Click(object sender, RoutedEventArgs e) + => QueueComposerDraft(priority: "now", explicitKind: null, startImmediatelyWhenIdle: true); + + private void InputBox_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (TryHandleSlashNavigationKey(e)) + return; + + // Ctrl+V: 클립보드 이미지 붙여넣기 + if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + if (TryPasteClipboardImage()) + { + e.Handled = true; + return; + } + // 이미지가 아니면 기본 텍스트 붙여넣기로 위임 + } + + if (e.Key == Key.Enter) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + // Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임) + return; + } + + // 슬래시 팝업이 열려 있으면 선택된 항목 실행 + if (SlashPopup.IsOpen && _slashPalette.SelectedIndex >= 0) + { + e.Handled = true; + ExecuteSlashSelectedItem(); + return; + } + + // /help 직접 입력 시 도움말 창 표시 + if (InputBox.Text.Trim().Equals("/help", StringComparison.OrdinalIgnoreCase)) + { + e.Handled = true; + InputBox.Text = ""; + SlashPopup.IsOpen = false; + ShowSlashHelpWindow(); + return; + } + + // Ctrl+Enter → 즉시 실행 대기열, Enter → 다음 대기열 + e.Handled = true; + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true); + return; + } + + QueueComposerDraft(priority: "next", explicitKind: null, startImmediatelyWhenIdle: true); + } + } + + /// 클립보드에 이미지가 있으면 붙여넣기. 성공 시 true. + private bool TryPasteClipboardImage() + { + if (!_settings.Settings.Llm.EnableImageInput) return false; + if (!Clipboard.ContainsImage()) return false; + + try + { + var img = Clipboard.GetImage(); + if (img == null) return false; + + // base64 인코딩 + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(img)); + using var ms = new System.IO.MemoryStream(); + encoder.Save(ms); + var bytes = ms.ToArray(); + + // 크기 제한 확인 + var maxKb = _settings.Settings.Llm.MaxImageSizeKb; + if (maxKb <= 0) maxKb = 5120; + if (bytes.Length > maxKb * 1024) + { + CustomMessageBox.Show($"이미지가 너무 큽니다 ({bytes.Length / 1024}KB, 최대 {maxKb}KB).", + "이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning); + return true; // 처리됨 (에러이지만 이미지였음) + } + + var base64 = Convert.ToBase64String(bytes); + var attachment = new ImageAttachment + { + Base64 = base64, + MimeType = "image/png", + FileName = $"clipboard_{DateTime.Now:HHmmss}.png", + }; + + _pendingImages.Add(attachment); + AddImagePreview(attachment, img); + return true; + } + catch (Exception ex) + { + Services.LogService.Debug($"클립보드 이미지 붙여넣기 실패: {ex.Message}"); + return false; + } + } + + /// 이미지 미리보기 UI 추가. + private void AddImagePreview(ImageAttachment attachment, System.Windows.Media.Imaging.BitmapSource? thumbnail = null) + { + var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray; + + var chip = new Border + { + Background = hintBg, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(4), + Margin = new Thickness(0, 0, 4, 4), + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + // 썸네일 이미지 + if (thumbnail != null) + { + sp.Children.Add(new System.Windows.Controls.Image + { + Source = thumbnail, + MaxHeight = 48, MaxWidth = 64, + Stretch = Stretch.Uniform, + Margin = new Thickness(0, 0, 4, 0), + }); + } + else + { + // base64에서 썸네일 생성 + sp.Children.Add(new TextBlock + { + Text = "\uE8B9", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, + Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(2, 0, 4, 0), + }); + } + + sp.Children.Add(new TextBlock + { + Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush, + VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + var capturedAttachment = attachment; + var capturedChip = chip; + var removeBtn = new Button + { + Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush }, + Background = Brushes.Transparent, BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), + }; + removeBtn.Click += (_, _) => + { + _pendingImages.Remove(capturedAttachment); + AttachedFilesPanel.Items.Remove(capturedChip); + if (_pendingImages.Count == 0 && _attachedFiles.Count == 0) + AttachedFilesPanel.Visibility = Visibility.Collapsed; + }; + sp.Children.Add(removeBtn); + chip.Child = sp; + + AttachedFilesPanel.Items.Add(chip); + AttachedFilesPanel.Visibility = Visibility.Visible; + } + + // ─── 슬래시 명령어 ──────────────────────────────────────────────────── + + // ── 슬래시 명령어 팝업 상태 ── + private readonly SlashPaletteState _slashPalette = new(); + private readonly Dictionary _slashVisibleItemByAbsoluteIndex = new(); + private readonly List _slashVisibleAbsoluteOrder = new(); + + // ── 슬래시 명령어 (탭별 분류) ── + + private void InputBox_TextChanged(object sender, TextChangedEventArgs e) + { + UpdateWatermarkVisibility(); + UpdateInputBoxHeight(); + ScheduleInputUiRefresh(); + var text = InputBox.Text; + + // 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제 + if (_slashPalette.ActiveCommand != null && text.StartsWith("/")) + HideSlashChip(restoreText: false); + + if (text.StartsWith("/") && !text.Contains(' ')) + { + // 탭별 필터링: Chat → "all"만, Cowork/Code → "all" + "dev" + bool isDev = _activeTab is "Cowork" or "Code"; + + // 내장 슬래시 명령어 매칭 (탭 필터) + var matches = SlashCommandCatalog.MatchBuiltinCommands(text, isDev); + + // 스킬 슬래시 명령어 매칭 (탭별 필터) + if (_settings.Settings.Llm.EnableSkillSystem) + { + var skillMatches = SkillService.MatchSlashCommand(text) + .Where(s => s.IsVisibleInTab(_activeTab)) + .Select(s => (Cmd: "/" + s.Name, + Label: BuildSlashSkillLabel(s), + IsSkill: true, Available: s.IsAvailable)); + foreach (var sm in skillMatches) + matches.Add((sm.Cmd, sm.Label, sm.IsSkill)); + } + + if (matches.Count > 0) + { + _slashPalette.Matches = matches; + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches); + RenderSlashPage(); + SlashPopup.IsOpen = true; + ScheduleInputUiRefresh(); + return; + } + } + SlashPopup.IsOpen = false; + ScheduleInputUiRefresh(); + } + + private void UpdateInputBoxHeight() + { + if (InputBox == null) + return; + + var text = InputBox.Text ?? string.Empty; + var explicitLineCount = 1 + text.Count(ch => ch == '\n'); + var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); + if (displayMode is not ("rich" or "balanced" or "simple")) + displayMode = "balanced"; + + var maxLines = displayMode switch + { + "rich" => 6, + "simple" => 4, + _ => 5, + }; + + InputBox.MinLines = 1; + InputBox.MaxLines = maxLines; + InputBox.SetCurrentValue(TextBox.HeightProperty, double.NaN); + InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Disabled; + } + + private static string BuildAssistantFallbackContent(ChatConversation conv, string runTab, string? content) + { + if (!string.IsNullOrWhiteSpace(content)) + return content; + + var latestEventSummary = conv.ExecutionEvents? + .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)) + .OrderByDescending(evt => evt.Timestamp) + .Select(evt => evt.Summary.Trim()) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(latestEventSummary)) + { + return runTab switch + { + "Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}", + "Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}", + _ => latestEventSummary, + }; + } + + return "(빈 응답)"; + } + + private static void SyncLatestAssistantMessage(ChatConversation conv, string content) + { + if (conv.Messages.Count == 0) + return; + + for (var i = conv.Messages.Count - 1; i >= 0; i--) + { + var message = conv.Messages[i]; + if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + continue; + + message.Content = content; + return; + } + } + + private void ScheduleInputUiRefresh() + { + if (_inputUiRefreshTimer == null) + return; + + _inputUiRefreshTimer.Stop(); + _inputUiRefreshTimer.Start(); + } + + private static string BuildSlashSkillLabel(SkillDefinition skill) + { + var badge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase) + ? "[FORK]" + : "[DIRECT]"; + var baseLabel = $"{badge} {skill.Label}"; + return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}"; + } + + private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true) + { + var map = _settings.Settings.Llm.SlashPaletteSections; + if (map != null && map.TryGetValue(sectionKey, out var expanded)) + return expanded; + return defaultValue; + } + + private void SetSlashSectionExpanded(string sectionKey, bool expanded) + { + var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + map[sectionKey] = expanded; + try { _settings.Save(); } catch { } + } + + private bool AreAllSlashSectionsExpanded() + { + var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); + return commandsExpanded && skillsExpanded; + } + + private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e) + { + var expandAll = !AreAllSlashSectionsExpanded(); + SetSlashSectionExpanded("slash_commands", expandAll); + SetSlashSectionExpanded("slash_skills", expandAll); + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + } + + private void BtnSlashReset_Click(object sender, RoutedEventArgs e) + { + _settings.Settings.Llm.FavoriteSlashCommands.Clear(); + _settings.Settings.Llm.RecentSlashCommands.Clear(); + try { _settings.Save(); } catch { } + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + } + + private Dictionary BuildRecentSlashRankMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var recent = _settings.Settings.Llm.RecentSlashCommands; + for (var i = 0; i < recent.Count; i++) + { + var key = recent[i]?.Trim(); + if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) + continue; + map[key] = i; // index 낮을수록 최근 + } + return map; + } + + private Dictionary BuildFavoriteSlashRankMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var fav = _settings.Settings.Llm.FavoriteSlashCommands; + var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); + for (var i = 0; i < fav.Count; i++) + { + if (i >= maxFavorites) + break; + var key = fav[i]?.Trim(); + if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) + continue; + map[key] = i; // index 낮을수록 우선 + } + return map; + } + + private void RegisterRecentSlashCommand(string cmd) + { + if (string.IsNullOrWhiteSpace(cmd)) + return; + var recent = _settings.Settings.Llm.RecentSlashCommands; + var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50); + recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase)); + recent.Insert(0, cmd); + if (recent.Count > maxRecent) + recent.RemoveRange(maxRecent, recent.Count - maxRecent); + try { _settings.Save(); } catch { } + } + + private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches) + { + var commandExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillExpanded = GetSlashSectionExpanded("slash_skills", true); + for (var i = 0; i < matches.Count; i++) + { + var visible = matches[i].IsSkill ? skillExpanded : commandExpanded; + if (visible) + return i; + } + return -1; + } + + private bool IsSlashItemVisibleByIndex(int index) + { + if (index < 0 || index >= _slashPalette.Matches.Count) + return false; + var item = _slashPalette.Matches[index]; + return item.IsSkill + ? GetSlashSectionExpanded("slash_skills", true) + : GetSlashSectionExpanded("slash_commands", true); + } + + private IReadOnlyList GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder; + + /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. + private void RenderSlashPage() + { + SlashItems.Items.Clear(); + _slashVisibleItemByAbsoluteIndex.Clear(); + _slashVisibleAbsoluteOrder.Clear(); + var total = _slashPalette.Matches.Count; + var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); + var totalCommands = total - totalSkills; + var favoriteRank = BuildFavoriteSlashRankMap(); + var recentRank = BuildRecentSlashRankMap(); + var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); + + SlashPopupTitle.Text = "명령 및 스킬"; + SlashPopupHint.Text = expressionLevel switch + { + "simple" => $"명령 {totalCommands} · 스킬 {totalSkills}", + "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동", + _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행", + }; + + var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); + if (SlashToggleGroupsLabel != null) + SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기"; + + Border CreateSlashSectionHeader(string key, string title, int count, bool expanded) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + + var header = new Border + { + Background = Brushes.Transparent, + BorderBrush = Brushes.Transparent, + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 4, 0, 2), + Cursor = Cursors.Hand, + }; + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + grid.Children.Add(new TextBlock + { + Text = expanded ? "\uE70D" : "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + var titleText = new TextBlock + { + Text = $"{title} {count}", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(titleText, 1); + grid.Children.Add(titleText); + var metaText = new TextBlock + { + Text = expanded ? "접기" : "펼치기", + FontSize = 9.5, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(metaText, 2); + grid.Children.Add(metaText); + + header.Child = grid; + header.MouseEnter += (_, _) => header.Background = hoverBrushItem; + header.MouseLeave += (_, _) => header.Background = Brushes.Transparent; + header.MouseLeftButtonDown += (_, _) => + { + SetSlashSectionExpanded(key, !expanded); + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + }; + return header; + } + + void AddSlashItem(int i) + { + var (cmd, label, isSkill) = _slashPalette.Matches[i]; + var isFavorite = favoriteRank.ContainsKey(cmd); + var isRecent = recentRank.ContainsKey(cmd); + var capturedCmd = cmd; + var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; + var skillAvailable = skillDef?.IsAvailable ?? true; + + var absoluteIndex = i; + var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + + var item = new Border + { + Background = Brushes.Transparent, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(8, 9, 8, 9), + Margin = new Thickness(0, 0, 0, 0), + Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow, + Opacity = skillAvailable ? 1.0 : 0.5, + }; + var itemGrid = new Grid(); + itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var leftStack = new StackPanel(); + var titleRow = new StackPanel { Orientation = Orientation.Horizontal }; + titleRow.Children.Add(new TextBlock + { + Text = isSkill ? "\uE768" : "\uE9CE", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = skillAvailable ? accent : secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + titleRow.Children.Add(new TextBlock + { + Text = cmd, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = skillAvailable ? primaryText : secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + if (isFavorite) + { + titleRow.Children.Add(new Border + { + Background = BrushFromHex("#FEF3C7"), + BorderBrush = BrushFromHex("#F59E0B"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(5, 0, 5, 0), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = "핀", + FontSize = 9.5, + Foreground = BrushFromHex("#92400E"), + } + }); + } + if (isRecent) + { + titleRow.Children.Add(new Border + { + Background = BrushFromHex("#EEF2FF"), + BorderBrush = BrushFromHex("#C7D2FE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(5, 0, 5, 0), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = "최근", + FontSize = 9.5, + Foreground = BrushFromHex("#3730A3"), + } + }); + } + leftStack.Children.Add(titleRow); + leftStack.Children.Add(new TextBlock + { + Text = label, + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(20, 2, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis, + }); + Grid.SetColumn(leftStack, 0); + itemGrid.Children.Add(leftStack); + + var pinToggle = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(6, 0, 0, 0), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = isFavorite ? "\uE77A" : "\uE718", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }, + ToolTip = isFavorite ? "핀 해제" : "핀 고정", + }; + pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem; + pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent; + pinToggle.MouseLeftButtonDown += (s, e) => + { + e.Handled = true; + ToggleSlashFavorite(capturedCmd); + }; + Grid.SetColumn(pinToggle, 1); + itemGrid.Children.Add(pinToggle); + + item.Child = itemGrid; + + if (skillAvailable) + { + item.MouseEnter += (_, _) => + { + _slashPalette.SelectedIndex = absoluteIndex; + UpdateSlashSelectionVisualState(); + }; + item.MouseLeave += (_, _) => UpdateSlashSelectionVisualState(); + item.MouseLeftButtonDown += (_, _) => + { + _slashPalette.SelectedIndex = absoluteIndex; + ExecuteSlashSelectedItem(); + }; + } + + SlashItems.Items.Add(item); + _slashVisibleItemByAbsoluteIndex[absoluteIndex] = item; + _slashVisibleAbsoluteOrder.Add(absoluteIndex); + } + + SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded)); + if (commandsExpanded) + { + var commandIndices = Enumerable.Range(0, total) + .Where(i => !_slashPalette.Matches[i].IsSkill) + .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) + .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) + .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); + foreach (var i in commandIndices) + { + AddSlashItem(i); + } + } + + SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded)); + if (skillsExpanded) + { + var skillIndices = Enumerable.Range(0, total) + .Where(i => _slashPalette.Matches[i].IsSkill) + .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) + .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) + .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); + foreach (var i in skillIndices) + { + AddSlashItem(i); + } + } + + var visibleCommandCount = commandsExpanded ? totalCommands : 0; + var visibleSkillCount = skillsExpanded ? totalSkills : 0; + if (visibleCommandCount + visibleSkillCount == 0) + { + SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기"; + } + else + { + SlashPopupFooter.Text = $"Enter 실행 · ↑↓/PgUp/PgDn 이동 · Home/End · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}"; + } + + UpdateSlashSelectionVisualState(); + EnsureSlashSelectionVisible(); + } + + /// 슬래시 팝업 마우스 휠 스크롤 처리. + private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + e.Handled = true; + SlashPopup_ScrollByDelta(e.Delta); + } + + private void MoveSlashSelection(int direction) + { + var visibleOrder = GetVisibleSlashOrderedIndices(); + if (visibleOrder.Count == 0) + return; + + var currentPosition = -1; + for (var i = 0; i < visibleOrder.Count; i++) + { + if (visibleOrder[i] != _slashPalette.SelectedIndex) + continue; + currentPosition = i; + break; + } + if (currentPosition < 0) + { + _slashPalette.SelectedIndex = visibleOrder[0]; + return; + } + + if (direction < 0 && currentPosition > 0) + _slashPalette.SelectedIndex = visibleOrder[currentPosition - 1]; + else if (direction > 0 && currentPosition < visibleOrder.Count - 1) + _slashPalette.SelectedIndex = visibleOrder[currentPosition + 1]; + } + + private int? FindSlashIndexClosestToViewportTop() + { + if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0) + return null; + + var bestIndex = -1; + var bestDistance = double.MaxValue; + foreach (var absoluteIndex in _slashVisibleAbsoluteOrder) + { + if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item)) + continue; + + try + { + var bounds = item.TransformToAncestor(SlashScrollViewer) + .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); + + // 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용. + var distance = Math.Abs(bounds.Top); + if (distance < bestDistance && bounds.Bottom >= 0) + { + bestDistance = distance; + bestIndex = absoluteIndex; + } + } + catch + { + // 레이아웃 갱신 중 transform 예외는 무시. + } + } + + return bestIndex >= 0 ? bestIndex : null; + } + + /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. + private void SlashPopup_ScrollByDelta(int delta) + { + if (_slashPalette.Matches.Count == 0) + return; + + if (GetVisibleSlashOrderedIndices().Count == 0) + { + if (SlashScrollViewer != null) + SlashScrollViewer.ScrollToVerticalOffset(Math.Max(0, SlashScrollViewer.VerticalOffset - delta / 3.0)); + return; + } + + // 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동. + if (SlashScrollViewer != null) + { + var target = Math.Max(0, Math.Min( + SlashScrollViewer.ScrollableHeight, + SlashScrollViewer.VerticalOffset - (delta / 3.0))); + SlashScrollViewer.ScrollToVerticalOffset(target); + } + + var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0)); + var direction = delta > 0 ? -1 : 1; + for (var i = 0; i < steps; i++) + MoveSlashSelection(direction); + + var viewportTopIndex = FindSlashIndexClosestToViewportTop(); + if (viewportTopIndex.HasValue) + _slashPalette.SelectedIndex = viewportTopIndex.Value; + + UpdateSlashSelectionVisualState(); + EnsureSlashSelectionVisible(); + } + + /// 키보드로 선택된 슬래시 아이템을 실행합니다. + private void ExecuteSlashSelectedItem() + { + var absoluteIdx = _slashPalette.SelectedIndex; + if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return; + + var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx]; + var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; + var skillAvailable = skillDef?.IsAvailable ?? true; + if (!skillAvailable) return; + RegisterRecentSlashCommand(cmd); + + SlashPopup.IsOpen = false; + _slashPalette.SelectedIndex = -1; + + if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase)) + { + InputBox.Text = ""; + ShowSlashHelpWindow(); + return; + } + ShowSlashChip(cmd); + InputBox.Focus(); + } + + private void EnsureSlashSelectionVisible() + { + if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0) + return; + + if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item)) + return; + + if (!IsVisualDescendantOf(item, SlashScrollViewer)) + return; + + Rect bounds; + try + { + bounds = item.TransformToAncestor(SlashScrollViewer) + .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); + } + catch + { + // 렌더 트리 갱신 중에는 transform이 실패할 수 있어 조용히 무시. + return; + } + + if (bounds.Top < 0) + SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8); + else if (bounds.Bottom > SlashScrollViewer.ViewportHeight) + SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8); + } + + private static bool IsVisualDescendantOf(DependencyObject? child, DependencyObject? parent) + { + if (child == null || parent == null) + return false; + + var current = child; + while (current != null) + { + if (ReferenceEquals(current, parent)) + return true; + current = VisualTreeHelper.GetParent(current); + } + + return false; + } + + private void UpdateSlashSelectionVisualState() + { + if (_slashVisibleItemByAbsoluteIndex.Count == 0) + return; + + var selectedIndex = _slashPalette.SelectedIndex; + var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + + foreach (var (absoluteIndex, element) in _slashVisibleItemByAbsoluteIndex) + { + if (element is not Border border) + continue; + + var selected = absoluteIndex == selectedIndex; + border.Background = selected ? hoverBrushItem : Brushes.Transparent; + border.BorderBrush = selected ? accent : borderBrush; + border.BorderThickness = selected ? new Thickness(2, 0, 0, 1) : new Thickness(0, 0, 0, 1); + } + } + + /// 슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다. + private void ToggleSlashFavorite(string cmd) + { + var favs = _settings.Settings.Llm.FavoriteSlashCommands; + var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); + var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + favs.Remove(existing); + else + { + favs.Add(cmd); + if (favs.Count > maxFavorites) + favs.RemoveRange(maxFavorites, favs.Count - maxFavorites); + } + + _settings.Save(); + + if (SlashPopup.IsOpen) + { + RenderSlashPage(); + return; + } + + // 팝업이 닫힌 경우에만 TextChanged 트리거 + var currentText = InputBox.Text; + InputBox.TextChanged -= InputBox_TextChanged; + InputBox.Text = ""; + InputBox.TextChanged += InputBox_TextChanged; + InputBox.Text = currentText; + } + + /// 슬래시 명령어 칩을 표시하고 InputBox를 비웁니다. + private void ShowSlashChip(string cmd) + { + _slashPalette.ActiveCommand = cmd; + SlashChipText.Text = cmd; + SlashCommandChip.Visibility = Visibility.Visible; + + // 칩 너비 측정 후 InputBox 왼쪽 여백 조정 + SlashCommandChip.UpdateLayout(); + var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6; + InputBox.Padding = new Thickness(chipRight, 10, 14, 10); + InputBox.Text = ""; + } + + /// 슬래시 명령어 칩을 숨깁니다. + /// true이면 InputBox에 명령어 텍스트를 복원합니다. + private void HideSlashChip(bool restoreText = false) + { + if (_slashPalette.ActiveCommand == null) return; + var prev = _slashPalette.ActiveCommand; + _slashPalette.ActiveCommand = null; + SlashCommandChip.Visibility = Visibility.Collapsed; + InputBox.Padding = new Thickness(14, 10, 14, 10); + if (restoreText) + { + InputBox.Text = prev + " "; + InputBox.CaretIndex = InputBox.Text.Length; + } + } + + /// 슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다. + private (string? slashSystem, string userText) ParseSlashCommand(string input) + { + var trimmed = input.TrimStart(); + if (trimmed.StartsWith("/")) + { + var firstSpace = trimmed.IndexOf(' '); + var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim(); + if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry)) + { + // __HELP__는 특수 처리 (ParseSlashCommand에서는 무시) + if (entry.SystemPrompt == "__HELP__") return (null, input); + var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : ""; + return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest); + } + } + + // 스킬 명령어 매칭 + var matchedSkill = SkillService.MatchSlashInvocation(input); + if (matchedSkill != null) + { + var slashCmd = "/" + matchedSkill.Name; + var rest = input[slashCmd.Length..].Trim(); + var runtimePolicy = SkillService.BuildRuntimeDirective(matchedSkill); + var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy) + ? matchedSkill.SystemPrompt + : $"{matchedSkill.SystemPrompt}\n\n{runtimePolicy}"; + return (mergedPrompt, string.IsNullOrEmpty(rest) ? matchedSkill.Label : rest); + } + + return (null, input); + } + + // ─── 드래그 앤 드롭 AI 액션 팝업 ───────────────────────────────────── + + private static readonly Dictionary> DropActions = new(StringComparer.OrdinalIgnoreCase) + { + ["code"] = + [ + ("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."), + ("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."), + ("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."), + ("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."), + ], + ["document"] = + [ + ("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."), + ("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."), + ("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."), + ], + ["data"] = + [ + ("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."), + ("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."), + ("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"), + ], + ["image"] = + [ + ("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."), + ("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."), + ], + }; + + private static readonly HashSet CodeExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" }; + private static readonly HashSet DataExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" }; + // ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용 + + private Popup? _dropActionPopup; + + private void ShowDropActionMenu(string[] files) + { + // 파일 유형 판별 + var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant(); + string category; + if (CodeExtensions.Contains(ext)) category = "code"; + else if (DataExtensions.Contains(ext)) category = "data"; + else if (ImageExtensions.Contains(ext)) category = "image"; + else category = "document"; + + var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"]; + + // 팝업 생성 + _dropActionPopup?.SetValue(Popup.IsOpenProperty, false); + + var panel = new StackPanel(); + // 헤더 + var header = new TextBlock + { + Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" 외 {files.Length - 1}개" : "")}", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(12, 8, 12, 6), + }; + panel.Children.Add(header); + + // 액션 항목 + var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(24, 255, 255, 255)); + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var textBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + + foreach (var (label, icon, prompt) in actions) + { + var capturedPrompt = prompt; + var row = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(12, 7, 12, 7), + Margin = new Thickness(4, 1, 4, 1), + Cursor = Cursors.Hand, + }; + var stack = new StackPanel { Orientation = Orientation.Horizontal }; + stack.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + stack.Children.Add(new TextBlock + { + Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center, + }); + row.Child = stack; + + row.MouseEnter += (_, _) => row.Background = hoverBrush; + row.MouseLeave += (_, _) => row.Background = Brushes.Transparent; + row.MouseLeftButtonUp += (_, _) => + { + if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; + foreach (var f in files) AddAttachedFile(f); + InputBox.Text = capturedPrompt; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + if (_settings.Settings.Llm.DragDropAutoSend) + QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true); + }; + panel.Children.Add(row); + } + + // "첨부만" 항목 + var attachOnly = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(12, 7, 12, 7), + Margin = new Thickness(4, 1, 4, 1), + Cursor = Cursors.Hand, + }; + var attachStack = new StackPanel { Orientation = Orientation.Horizontal }; + attachStack.Children.Add(new TextBlock + { + Text = "\uE723", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + attachStack.Children.Add(new TextBlock + { + Text = "첨부만", FontSize = 13, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }); + attachOnly.Child = attachStack; + attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush; + attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent; + attachOnly.MouseLeftButtonUp += (_, _) => + { + if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; + foreach (var f in files) AddAttachedFile(f); + InputBox.Focus(); + }; + panel.Children.Add(attachOnly); + + var container = new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)), + CornerRadius = new CornerRadius(12), + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(4, 4, 4, 6), + Child = panel, + MinWidth = 200, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, + }, + }; + + _dropActionPopup = new Popup + { + PlacementTarget = InputBorder, + Placement = PlacementMode.Top, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + Child = container, + }; + _dropActionPopup.IsOpen = true; + } + + // ─── /help 도움말 창 ───────────────────────────────────────────────── + + private void ShowSlashHelpWindow() + { + var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)); + var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var fg2 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(40, 255, 255, 255)); + + var win = new Window + { + Title = "AX Agent — 슬래시 명령어 도움말", + Width = 560, Height = 640, MinWidth = 440, MinHeight = 500, + WindowStyle = WindowStyle.None, AllowsTransparency = true, + Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize, + WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this, + Icon = Icon, + }; + + var mainBorder = new Border + { + Background = bg, CornerRadius = new CornerRadius(16), + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), Margin = new Thickness(10), + Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 }, + }; + + var rootGrid = new Grid(); + rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) }); + rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + // 헤더 + var headerBorder = new Border + { + CornerRadius = new CornerRadius(16, 16, 0, 0), + Background = new LinearGradientBrush( + Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204), + new Point(0, 0), new Point(1, 1)), + Padding = new Thickness(20, 0, 20, 0), + }; + var headerGrid = new Grid(); + var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }); + headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center }); + headerGrid.Children.Add(headerStack); + + var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center }; + closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255)); + closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent; + closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); }; + headerGrid.Children.Add(closeBtn); + headerBorder.Child = headerGrid; + Grid.SetRow(headerBorder, 0); + rootGrid.Children.Add(headerBorder); + + // 콘텐츠 + var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) }; + var contentPanel = new StackPanel(); + + // 설명 + contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 }); + + // 공통 명령어 섹션 + AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, + ("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."), + ("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"), + ("/new", "새 대화를 시작합니다."), + ("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."), + ("/model", "모델 선택 패널을 엽니다."), + ("/permissions", "권한 설정 패널을 엽니다."), + ("/allowed-tools", "허용 도구(권한) 패널을 엽니다."), + ("/settings", "AX Agent 설정 창을 엽니다."), + ("/stats", "최근 호출의 토큰 통계를 표시합니다."), + ("/cost", "최근 호출의 추정 비용을 표시합니다."), + ("/export", "현재 대화를 파일로 내보냅니다."), + ("/clear", "현재 대화를 정리하고 새 대화를 시작합니다.")); + + AddHelpSection(contentPanel, "작업/운영 명령어", "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", "변경 코드를 리뷰하고 리스크를 찾습니다."), + ("/commit", "변경사항을 확인하고 승인 후 실제 커밋을 실행합니다. (files: 지정 지원)"), + ("/ultrareview", "더 엄격한 리뷰 기준으로 치명 리스크를 우선 점검합니다."), + ("/security-review", "보안 중심으로 취약점과 개선안을 점검합니다."), + ("/pr", "변경사항을 PR 설명 형식으로 정리합니다."), + ("/pr-comments", "리뷰 코멘트 형태의 개선 의견을 작성합니다."), + ("/test", "테스트 생성/개선 방향을 제시합니다."), + ("/verify", "빌드/테스트/리스크 점검까지 포함한 검증 모드로 실행합니다."), + ("/structure", "프로젝트 구조를 분석합니다."), + ("/build", "빌드 및 오류 분석을 진행합니다."), + ("/search", "코드베이스 검색을 수행합니다."), + ("/diff", "현재 diff의 핵심 변경점/리스크를 요약합니다."), + ("/doctor", "프로젝트/환경 점검 체크를 수행합니다.")); + + AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg, + ("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"), + ("/agents", "에이전트 분담 전략 제시"), + ("/plugin", "플러그인 구성 점검"), + ("/reload-plugins", "플러그인 재로드 점검"), + ("/install-github-app", "GitHub 앱 연동 안내"), + ("/install-slack-app", "Slack 앱 연동 안내"), + ("/remote-env", "원격 환경 연결 점검"), + ("/ide", "IDE 연동 점검"), + ("/terminal-setup", "터미널 초기 구성 점검")); + + // 스킬 명령어 섹션 + var skills = SkillService.Skills; + if (skills.Count > 0) + { + var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray(); + AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — %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) }); + var tips = new[] + { + "/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.", + "파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.", + "스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.", + "Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.", + }; + foreach (var tip in tips) + { + tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 }); + } + contentPanel.Children.Add(tipPanel); + + scroll.Content = contentPanel; + Grid.SetRow(scroll, 1); + rootGrid.Children.Add(scroll); + + mainBorder.Child = rootGrid; + win.Content = mainBorder; + // 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지) + headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch { } }; + win.ShowDialog(); + } + + private static void AddHelpSection(StackPanel parent, string title, string subtitle, + Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg, + params (string Cmd, string Desc)[] items) + { + parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) }); + parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) }); + + foreach (var (cmd, desc) in items) + { + var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) }; + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = new FontFamily("Consolas") }; + Grid.SetColumn(cmdText, 0); + grid.Children.Add(cmdText); + + var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + Grid.SetColumn(descText, 1); + grid.Children.Add(descText); + + row.Child = grid; + row.MouseEnter += (_, _) => row.Background = hoverBg; + row.MouseLeave += (_, _) => row.Background = itemBg; + parent.Children.Add(row); + } + } + + private async Task ExecuteManualCompactAsync(string commandText, string runTab) + { + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + _currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab }; + conv = _currentConversation; + if (runTab is "Cowork" or "Code") + conv.ShowExecutionHistory = false; + } + + var userMsg = new ChatMessage { Role = "user", Content = commandText }; + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, userMsg, useForTitle: true); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(userMsg); + } + } + + SaveLastConversations(); + _storage.Save(conv); + ChatSession?.RememberConversation(runTab, conv.Id); + UpdateChatTitle(); + InputBox.Text = ""; + EmptyState.Visibility = Visibility.Collapsed; + RenderMessages(preserveViewport: true); + ForceScrollToEnd(); + + var llm = _settings.Settings.Llm; + var working = conv.Messages.ToList(); + var compactResult = await ContextCondenser.CondenseWithStatsAsync( + working, + _llm, + llm.MaxContextTokens, + llm.EnableProactiveContextCompact, + llm.ContextCompactTriggerPercent, + true, + CancellationToken.None); + var beforeTokens = compactResult.BeforeTokens; + var afterTokens = compactResult.AfterTokens; + RecordCompactionStats(compactResult, wasAutomatic: false); + + if (compactResult.Changed) + { + lock (_convLock) + { + conv.Messages = working; + } + } + + var assistantText = compactResult.Changed + ? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}\n- 단계: {compactResult.StageSummary}\n- 절감량: {compactResult.SavedTokens:N0} tokens" + : "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다."; + + lock (_convLock) + { + var session = ChatSession; + _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, _storage); + _currentConversation = session?.CurrentConversation ?? conv; + conv = _currentConversation!; + } + + SaveLastConversations(); + _storage.Save(conv); + RenderMessages(preserveViewport: true); + ForceScrollToEnd(); + if (StatusTokens != null) + StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}"; + SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false); + RefreshContextUsageVisual(); + RefreshConversationList(); + UpdateTaskSummaryIndicators(); + } + + private void AppendLocalSlashResult(string runTab, string commandText, string assistantText) + { + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + _currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab }; + conv = _currentConversation; + } + + var userMsg = new ChatMessage { Role = "user", Content = commandText }; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, userMsg, useForTitle: true); + _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, _storage); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(userMsg); + _chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, _storage); + } + } + + SaveLastConversations(); + _storage.Save(conv); + ChatSession?.RememberConversation(runTab, conv.Id); + UpdateChatTitle(); + InputBox.Text = ""; + EmptyState.Visibility = Visibility.Collapsed; + RenderMessages(preserveViewport: true); + 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 static string MaskEndpoint(string endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint)) + return "(미설정)"; + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + return endpoint; + + return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}"; + } + + private static string NormalizeServiceLabel(string service) + { + return service.Trim().ToLowerInvariant() switch + { + "vllm" => "vLLM", + "gemini" => "Gemini", + "sigmoid" => "Claude", + _ => "Ollama", + }; + } + + private async Task BuildLlmRuntimeDiagnosisAsync() + { + var snapshot = _llm.GetRuntimeConnectionSnapshot(); + var (ok, message) = await _llm.TestConnectionAsync(); + + var lines = new List + { + "LLM 런타임 진단", + $"- 서비스: {NormalizeServiceLabel(snapshot.Service)}", + $"- 모델: {snapshot.Model}", + $"- 엔드포인트: {MaskEndpoint(snapshot.Endpoint)}", + $"- API 키: {(snapshot.HasApiKey ? "설정됨" : "미설정")}", + }; + + if (string.Equals(snapshot.Service, "vllm", StringComparison.OrdinalIgnoreCase)) + lines.Add($"- SSL 인증서 검증 우회: {(snapshot.AllowInsecureTls ? "ON" : "OFF")}"); + + lines.Add($"- 연결 결과: {(ok ? "성공" : "실패")} ({message})"); + return string.Join("\n", lines); + } + + private bool IsMcpServerEnabled(McpServerEntry server) + { + if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden)) + return overridden; + return server.Enabled; + } + + private bool IsChromeMcpCandidate(McpServerEntry server) + { + if (!IsMcpServerEnabled(server)) + return false; + + return + (server.Name?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + (server.Command?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + (server.Url?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + server.Args.Any(a => a.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) >= 0); + } + + private async Task 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(BuildEffectiveMcpServer(server)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected) + { + lines.Add($"- {serverName}: 연결 실패"); + continue; + } + + connectedCount++; + var tools = client.Tools; + totalTools += tools.Count; + lines.Add($"- {serverName}: 연결 성공 (도구 {tools.Count}개)"); + + var toolPreview = tools + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Take(6) + .ToList(); + if (toolPreview.Count > 0) + lines.Add($" 도구: {string.Join(", ", toolPreview)}"); + } + + if (connectedCount == 0) + { + lines.Add("진단 결과: 연결 가능한 Chrome MCP 서버가 없습니다."); + lines.Add("확인 항목: command/args 경로, 실행 권한, Node/NPM 설치 상태, 서버 실행 로그"); + return string.Join("\n", lines); + } + + lines.Add($"진단 결과: {connectedCount}개 서버 연결 성공, 총 도구 {totalTools}개 확인"); + lines.Add("다음 단계: /mcp 또는 /mcp reconnect <서버명> 으로 상태를 갱신하세요."); + return string.Join("\n", lines); + } + + private async Task<(bool Ready, List 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(BuildEffectiveMcpServer(server)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected || client.Tools.Count == 0) + continue; + + readyServers.Add(string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name); + toolNames.AddRange(client.Tools.Select(t => $"mcp_{t.ServerName}_{t.Name}")); + } + + return (readyServers.Count > 0, readyServers, toolNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + + internal static string BuildChromeExecutionSystemPrompt(string userRequest, IEnumerable 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), + "add" => ("add", target), + "remove" => ("remove", target), + "reset" => ("reset", target), + "login" => ("login", target), + "logout" => ("logout", target), + "status" => ("status", target), + _ => ("help", text), + }; + } + + internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target) + { + var raw = (target ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return (false, "", "", "사용법: /mcp login <서버명> <토큰>"); + + var firstSpace = raw.IndexOf(' '); + if (firstSpace <= 0 || firstSpace >= raw.Length - 1) + return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>"); + + var serverTarget = raw[..firstSpace].Trim(); + var token = raw[(firstSpace + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(serverTarget)) + return (false, "", "", "서버명이 비어 있습니다."); + if (string.IsNullOrWhiteSpace(token)) + return (false, "", "", "토큰이 비어 있습니다."); + + return (true, serverTarget, token, ""); + } + + internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target) + { + var raw = (target ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse "); + + var sep = raw.IndexOf("::", StringComparison.Ordinal); + if (sep < 0) + return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js"); + + var name = raw[..sep].Trim(); + var spec = raw[(sep + 2)..].Trim(); + if (string.IsNullOrWhiteSpace(name)) + return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요."); + if (string.IsNullOrWhiteSpace(spec)) + return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요."); + + var tokens = TokenizeCommand(spec); + if (tokens.Count < 2) + return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse"); + + var transport = tokens[0].Trim().ToLowerInvariant(); + if (transport == "stdio") + { + var command = tokens[1].Trim(); + if (string.IsNullOrWhiteSpace(command)) + return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다."); + + var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List(); + return (true, new McpServerEntry + { + Name = name, + Transport = "stdio", + Command = command, + Args = args, + Enabled = true, + }, ""); + } + + if (transport == "sse") + { + var url = string.Join(" ", tokens.Skip(1)).Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) + return (false, null, $"유효하지 않은 SSE URL 입니다: {url}"); + if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다."); + + return (true, new McpServerEntry + { + Name = name, + Transport = "sse", + Url = url, + Enabled = true, + }, ""); + } + + return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse"); + } + + internal static List TokenizeCommand(string input) + { + var text = input ?? ""; + var tokens = new List(); + var sb = new System.Text.StringBuilder(); + var inQuote = false; + + foreach (var ch in text) + { + if (ch == '"') + { + inQuote = !inQuote; + continue; + } + + if (!inQuote && char.IsWhiteSpace(ch)) + { + if (sb.Length == 0) continue; + tokens.Add(sb.ToString()); + sb.Clear(); + continue; + } + + sb.Append(ch); + } + + if (sb.Length > 0) + tokens.Add(sb.ToString()); + + return tokens; + } + + private async Task 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(); + var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : ""; + if (!IsMcpServerEnabled(server)) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); + continue; + } + + if (!runtimeCheck) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); + continue; + } + + if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); + continue; + } + + using var client = new McpClientService(BuildEffectiveMcpServer(server)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); + continue; + } + + var toolCount = client.Tools.Count; + var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount); + lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}"); + } + + lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>"); + return string.Join("\n", lines); + } + + internal static string ResolveMcpDisplayStatus(bool isEnabled, string transport, bool runtimeCheck, bool connected, int? toolCount) + { + var normalizedTransport = string.IsNullOrWhiteSpace(transport) ? "stdio" : transport.Trim().ToLowerInvariant(); + if (!isEnabled) + return "Disabled"; + + if (!runtimeCheck) + return "Enabled"; + + if (!string.Equals(normalizedTransport, "stdio", StringComparison.OrdinalIgnoreCase)) + return "Configured"; + + if (!connected) + return "Disconnected"; + + if ((toolCount ?? 0) <= 0) + return "NeedsAuth (도구 0개)"; + + return $"Connected (도구 {toolCount}개)"; + } + + private string ResolveMcpServerName(IEnumerable servers, string inputName) + { + var trimmed = inputName.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + return ""; + var exact = servers.FirstOrDefault(s => string.Equals(s.Name, trimmed, StringComparison.OrdinalIgnoreCase)); + if (exact != null) + return exact.Name; + var partial = servers.FirstOrDefault(s => (s.Name?.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0); + return partial?.Name ?? ""; + } + + private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server) + { + var clone = new McpServerEntry + { + Name = server.Name, + Command = server.Command, + Args = server.Args?.ToList() ?? new List(), + Env = new Dictionary(server.Env ?? new Dictionary(), StringComparer.OrdinalIgnoreCase), + Enabled = server.Enabled, + Transport = server.Transport, + Url = server.Url, + }; + + var key = clone.Name ?? ""; + if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token)) + clone.Env["MCP_AUTH_TOKEN"] = token; + + return clone; + } + + private async Task HandleMcpSlashAsync(string displayText, CancellationToken ct = default) + { + var llm = _settings.Settings.Llm; + llm.McpServers ??= new List(); + var servers = llm.McpServers; + var (action, target) = ParseMcpAction(displayText); + if (action == "help") + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>"; + + if (action == "status") + return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); + + if (action == "reset") + { + var changed = _sessionMcpEnabledOverrides.Count; + _sessionMcpEnabledOverrides.Clear(); + _sessionMcpAuthTokens.Clear(); + return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action == "login") + { + var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target); + if (!ok) + return error; + + var resolved = ResolveMcpServerName(servers, serverTarget); + if (string.IsNullOrWhiteSpace(resolved)) + return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}"; + + _sessionMcpAuthTokens[resolved] = token; + return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); + } + + if (action == "logout") + { + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + var removed = _sessionMcpAuthTokens.Count; + _sessionMcpAuthTokens.Clear(); + return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"로그아웃 대상 서버를 찾지 못했습니다: {target}"; + + var removedOne = _sessionMcpAuthTokens.Remove(resolved); + return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action == "add") + { + var (ok, entry, error) = ParseMcpAddTarget(target); + if (!ok || entry == null) + return error; + + var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase)); + if (duplicate) + return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요."; + + servers.Add(entry); + _settings.Save(); + _sessionMcpEnabledOverrides.Remove(entry.Name); + + return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (servers.Count == 0) + return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요."; + + if (action == "remove") + { + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + var removed = servers.Count; + servers.Clear(); + _sessionMcpEnabledOverrides.Clear(); + _sessionMcpAuthTokens.Clear(); + _settings.Save(); + return $"MCP 서버를 모두 제거했습니다. ({removed}개)"; + } + + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"제거 대상 서버를 찾지 못했습니다: {target}"; + + var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); + _sessionMcpEnabledOverrides.Remove(resolved); + _sessionMcpAuthTokens.Remove(resolved); + _settings.Save(); + return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action is "enable" or "disable") + { + var newEnabled = action == "enable"; + int changed; + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + changed = 0; + foreach (var server in servers.Where(s => IsMcpServerEnabled(s) != newEnabled)) + { + _sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled; + changed++; + } + } + else + { + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"대상 서버를 찾지 못했습니다: {target}"; + var server = servers.First(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); + changed = IsMcpServerEnabled(server) == newEnabled ? 0 : 1; + _sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled; + } + + var status = newEnabled ? "활성화" : "비활성화"; + return $"MCP 서버 {status} 완료 ({changed}개 변경, 현재 세션에만 적용)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action == "reconnect") + { + List targetServers; + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + targetServers = servers.Where(IsMcpServerEnabled).ToList(); + } + else + { + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"재연결 대상 서버를 찾지 못했습니다: {target}"; + targetServers = servers.Where(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (targetServers.Count == 0) + return "재연결할 활성 MCP 서버가 없습니다."; + + return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false); + } + + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>"; + } + + private string BuildSlashHeapDumpText() + { + var proc = System.Diagnostics.Process.GetCurrentProcess(); + var managedBytes = GC.GetTotalMemory(forceFullCollection: false); + var workingSet = proc.WorkingSet64; + var privateBytes = proc.PrivateMemorySize64; + return + $"메모리 진단 (heapdump)\n" + + $"- Managed Heap: {managedBytes / 1024.0 / 1024.0:F1} MB\n" + + $"- Working Set: {workingSet / 1024.0 / 1024.0:F1} MB\n" + + $"- Private Bytes: {privateBytes / 1024.0 / 1024.0:F1} MB\n" + + $"- GC Gen0/1/2: {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}"; + } + + private string CyclePassPreset() + { + var llm = _settings.Settings.Llm; + var current = llm.MaxAgentIterations <= 16 ? 16 : llm.MaxAgentIterations <= 25 ? 25 : 40; + var next = current switch + { + 16 => 25, + 25 => 40, + _ => 16, + }; + llm.MaxAgentIterations = next; + _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(); + + // 슬래시 칩이 활성화된 경우 명령어 앞에 붙임 + var text = _slashPalette.ActiveCommand != null + ? (_slashPalette.ActiveCommand + " " + rawText).Trim() + : rawText; + HideSlashChip(restoreText: false); + + if (string.IsNullOrEmpty(text) || _isStreaming) return; + + // placeholder 정리 + ClearPromptCardPlaceholder(); + + // 슬래시 명령어 처리 + 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)) + { + var (statusAction, _) = ParseGenericAction(displayText ?? "", "/status"); + if (statusAction is "test" or "diag" or "connection" or "connect") + { + var diagnosis = await BuildLlmRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/status", diagnosis); + return; + } + + AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText() + "\n(연결 점검: /status test)"); + return; + } + if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal)) + { + var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions"); + if (TryApplyPermissionModeFromAction(permAction, out var appliedMode)) + { + AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(appliedMode)}({appliedMode})로 변경했습니다.\n{BuildPermissionStatusText()}"); + return; + } + + if (permAction == "status") + { + AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|accept|plan|bypass|status"); + return; + } + + OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|accept|plan|bypass|status"); + return; + } + if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) + { + var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools"); + if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode)) + { + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(allowedMode)}({allowedMode})로 변경했습니다.\n{BuildPermissionStatusText()}"); + return; + } + + if (toolAction == "status") + { + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|accept|plan|bypass|status"); + return; + } + + OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|accept|plan|bypass|status"); + return; + } + if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) + { + BtnModelSelector_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/model", "모델 선택 패널을 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__SETTINGS__", StringComparison.Ordinal)) + { + var (settingsAction, _) = ParseGenericAction(displayText ?? "", "/settings"); + if (settingsAction == "model") + { + BtnModelSelector_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/settings", "모델 선택 패널을 열었습니다."); + return; + } + + if (settingsAction == "permissions") + { + OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions"); + return; + } + + if (settingsAction == "mcp") + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. MCP 항목에서 서버를 관리하세요."); + return; + } + + if (settingsAction == "theme") + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. 테마 항목을 확인하세요."); + return; + } + + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. (사용법: /settings model|permissions|mcp|theme)"); + return; + } + if (string.Equals(slashSystem, "__THEME__", StringComparison.Ordinal)) + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/theme", "설정 창을 열었습니다. AX Agent 테마를 변경할 수 있습니다."); + return; + } + if (string.Equals(slashSystem, "__STATS__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/stats", BuildSlashStatsText()); + return; + } + if (string.Equals(slashSystem, "__COST__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/cost", BuildSlashCostText()); + return; + } + if (string.Equals(slashSystem, "__EXPORT__", StringComparison.Ordinal)) + { + ExportConversation(); + AppendLocalSlashResult(_activeTab, "/export", "현재 대화를 내보냈습니다."); + return; + } + if (string.Equals(slashSystem, "__STATUSLINE__", StringComparison.Ordinal)) + { + var line = BuildSlashStatuslineText(); + SetStatus(line, spinning: false); + AppendLocalSlashResult(_activeTab, "/statusline", line); + return; + } + if (string.Equals(slashSystem, "__HEAPDUMP__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/heapdump", BuildSlashHeapDumpText()); + return; + } + if (string.Equals(slashSystem, "__PASSES__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/passes", CyclePassPreset()); + return; + } + if (string.Equals(slashSystem, "__CHROME__", StringComparison.Ordinal)) + { + var chromeInput = (displayText ?? "").Trim(); + var hasArgs = !string.IsNullOrWhiteSpace(chromeInput) + && !string.Equals(chromeInput, "/chrome", StringComparison.OrdinalIgnoreCase); + if (!hasArgs) + { + var diagnosis = await BuildChromeRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/chrome", diagnosis); + return; + } + + if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + AppendLocalSlashResult(_activeTab, "/chrome", + "브라우저 제어 실행은 Cowork/Code 탭에서 지원됩니다. 탭 전환 후 다시 실행해 주세요.\n" + + $"예: /chrome {chromeInput}"); + return; + } + + var probe = await ProbeChromeToolingAsync(); + if (!probe.Ready) + { + var reconnectResult = await HandleMcpSlashAsync("/mcp reconnect all"); + probe = await ProbeChromeToolingAsync(); + if (probe.Ready) + { + slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames); + text = chromeInput; + SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비(재연결 성공)", spinning: false); + AppendLocalSlashResult(_activeTab, "/chrome", "사전 점검에서 연결이 부족해 /mcp reconnect all을 자동 실행했고 재시도에 성공했습니다."); + goto CHROME_ROUTING_READY; + } + + var diagnosis = await BuildChromeRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/chrome", + "브라우저 MCP 도구가 준비되지 않아 실행을 시작하지 못했습니다.\n" + + "자동 재시도(/mcp reconnect all) 결과:\n" + reconnectResult + "\n\n" + diagnosis); + return; + } + + slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames); + text = chromeInput; + SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비", spinning: false); + CHROME_ROUTING_READY:; + } + if (string.Equals(slashSystem, "__MCP__", StringComparison.Ordinal)) + { + var mcpResult = await HandleMcpSlashAsync(displayText ?? ""); + AppendLocalSlashResult(_activeTab, "/mcp", mcpResult); + return; + } + if (string.Equals(slashSystem, "__STICKERS__", StringComparison.Ordinal)) + { + var queueSummary = _appState.GetDraftQueueSummary(_activeTab); + var activeCount = _appState.ActiveTasks.Count; + AppendLocalSlashResult(_activeTab, "/stickers", + "빠른 상태 스티커\n" + + $"- [RUN] 진행중 작업 {activeCount}\n" + + $"- [QUEUE] 대기 {queueSummary.QueuedCount}\n" + + $"- [BLOCK] 승인 대기 {queueSummary.BlockedCount}\n" + + "- [DONE] 완료 후 /statusline 으로 상태 확인"); + return; + } + if (string.Equals(slashSystem, "__THINKBACK__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/thinkback", BuildThinkbackSummaryText()); + return; + } + if (string.Equals(slashSystem, "__THINKBACK_PLAY__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/thinkback-play", BuildThinkbackPlayText()); + return; + } + if (string.Equals(slashSystem, "__SKILLS__", StringComparison.Ordinal)) + { + OpenSkillsFromSlash(); + AppendLocalSlashResult(_activeTab, "/skills", "스킬 브라우저를 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal)) + { + var mode = TogglePermissionModeFromSlash(); + AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(mode)}({mode})로 변경했습니다."); + return; + } + if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal)) + { + if (PromptRenameConversationFromSlash(out var renamedTitle)) + AppendLocalSlashResult(_activeTab, "/rename", $"대화 이름을 \"{renamedTitle}\"로 변경했습니다."); + else + AppendLocalSlashResult(_activeTab, "/rename", "이름 변경을 취소했습니다."); + return; + } + if (string.Equals(slashSystem, "__FEEDBACK__", StringComparison.Ordinal)) + { + ChatConversation? currentConv; + lock (_convLock) + currentConv = _currentConversation; + var hasAssistant = currentConv?.Messages.Any(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) == true; + if (!hasAssistant) + { + AppendLocalSlashResult(_activeTab, "/feedback", "피드백할 이전 응답이 없습니다. 먼저 대화를 진행한 뒤 다시 시도하세요."); + return; + } + + ShowRetryWithFeedbackInput(); + AppendLocalSlashResult(_activeTab, "/feedback", "수정 피드백 입력 패널을 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__VERIFY__", StringComparison.Ordinal)) + { + if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + AppendLocalSlashResult(_activeTab, "/verify", "검증 모드는 Cowork/Code 탭에서만 실행할 수 있습니다."); + return; + } + + slashSystem = BuildVerifySystemPrompt(displayText ?? ""); + text = string.IsNullOrWhiteSpace(displayText) ? "현재 변경사항 검증 실행" : displayText; + SetStatus("검증 모드 실행 준비...", spinning: false); + } + if (string.Equals(slashSystem, "__COMMIT__", StringComparison.Ordinal)) + { + var commitResult = await ExecuteCommitWithApprovalAsync(displayText, _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; + var queuedDraftId = _runningDraftId; + var draftSucceeded = false; + var draftCancelled = false; + string? draftFailure = null; + var lastAutoSaveUtc = DateTime.UtcNow; + + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + _currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab }; + conv = _currentConversation; + } + + var userMsg = new ChatMessage { Role = "user", Content = text }; + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, userMsg, useForTitle: true); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(userMsg); + if (conv.Messages.Count(m => m.Role == "user") == 1) + conv.Title = text.Length > 30 ? text[..30] + "…" : text; + } + } + SaveLastConversations(); + void TryPersistConversation(bool force = false) + { + if (!force && (DateTime.UtcNow - lastAutoSaveUtc).TotalSeconds < 1.2) + return; + + lastAutoSaveUtc = DateTime.UtcNow; + try + { + _storage.Save(conv); + ChatSession?.RememberConversation(originTab, conv.Id); + } + catch (Exception ex) + { + Services.LogService.Debug($"대화 중간 저장 실패: {ex.Message}"); + } + } + TryPersistConversation(force: true); + + UpdateChatTitle(); + InputBox.Text = ""; + UpdateInputBoxHeight(); + EmptyState.Visibility = Visibility.Collapsed; + RenderMessages(preserveViewport: true); + + // 대화 통계 기록 + Services.UsageStatisticsService.RecordChat(runTab); + + ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동 + PlayRainbowGlow(); // 무지개 글로우 애니메이션 + + _isStreaming = true; + _streamRunTab = runTab; + BtnSend.IsEnabled = false; + BtnSend.Visibility = Visibility.Collapsed; + BtnStop.Visibility = Visibility.Visible; + if (runTab == "Cowork" || runTab == "Code") + BtnPause.Visibility = Visibility.Visible; + _streamCts = new CancellationTokenSource(); + + var assistantContent = string.Empty; + _activeStreamText = null; + _cachedStreamContent = ""; + _displayedLength = 0; + _cursorVisible = true; + _aiIconPulseStopped = false; + _streamStartTime = DateTime.UtcNow; + _elapsedTimer.Start(); + SetStatus("응답 생성 중...", spinning: true); + + // ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ── + ModelRouteResult? routeResult = null; + + try + { + List sendMessages; + + // 첨부 파일 컨텍스트 삽입 + string? fileContext = null; + if (_attachedFiles.Count > 0) + { + fileContext = BuildFileContextPrompt(); + if (!string.IsNullOrEmpty(fileContext)) + { + // 전송용 메시지에는 엔진이 적용하고, 원본 대화에는 첨부 기록만 남김 + } + userMsg.AttachedFiles = _attachedFiles.ToList(); + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + } + + // ── 이미지 첨부 ── + List? outboundImages = null; + if (_pendingImages.Count > 0) + { + userMsg.Images = _pendingImages.ToList(); + outboundImages = _pendingImages.ToList(); + _pendingImages.Clear(); + AttachedFilesPanel.Items.Clear(); + if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; + } + + var coworkSystem = string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) + ? BuildCoworkSystemPrompt() + : null; + var codeSystem = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase) + ? BuildCodeSystemPrompt() + : null; + var (resolvedService, _) = _llm.GetCurrentModelInfo(); + var executionMode = _chatEngine.ResolveExecutionMode( + runTab, + _settings.Settings.Llm.Streaming, + resolvedService, + coworkSystem, + codeSystem); + var promptStack = _chatEngine.BuildPromptStack( + conv.SystemCommand, + slashSystem, + executionMode.TaskSystemPrompt); + + sendMessages = _chatEngine + .PrepareTurn(conv, promptStack, fileContext, outboundImages) + .Messages; + + // ── 전송 전 컨텍스트 사전 압축 ── + { + var llm = _settings.Settings.Llm; + var compactResult = await ContextCondenser.CondenseWithStatsAsync( + sendMessages, + _llm, + llm.MaxContextTokens, + llm.EnableProactiveContextCompact, + llm.ContextCompactTriggerPercent, + false, + _streamCts!.Token); + if (compactResult.Changed) + { + RecordCompactionStats(compactResult, wasAutomatic: true); + SetStatus($"컨텍스트를 사전 정리했습니다 · {compactResult.BeforeTokens:N0} → {compactResult.AfterTokens:N0} tokens", spinning: true); + RefreshContextUsageVisual(); + } + } + + // ── 자동 모델 라우팅 ── + if (_settings.Settings.Llm.EnableAutoRouter) + { + routeResult = _router.Route(text); + if (routeResult != null) + { + _llm.PushRouteOverride(routeResult.Service, routeResult.Model); + SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true); + } + } + + if (executionMode.UseAgentLoop) + { + var response = await RunAgentLoopAsync(runTab, originTab, conv, sendMessages, _streamCts!.Token); + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; + } + else + { + var response = await _llm.SendAsync(sendMessages, _streamCts.Token); + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; + } + + draftSucceeded = true; + } + catch (OperationCanceledException) + { + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(취소됨)" : assistantContent; + draftCancelled = true; + draftFailure = "사용자가 작업을 중단했습니다."; + } + catch (Exception ex) + { + var errMsg = $"⚠ 오류: {ex.Message}"; + assistantContent = errMsg; + AddRetryButton(); + draftFailure = ex.Message; + } + finally + { + // 자동 라우팅 오버라이드 해제 + if (routeResult != null) + { + _llm.ClearRouteOverride(); + UpdateModelLabel(); + } + + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 + StopRainbowGlow(); // 레인보우 글로우 종료 + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + _streamRunTab = null; + SetStatusIdle(); + } + + assistantContent = BuildAssistantFallbackContent(conv, runTab, assistantContent); + if (runTab is "Cowork" or "Code") + conv.ShowExecutionHistory = false; + + lock (_convLock) + { + var session = ChatSession; + _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantContent, _storage); + _currentConversation = session?.CurrentConversation ?? conv; + conv = _currentConversation!; + } + + lock (_convLock) + _currentConversation = conv; + RenderMessages(preserveViewport: true); + AutoScrollIfNeeded(); + + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + ChatSession?.RememberConversation(originTab, conv.Id); + SyncTabConversationIdsFromSession(); + RefreshConversationList(); + if (!string.IsNullOrWhiteSpace(queuedDraftId)) + { + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + if (draftSucceeded) + _draftQueueProcessor.Complete(session, originTab, queuedDraftId, _storage, _appState.TaskRuns); + else + _draftQueueProcessor.HandleFailure(session, originTab, queuedDraftId, draftFailure, draftCancelled, 3, _storage, _appState.TaskRuns); + + _currentConversation = session.CurrentConversation ?? _currentConversation; + } + + if (string.Equals(_runningDraftId, queuedDraftId, StringComparison.OrdinalIgnoreCase)) + _runningDraftId = null; + } + } + + RefreshDraftQueueUi(); + if (!draftCancelled && !string.IsNullOrWhiteSpace(queuedDraftId) && string.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + _ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background); + } + + private async Task RunAgentLoopAsync( + string runTab, + string originTab, + ChatConversation conversation, + IReadOnlyList sendMessages, + CancellationToken cancellationToken) + { + OpenWorkflowAnalyzerIfEnabled(); + + _agentCumulativeInputTokens = 0; + _agentCumulativeOutputTokens = 0; + + _agentLoop.EventOccurred += OnAgentEvent; + _agentLoop.UserDecisionCallback = CreatePlanDecisionCallback(); + try + { + _agentLoop.ActiveTab = runTab; + var response = await _agentLoop.RunAsync(sendMessages.ToList(), cancellationToken); + try + { + _storage.Save(conversation); + ChatSession?.RememberConversation(originTab, conversation.Id); + } + catch (Exception ex) + { + Services.LogService.Debug($"에이전트 루프 저장 실패: {ex.Message}"); + } + + if (_settings.Settings.Llm.NotifyOnComplete) + { + var title = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase) + ? "AX Code Agent" + : "AX Cowork Agent"; + var message = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase) + ? "코드 작업이 완료되었습니다." + : "코워크 작업이 완료되었습니다."; + Services.NotificationService.Notify(title, message); + } + + return response; + } + finally + { + _agentLoop.EventOccurred -= OnAgentEvent; + _agentLoop.UserDecisionCallback = null; + } + } + + // ─── 코워크 에이전트 지원 ──────────────────────────────────────────── + + private string BuildCoworkSystemPrompt() + { + var workFolder = GetCurrentWorkFolder(); + var llm = _settings.Settings.Llm; + var sb = new System.Text.StringBuilder(); + sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools."); + sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd})."); + sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환)."); + sb.AppendLine("Always explain your plan step by step BEFORE executing tools. After creating files, summarize what was created."); + sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered."); + sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs."); + sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates."); + sb.AppendLine("IMPORTANT: When asked to create a document with multiple sections (reports, proposals, analyses, etc.), you MUST:"); + sb.AppendLine(" 1. First, plan the document: decide the exact sections (headings), their order, and key points for each section based on the topic."); + sb.AppendLine(" 2. Call document_plan with sections_hint = your planned section titles (comma-separated). Example: sections_hint=\"회사 개요, 사업 현황, 재무 분석, SWOT, 전략 제언, 결론\""); + sb.AppendLine(" This ensures the document structure matches YOUR plan, not a generic template."); + sb.AppendLine(" 3. Then immediately call html_create (or docx_create/file_write) using the scaffold from document_plan."); + sb.AppendLine(" 4. Write actual detailed content for EVERY section — no skipping, no placeholders, no minimal content."); + sb.AppendLine(" 5. Do NOT call html_create directly without document_plan for multi-section documents."); + + // 문서 품질 검증 루프 + sb.AppendLine("\n## Document Quality Review"); + sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:"); + sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete"); + sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting"); + sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered"); + sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify"); + sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made"); + + // 문서 포맷 변환 지원 + sb.AppendLine("\n## Format Conversion"); + sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):"); + sb.AppendLine("1. Use file_read or document_read to read the source file content"); + sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)"); + sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible"); + + // 사용자 지정 출력 포맷 + var fmt = llm.DefaultOutputFormat; + if (!string.IsNullOrEmpty(fmt) && fmt != "auto") + { + var fmtMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["xlsx"] = "Excel (.xlsx) using excel_create", + ["docx"] = "Word (.docx) using docx_create", + ["html"] = "HTML (.html) using html_create", + ["md"] = "Markdown (.md) using markdown_create", + ["csv"] = "CSV (.csv) using csv_create", + }; + if (fmtMap.TryGetValue(fmt, out var fmtDesc)) + sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise."); + } + + // 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내 + if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern") + sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template."); + else + sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard."); + + if (!string.IsNullOrEmpty(workFolder)) + sb.AppendLine($"Current work folder: {workFolder}"); + sb.AppendLine($"File permission mode: {llm.FilePermission}"); + sb.Append(BuildSubAgentDelegationSection(false)); + + // 폴더 데이터 활용 지침 + switch (_folderDataUsage) + { + case "active": + sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available."); + sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " + + "Then EVALUATE whether each document is RELEVANT to the user's current request topic. " + + "Only use document_read on files that are clearly related to the conversation subject. " + + "Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " + + "In your planning step, list which files you plan to read and explain WHY they are relevant."); + break; + case "passive": + sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " + + "Only read folder documents when the user explicitly asks you to reference or use them."); + break; + default: // "none" + sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path."); + break; + } + + // 프리셋 시스템 프롬프트가 있으면 추가 + lock (_convLock) + { + if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand)) + sb.AppendLine("\n" + _currentConversation.SystemCommand); + } + + // 프로젝트 문맥 파일 (AGENTS.md) 주입 + sb.Append(LoadProjectContext(workFolder)); + + // 프로젝트 규칙 (.ax/rules/) 자동 주입 + sb.Append(BuildProjectRulesSection(workFolder)); + + // 에이전트 메모리 주입 + sb.Append(BuildMemorySection(workFolder)); + + // 피드백 학습 컨텍스트 주입 + sb.Append(BuildFeedbackContext()); + + return sb.ToString(); + } + + private string BuildCodeSystemPrompt() + { + var workFolder = GetCurrentWorkFolder(); + var llm = _settings.Settings.Llm; + var code = llm.Code; + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development."); + sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd})."); + sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool."); + sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached."); + sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above."); + + sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)"); + sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files."); + sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count."); + sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + file_read to deeply understand the code you'll modify."); + sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points."); + sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior."); + sb.AppendLine("4. PLAN: Present your analysis + impact assessment. List ALL files that will be modified."); + sb.AppendLine(" - Explain WHY each change is needed and what could break."); + sb.AppendLine(" - If you touch shared services, interfaces, models, controllers, view-models, app startup, or dependency registration, treat it as a high-impact change."); + sb.AppendLine(" - Wait for user approval before proceeding."); + sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files."); + sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code."); + sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits."); + sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline."); + sb.AppendLine(" - If tests fail that passed before, fix immediately."); + sb.AppendLine(" - If build fails, analyze error output and correct."); + sb.AppendLine(" - Re-read every edited file and verify impacted callers/references before finishing."); + sb.AppendLine(" - Do not stop at a passing build if request coverage, edge cases, or changed call sites are still unverified."); + sb.AppendLine(" - For high-impact changes, you must verify both references/callers and build/test evidence before finishing."); + sb.AppendLine("7. GIT: Use git_tool to check status, create diff, and optionally commit."); + sb.AppendLine("8. REPORT: Summarize changes, test results, and any remaining concerns."); + + sb.AppendLine("\n## Development Environment"); + sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands."); + sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed."); + + // 패키지 저장소 정보 + sb.AppendLine("\n## Package Repositories"); + if (!string.IsNullOrEmpty(code.NexusBaseUrl)) + sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}"); + sb.AppendLine($"NuGet (.NET): {code.NugetSource}"); + sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}"); + sb.AppendLine($"Maven (Java): {code.MavenSource}"); + sb.AppendLine($"npm (JavaScript): {code.NpmSource}"); + sb.AppendLine("When adding dependencies, use these repository URLs."); + + // IDE 정보 + if (!string.IsNullOrEmpty(code.PreferredIdePath)) + sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}"); + + // 사용자 선택 개발 언어 + if (_selectedLanguage != "auto") + { + var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage }; + sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation."); + } + + // 언어별 가이드라인 + sb.AppendLine("\n## Language Guidelines"); + sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions."); + sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred."); + sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide."); + sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines."); + sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API."); + + // 코드 품질 + 안전 수칙 + sb.AppendLine("\n## Code Quality & Safety"); + sb.AppendLine("- NEVER delete or overwrite files without user confirmation."); + sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents."); + sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff)."); + sb.AppendLine("- When porting/referencing external code, do not copy verbatim. Rename and re-structure to match AX Copilot conventions."); + sb.AppendLine("- Use grep to find ALL references before renaming/removing anything."); + sb.AppendLine("- After editing, re-open the changed files and nearby callers to verify the final state, not just the patch intent."); + sb.AppendLine("- Treat verification as incomplete unless you can cite build/test or direct file-read evidence."); + sb.AppendLine("- If unsure about a change's impact, ask the user first."); + sb.AppendLine("- For large refactors, do them incrementally with build verification between steps."); + sb.AppendLine("- Use git_tool action='diff' to review your changes before committing."); + + sb.AppendLine("\n## Lint & Format"); + sb.AppendLine("After code changes, check for available linters:"); + sb.AppendLine("- Python: ruff, black, flake8, pylint"); + sb.AppendLine("- JavaScript: eslint, prettier"); + sb.AppendLine("- C#: dotnet format"); + sb.AppendLine("- C++: clang-format"); + sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect."); + + if (!string.IsNullOrEmpty(workFolder)) + sb.AppendLine($"\nCurrent work folder: {workFolder}"); + sb.AppendLine($"File permission mode: {llm.FilePermission}"); + sb.Append(BuildSubAgentDelegationSection(true)); + + // 폴더 데이터 활용 + sb.AppendLine("\nFolder Data Usage = ACTIVE. Use folder_map and file_read to understand the codebase."); + sb.AppendLine("Analyze project structure before making changes. Read relevant files to understand context."); + + // 프리셋 시스템 프롬프트 + lock (_convLock) + { + if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd) + sb.AppendLine("\n" + sysCmd); + } + + // 프로젝트 문맥 파일 (AGENTS.md) 주입 + sb.Append(LoadProjectContext(workFolder)); + + // 프로젝트 규칙 (.ax/rules/) 자동 주입 + sb.Append(BuildProjectRulesSection(workFolder)); + + // 에이전트 메모리 주입 + sb.Append(BuildMemorySection(workFolder)); + + // 피드백 학습 컨텍스트 주입 + sb.Append(BuildFeedbackContext()); + + return sb.ToString(); + } + + private static string BuildSubAgentDelegationSection(bool codeMode) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n## Sub-Agent Delegation"); + sb.AppendLine("Use spawn_agent only for bounded side investigations that can run in parallel with your main work."); + sb.AppendLine("Good delegation targets: impact analysis, reference/caller search, test-file discovery, diff review, and bug root-cause investigation."); + sb.AppendLine("Do not delegate the final editing decision, the final report, or blocking work that you must inspect immediately yourself."); + sb.AppendLine("When spawning a sub-agent, give a concrete task with the exact question, likely file/module scope, and the output shape you want."); + sb.AppendLine("Expected sub-agent result shape: conclusion, files checked, key evidence, recommended next action, risks/unknowns."); + sb.AppendLine("Use wait_agents only when you are ready to integrate the result. Keep doing useful local work while the sub-agent runs."); + if (codeMode) + { + sb.AppendLine("For code tasks, prefer delegating read-only investigations such as caller mapping, related test discovery, or build/test failure triage."); + sb.AppendLine("For high-impact code changes, delegate at least one focused investigation for callers/references or related tests before finalizing."); + } + else + { + sb.AppendLine("For cowork tasks, prefer delegating fact gathering, source cross-checking, or evidence collection while the main agent continues planning."); + } + + return sb.ToString(); + } + + /// 프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다. + private string BuildProjectRulesSection(string? workFolder) + { + if (string.IsNullOrEmpty(workFolder)) return ""; + if (!_settings.Settings.Llm.EnableProjectRules) return ""; + + try + { + var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder); + if (rules.Count == 0) return ""; + + // 컨텍스트별 필터링: Cowork=document, Code=always (기본) + var when = _activeTab == "Code" ? "always" : "always"; + var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when); + return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered); + } + catch + { + return ""; + } + } + + /// 에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다. + private string BuildMemorySection(string? workFolder) + { + if (!_settings.Settings.Llm.EnableAgentMemory) return ""; + + var app = System.Windows.Application.Current as App; + var memService = app?.MemoryService; + if (memService == null || memService.Count == 0) return ""; + + // 메모리를 로드 (작업 폴더 변경 시 재로드) + memService.Load(workFolder ?? ""); + + var all = memService.All; + if (all.Count == 0) return ""; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n## 프로젝트 메모리 (이전 대화에서 학습한 내용)"); + sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요."); + sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요."); + sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n"); + + foreach (var group in all.GroupBy(e => e.Type)) + { + var label = group.Key switch + { + "rule" => "프로젝트 규칙", + "preference" => "사용자 선호", + "fact" => "프로젝트 사실", + "correction" => "이전 교정", + _ => group.Key, + }; + sb.AppendLine($"[{label}]"); + foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15)) + sb.AppendLine($"- {e.Content}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + /// 워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다. + private void OpenWorkflowAnalyzerIfEnabled() + { + var llm = _settings.Settings.Llm; + if (!llm.DevMode || !llm.WorkflowVisualizer) return; + + if (_analyzerWindow == null) + { + // 새로 생성 + _analyzerWindow = new WorkflowAnalyzerWindow(); + _analyzerWindow.Closed += (_, _) => _analyzerWindow = null; + // 테마 리소스 전달 + foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries) + _analyzerWindow.Resources.MergedDictionaries.Add(dict); + _analyzerWindow.Show(); + } + else if (!_analyzerWindow.IsVisible) + { + // Hide()로 숨겨진 창 → 기존 내용 유지한 채 다시 표시 + _analyzerWindow.Show(); + _analyzerWindow.Activate(); + } + else + { + // 이미 보이는 상태 → 새 에이전트 실행을 위해 초기화 후 활성화 + _analyzerWindow.Reset(); + _analyzerWindow.Activate(); + } + + // 타임라인 탭으로 전환 (새 실행 시작) + _analyzerWindow.SwitchToTimelineTab(); + + // 이벤트 구독 (중복 방지) + _agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent; + _agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent; + } + + /// 워크플로우 분석기 버튼의 표시 상태를 갱신합니다. + private void UpdateAnalyzerButtonVisibility() + { + var llm = _settings.Settings.Llm; + BtnShowAnalyzer.Visibility = (llm.DevMode && llm.WorkflowVisualizer) + ? Visibility.Visible : Visibility.Collapsed; + } + + /// 워크플로우 분석기 창을 수동으로 열거나 포커스합니다 (하단 바 버튼). + private void BtnShowAnalyzer_Click(object sender, MouseButtonEventArgs e) + { + if (_analyzerWindow == null) + { + _analyzerWindow = new WorkflowAnalyzerWindow(); + _analyzerWindow.Closed += (_, _) => _analyzerWindow = null; + foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries) + _analyzerWindow.Resources.MergedDictionaries.Add(dict); + // 에이전트 이벤트 구독 + _agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent; + _agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent; + _analyzerWindow.Show(); + } + else if (!_analyzerWindow.IsVisible) + { + _analyzerWindow.Show(); + _analyzerWindow.Activate(); + } + else + { + _analyzerWindow.Activate(); + } + } + + /// 에이전트 루프 동안 누적 토큰 (하단 바 표시용) + private int _agentCumulativeInputTokens; + private int _agentCumulativeOutputTokens; + + private static readonly HashSet WriteToolNames = new(StringComparer.OrdinalIgnoreCase) + { + "file_write", "file_edit", "html_create", "xlsx_create", + "docx_create", "csv_create", "md_create", "script_create", + "diff_preview", "open_external", + }; + + private void OnAgentEvent(AgentEvent evt) + { + var eventTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; + + // 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다. + // 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다. + var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false; + AppendConversationExecutionEvent(evt, eventTab); + if (shouldShowExecutionHistory + && string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + RenderMessages(preserveViewport: true); + AutoScrollIfNeeded(); + + // 하단 상태바 업데이트 + UpdateStatusBar(evt); + _appState.ApplyAgentEvent(evt); + if (evt.Type == AgentEventType.Complete) + AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab); + else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName)) + AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab); + + // 하단 바 토큰 누적 업데이트 (에이전트 루프 전체 합계) + if (evt.InputTokens > 0 || evt.OutputTokens > 0) + { + _agentCumulativeInputTokens += evt.InputTokens; + _agentCumulativeOutputTokens += evt.OutputTokens; + UpdateStatusTokens(_agentCumulativeInputTokens, _agentCumulativeOutputTokens); + } + + // 스티키 진행률 바 업데이트 + UpdateAgentProgressBar(evt); + + // 계획 뷰어 단계 갱신 + if (evt.StepCurrent > 0 && evt.StepTotal > 0) + UpdatePlanViewerStep(evt); + if (evt.Type == AgentEventType.Complete) + CompletePlanViewer(); + + // 파일 탐색기 자동 새로고침 + if (evt.Success && !string.IsNullOrEmpty(evt.FilePath)) + RefreshFileTreeIfVisible(); + + // suggest_actions 도구 결과 → 후속 작업 칩 표시 + if (evt.Type == AgentEventType.ToolResult && evt.ToolName == "suggest_actions" && evt.Success) + RenderSuggestActionChips(evt.Summary); + + // 파일 생성/수정 결과가 있으면 미리보기 자동 표시 또는 갱신 + if (evt.Success && !string.IsNullOrEmpty(evt.FilePath) && + (evt.Type == AgentEventType.ToolResult || evt.Type == AgentEventType.Complete) && + WriteToolNames.Contains(evt.ToolName)) + { + var autoPreview = _settings.Settings.Llm.AutoPreview; + if (autoPreview == "auto") + { + // 별도 창 미리보기: 이미 열린 파일이면 새로고침, 아니면 새 탭 추가 + if (PreviewWindow.IsOpen) + PreviewWindow.RefreshIfOpen(evt.FilePath); + else + TryShowPreview(evt.FilePath); + + // 새 파일이면 항상 표시 + if (!PreviewWindow.IsOpen) + TryShowPreview(evt.FilePath); + } + } + + UpdateTaskSummaryIndicators(); + } + + private void OnSubAgentStatusChanged(SubAgentStatusEvent evt) + { + Dispatcher.Invoke(() => + { + _appState.ApplySubAgentStatus(evt); + UpdateTaskSummaryIndicators(); + }); + } + + private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab) + { + lock (_convLock) + { + var session = _appState.ChatSession; + if (session == null) + return; + + var normalizedTarget = NormalizeTabName(targetTab); + var normalizedActive = NormalizeTabName(_activeTab); + if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + _currentConversation = session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage); + return; + } + + var activeSnapshot = _currentConversation; + var previousSessionConversation = session.CurrentConversation; + session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage); + + if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + session.CurrentConversation = activeSnapshot; + _currentConversation = activeSnapshot; + } + else if (previousSessionConversation != null + && string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + session.CurrentConversation = previousSessionConversation; + _currentConversation = previousSessionConversation; + } + else + { + var activeId = session.GetConversationId(normalizedActive); + var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId); + if (activeConv != null) + { + session.CurrentConversation = activeConv; + _currentConversation = activeConv; + } + else + { + var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings); + session.CurrentConversation = fallback; + _currentConversation = fallback; + } + } + } + } + + private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab) + { + lock (_convLock) + { + var session = _appState.ChatSession; + if (session == null) + return; + + var normalizedTarget = NormalizeTabName(targetTab); + var normalizedActive = NormalizeTabName(_activeTab); + if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + _currentConversation = session.AppendExecutionEvent(normalizedTarget, evt, _storage); + return; + } + + var activeSnapshot = _currentConversation; + var previousSessionConversation = session.CurrentConversation; + session.AppendExecutionEvent(normalizedTarget, evt, _storage); + + if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + session.CurrentConversation = activeSnapshot; + _currentConversation = activeSnapshot; + } + else if (previousSessionConversation != null + && string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) + { + session.CurrentConversation = previousSessionConversation; + _currentConversation = previousSessionConversation; + } + else + { + var activeId = session.GetConversationId(normalizedActive); + var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId); + if (activeConv != null) + { + session.CurrentConversation = activeConv; + _currentConversation = activeConv; + } + else + { + var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings); + session.CurrentConversation = fallback; + _currentConversation = fallback; + } + } + } + } + + private void SyncAppStateWithCurrentConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + + _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); + _appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory); + _appState.RestoreRecentTasks(conv?.ExecutionEvents); + ApplyConversationListPreferences(conv); + UpdateTaskSummaryIndicators(); + } + + private void ApplyConversationListPreferences(ChatConversation? conv) + { + _failedOnlyFilter = false; + _runningOnlyFilter = false; + _sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase); + UpdateConversationFailureFilterUi(); + UpdateConversationRunningFilterUi(); + UpdateConversationSortUi(); + } + + private void PersistConversationListPreferences() + { + lock (_convLock) + { + var session = _appState.ChatSession; + if (session == null) + return; + + session.SaveConversationListPreferences(_activeTab, _failedOnlyFilter, _runningOnlyFilter, _sortConversationsByRecent, _storage); + _currentConversation = session.CurrentConversation; + } + } + + private void UpdateTaskSummaryIndicators() + { + var status = _appState.GetOperationalStatus(_activeTab); + + if (RuntimeActivityBadge != null) + RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge + ? Visibility.Visible + : Visibility.Collapsed; + + if (RuntimeActivityLabel != null) + RuntimeActivityLabel.Text = status.RuntimeLabel; + + if (LastCompletedLabel != null) + { + LastCompletedLabel.Text = status.LastCompletedText; + LastCompletedLabel.Visibility = status.ShowLastCompleted ? Visibility.Visible : Visibility.Collapsed; + } + + if (ConversationStatusStrip != null && ConversationStatusStripLabel != null) + { + if (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#FFF7ED"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#FDBA74"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#C2410C"); + ConversationStatusStripLabel.Text = status.StripText; + } + else if (string.Equals(status.StripKind, "running", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#DBEAFE"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#93C5FD"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8"); + ConversationStatusStripLabel.Text = status.StripText; + } + else if (string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase) + || string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#FEF2F2"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#FECACA"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#991B1B"); + ConversationStatusStripLabel.Text = status.StripText; + } + else if (string.Equals(status.StripKind, "queue", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#F5F3FF"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#C4B5FD"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#6D28D9"); + ConversationStatusStripLabel.Text = status.StripText; + } + else if (string.Equals(status.StripKind, "queue_blocked", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#FFFBEB"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#FCD34D"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#B45309"); + ConversationStatusStripLabel.Text = status.StripText; + } + else if (string.Equals(status.StripKind, "background", StringComparison.OrdinalIgnoreCase)) + { + ConversationStatusStrip.Visibility = Visibility.Visible; + ConversationStatusStrip.Background = BrushFromHex("#EFF6FF"); + ConversationStatusStrip.BorderBrush = BrushFromHex("#BFDBFE"); + ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8"); + ConversationStatusStripLabel.Text = status.StripText; + } + else + { + ConversationStatusStrip.Visibility = Visibility.Collapsed; + ConversationStatusStripLabel.Text = ""; + } + } + + UpdateConversationQuickStripUi(); + } + + private void UpdateConversationQuickStripUi() + { + if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null + || BtnQuickRunningFilter == null || BtnQuickHotSort == null) + return; + + var hasQuickSignal = _runningConversationCount > 0 + || _spotlightConversationCount > 0; + + ConversationQuickStrip.Visibility = hasQuickSignal + ? Visibility.Visible + : Visibility.Collapsed; + + QuickRunningLabel.Text = _runningConversationCount > 0 ? $"진행 {_runningConversationCount}" : "진행"; + QuickHotLabel.Text = _spotlightConversationCount > 0 ? $"활동 {_spotlightConversationCount}" : "활동"; + + BtnQuickRunningFilter.Background = _runningOnlyFilter ? BrushFromHex("#DBEAFE") : BrushFromHex("#F8FAFC"); + BtnQuickRunningFilter.BorderBrush = _runningOnlyFilter ? BrushFromHex("#93C5FD") : BrushFromHex("#E5E7EB"); + BtnQuickRunningFilter.BorderThickness = new Thickness(1); + QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); + + BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC"); + BtnQuickHotSort.BorderBrush = !_sortConversationsByRecent ? BrushFromHex("#C4B5FD") : BrushFromHex("#E5E7EB"); + BtnQuickHotSort.BorderThickness = new Thickness(1); + QuickHotLabel.Foreground = !_sortConversationsByRecent ? BrushFromHex("#6D28D9") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); + } + + private static string GetRunStatusLabel(string? status) + => status switch + { + "completed" => "완료", + "failed" => "실패", + "paused" => "일시중지", + _ => "진행 중", + }; + + private static string GetTaskStatusLabel(string? status) + => status switch + { + "completed" => "완료", + "failed" => "실패", + "blocked" => "재시도 대기", + "waiting" => "승인 대기", + "cancelled" => "중단", + _ => "진행 중", + }; + + private IEnumerable FilterTaskSummaryItems(IEnumerable tasks) + => _taskSummaryTaskFilter switch + { + "permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)), + "queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)), + "hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)), + "subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)), + "tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)), + _ => tasks, + }; + + private Border CreateTaskSummaryFilterChip(string key, string label) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase); + var chip = new Border + { + Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"), + BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 5, 5), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = label, + FontSize = 10.5, + Foreground = active ? BrushFromHex("#4338CA") : secondaryText, + } + }; + chip.MouseLeftButtonUp += (_, _) => + { + _taskSummaryTaskFilter = key; + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + }; + return chip; + } + + private static Brush GetRunStatusBrush(string? status) + => status switch + { + "completed" => BrushFromHex("#166534"), + "failed" => BrushFromHex("#B91C1C"), + "paused" => BrushFromHex("#B45309"), + _ => BrushFromHex("#1D4ED8"), + }; + + private static string ShortRunId(string? runId) + { + if (string.IsNullOrWhiteSpace(runId)) + return "main"; + + return runId.Length <= 8 ? runId : runId[..8]; + } + + // ─── Task Decomposition UI ──────────────────────────────────────────── + + private Border? _planningCard; + private StackPanel? _planStepsPanel; + private ProgressBar? _planProgressBar; + private TextBlock? _planProgressText; + + /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). + private void AddPlanningCard(AgentEvent evt) + { + var steps = evt.Steps!; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#FFFFFF"); + var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); + var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC"); + + var card = new Border + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(18), + Padding = new Thickness(16, 14, 16, 14), + Margin = new Thickness(20, 6, 170, 10), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = GetMessageMaxWidth(), + }; + + var sp = new StackPanel(); + + // 헤더 + var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) }; + header.Children.Add(new TextBlock + { + Text = "\uE9D5", // plan icon + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + }); + header.Children.Add(new TextBlock + { + Text = $"{steps.Count}개의 작업 완료 중 0", + FontSize = 11.5, FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + sp.Children.Add(header); + + // 진행률 바 + var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) }; + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + _planProgressBar = new ProgressBar + { + Minimum = 0, + Maximum = steps.Count, + Value = 0, + Height = 4, + Foreground = accentBrush, + Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"), + VerticalAlignment = VerticalAlignment.Center, + }; + // Remove the default border on ProgressBar + _planProgressBar.BorderThickness = new Thickness(0); + Grid.SetColumn(_planProgressBar, 0); + progressGrid.Children.Add(_planProgressBar); + + _planProgressText = new TextBlock + { + Text = "0%", + FontSize = 10.5, FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(_planProgressText, 1); + progressGrid.Children.Add(_planProgressText); + sp.Children.Add(progressGrid); + + // 단계 목록 + _planStepsPanel = new StackPanel(); + for (int i = 0; i < steps.Count; i++) + { + var stepRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 1, 0, 1), + Tag = i, // 인덱스 저장 + }; + + stepRow.Children.Add(new TextBlock + { + Text = "○", // 빈 원 (미완료) + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + Tag = "status", + }); + stepRow.Children.Add(new TextBlock + { + Text = $"{i + 1}. {steps[i]}", + FontSize = 11.5, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + MaxWidth = Math.Max(320, GetMessageMaxWidth() - 60), + VerticalAlignment = VerticalAlignment.Center, + }); + + _planStepsPanel.Children.Add(stepRow); + } + sp.Children.Add(_planStepsPanel); + + card.Child = sp; + _planningCard = card; + + // 페이드인 + card.Opacity = 0; + card.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + MessagePanel.Children.Add(card); + } + + /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. + 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 = expressionLevel == "simple" + ? new Thickness(40, 2, 120, 6) + : new Thickness(40, 2, 80, 6), + HorizontalAlignment = HorizontalAlignment.Left, + 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) }; + + // 승인 버튼 (강조) + var approveBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(16), + Padding = new Thickness(16, 7, 16, 7), + Margin = new Thickness(0, 0, 8, 0), + Cursor = Cursors.Hand, + }; + var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; + approveSp.Children.Add(new TextBlock + { + 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 + }); + approveBtn.Child = approveSp; + ApplyMenuItemHover(approveBtn); + approveBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); + tcs.TrySetResult(null); // null = 승인 + }; + btnRow.Children.Add(approveBtn); + + // 수정 요청 버튼 + var editBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 0), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + }; + var editSp = new StackPanel { Orientation = Orientation.Horizontal }; + editSp.Children.Add(new TextBlock + { + 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 = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); + editBtn.Child = editSp; + ApplyMenuItemHover(editBtn); + + // 수정 요청용 텍스트 입력 패널 (초기 숨김) + var editInputPanel = new Border + { + Visibility = Visibility.Collapsed, + 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), + }; + var editInputStack = new StackPanel(); + editInputStack.Children.Add(new TextBlock + { + Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:", + FontSize = 11.5, Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + var editTextBox = new TextBox + { + MinHeight = 36, + MaxHeight = 100, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 12.5, + 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), + }; + editInputStack.Children.Add(editTextBox); + + var submitEditBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 6, 0, 0), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + }; + submitEditBtn.Child = new TextBlock + { + Text = "피드백 전송", + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + }; + ApplyHoverScaleAnimation(submitEditBtn, 1.05); + submitEditBtn.MouseLeftButtonUp += (_, _) => + { + var feedback = editTextBox.Text.Trim(); + if (string.IsNullOrEmpty(feedback)) return; + CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush); + tcs.TrySetResult(feedback); + }; + editInputStack.Children.Add(submitEditBtn); + editInputPanel.Child = editInputStack; + + editBtn.MouseLeftButtonUp += (_, _) => + { + editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible + ? Visibility.Collapsed : Visibility.Visible; + if (editInputPanel.Visibility == Visibility.Visible) + editTextBox.Focus(); + }; + btnRow.Children.Add(editBtn); + + // 취소 버튼 + var cancelBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), + BorderThickness = new Thickness(1), + }; + var cancelSp = new StackPanel { Orientation = Orientation.Horizontal }; + cancelSp.Children.Add(new TextBlock + { + Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + cancelSp.Children.Add(new TextBlock + { + Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + }); + cancelBtn.Child = cancelSp; + ApplyMenuItemHover(cancelBtn); + cancelBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✕ 취소됨", + new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))); + tcs.TrySetResult("취소"); + }; + btnRow.Children.Add(cancelBtn); + + outerStack.Children.Add(btnRow); + outerStack.Children.Add(editInputPanel); + container.Child = outerStack; + + // 슬라이드 + 페이드 등장 애니메이션 + ApplyMessageEntryAnimation(container); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 + + // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 + var capturedOuterStack = outerStack; + var capturedAccent = accentBrush; + _ = tcs.Task.ContinueWith(t => + { + Dispatcher.BeginInvoke(() => + { + // 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우) + if (capturedOuterStack.Children.Count <= 1) return; + + var label = t.Result == null ? "✓ 승인됨" + : t.Result == "취소" ? "✕ 취소됨" + : "✎ 수정 요청됨"; + var fg = t.Result == "취소" + ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)) + : capturedAccent; + CollapseDecisionButtons(capturedOuterStack, label, fg); + }); + }, TaskScheduler.Default); + } + + /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. + private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) + { + outerStack.Children.Clear(); + var resultLabel = new TextBlock + { + Text = resultText, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = fg, + Opacity = 0.8, + Margin = new Thickness(0, 2, 0, 2), + }; + outerStack.Children.Add(resultLabel); + } + + // ════════════════════════════════════════════════════════════ + // 실행 계획 뷰어 (PlanViewerWindow) 연동 + // ════════════════════════════════════════════════════════════ + + /// PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다. + private Func, Task> CreatePlanDecisionCallback() + { + return async (planSummary, options) => + { + var tcs = new TaskCompletionSource(); + var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary); + + await Dispatcher.InvokeAsync(() => + { + // PlanViewerWindow 생성 또는 재사용 + if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) + { + _planViewerWindow = new PlanViewerWindow(); + _planViewerWindow.Closing += (_, e) => + { + e.Cancel = true; + _planViewerWindow.Hide(); + }; + } + + // 계획 표시 + 승인 대기 + _planViewerWindow.ShowPlanAsync(planSummary, steps, tcs); + + // 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시 + AddDecisionButtons(tcs, options); + + // 하단 바 계획 버튼 표시 + ShowPlanButton(true); + }); + + // 5분 타임아웃 + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); + if (completed != tcs.Task) + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + return "취소"; + } + + var result = await tcs.Task; + var agentDecision = result; + if (result == null) + { + 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 = 승인 + { + await Dispatcher.InvokeAsync(() => + { + _planViewerWindow?.SwitchToExecutionMode(); + _planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기 + }); + } + else + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + } + + return agentDecision; + }; + } + + /// 하단 바에 계획 보기 버튼을 표시/숨김합니다. + private void ShowPlanButton(bool show) + { + if (!show) + { + // 계획 버튼 제거 + for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) + { + if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") + { + // 앞의 구분선도 제거 + if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep") + MoodIconPanel.Children.RemoveAt(i - 1); + if (i < MoodIconPanel.Children.Count) + MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1)); + break; + } + } + return; + } + + // 이미 있으면 무시 + foreach (var child in MoodIconPanel.Children) + { + if (child is Border b && b.Tag?.ToString() == "PlanBtn") return; + } + + // 구분선 + var separator = new Border + { + Width = 1, Height = 18, + Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + Tag = "PlanSep", + }; + MoodIconPanel.Children.Add(separator); + + // 계획 버튼 + var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981"); + planBtn.Tag = "PlanBtn"; + planBtn.MouseLeftButtonUp += (_, e) => + { + e.Handled = true; + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + { + _planViewerWindow.Show(); + _planViewerWindow.Activate(); + } + }; + MoodIconPanel.Children.Add(planBtn); + } + + /// 계획 뷰어에서 현재 실행 단계를 갱신합니다. + private void UpdatePlanViewerStep(AgentEvent evt) + { + if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return; + if (evt.StepCurrent > 0) + _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based + } + + /// 계획 실행 완료를 뷰어에 알립니다. + private void CompletePlanViewer() + { + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + _planViewerWindow.MarkComplete(); + ShowPlanButton(false); + } + + private static bool IsWindowAlive(Window? w) + { + if (w == null) return false; + try { var _ = w.IsVisible; return true; } + catch { return false; } + } + + // ════════════════════════════════════════════════════════════ + // 후속 작업 제안 칩 (suggest_actions) + // ════════════════════════════════════════════════════════════ + + /// suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다. + private void RenderSuggestActionChips(string jsonSummary) + { + // JSON에서 액션 목록 파싱 시도 + List<(string label, string command)> actions = new(); + try + { + // summary 형식: "label: command" 줄바꿈 구분 또는 JSON + if (jsonSummary.Contains("\"label\"")) + { + using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in doc.RootElement.EnumerateArray()) + { + var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : ""; + var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label; + if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd)); + } + } + } + else + { + // 줄바꿈 형식: "1. label → command" + foreach (var line in jsonSummary.Split('\n')) + { + var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' '); + if (string.IsNullOrEmpty(trimmed)) continue; + var parts = trimmed.Split('→', ':', '—'); + if (parts.Length >= 2) + actions.Add((parts[0].Trim(), parts[1].Trim())); + else if (!string.IsNullOrEmpty(trimmed)) + actions.Add((trimmed, trimmed)); + } + } + } + catch { return; } + + if (actions.Count == 0) return; + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + var container = new Border + { + Margin = new Thickness(40, 4, 40, 8), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + var headerStack = new StackPanel { Margin = new Thickness(0, 0, 0, 6) }; + headerStack.Children.Add(new TextBlock + { + Text = "💡 다음 작업 제안:", + FontSize = 12, + Foreground = secondaryText, + }); + + var chipPanel = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) }; + + foreach (var (label, command) in actions.Take(5)) + { + var capturedCmd = command; + var chip = new Border + { + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 6), + Cursor = Cursors.Hand, + Background = new SolidColorBrush(Color.FromArgb(0x15, + ((SolidColorBrush)accentBrush).Color.R, + ((SolidColorBrush)accentBrush).Color.G, + ((SolidColorBrush)accentBrush).Color.B)), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, + ((SolidColorBrush)accentBrush).Color.R, + ((SolidColorBrush)accentBrush).Color.G, + ((SolidColorBrush)accentBrush).Color.B)), + BorderThickness = new Thickness(1), + }; + chip.Child = new TextBlock + { + Text = label, + FontSize = 12.5, + Foreground = accentBrush, + FontWeight = FontWeights.SemiBold, + }; + chip.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8; + chip.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + chip.MouseLeftButtonUp += (_, _) => + { + // 칩 패널 제거 후 해당 명령 실행 + MessagePanel.Children.Remove(container); + if (capturedCmd.StartsWith("/")) + { + InputBox.Text = capturedCmd + " "; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + } + else + { + InputBox.Text = capturedCmd; + _ = SendMessageAsync(); + } + }; + chipPanel.Children.Add(chip); + } + + var outerStack = new StackPanel(); + outerStack.Children.Add(headerStack); + outerStack.Children.Add(chipPanel); + container.Child = outerStack; + + ApplyMessageEntryAnimation(container); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); + } + + // ════════════════════════════════════════════════════════════ + // 피드백 학습 반영 (J) + // ════════════════════════════════════════════════════════════ + + /// 최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다. + private string BuildFeedbackContext() + { + try + { + var recentConversations = _storage.LoadAllMeta() + .OrderByDescending(m => m.UpdatedAt) + .Take(20) + .ToList(); + + var likedPatterns = new List(); + var dislikedPatterns = new List(); + + foreach (var meta in recentConversations) + { + var conv = _storage.Load(meta.Id); + if (conv == null) continue; + + foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) + { + // 첫 50자로 패턴 파악 + var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? ""; + if (msg.Feedback == "like") + likedPatterns.Add(preview); + else if (msg.Feedback == "dislike") + dislikedPatterns.Add(preview); + } + } + + if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0) + return ""; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n[사용자 선호도 참고]"); + if (likedPatterns.Count > 0) + { + sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):"); + foreach (var p in likedPatterns.Take(5)) + sb.AppendLine($" - \"{p}...\""); + } + if (dislikedPatterns.Count > 0) + { + sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):"); + foreach (var p in dislikedPatterns.Take(5)) + sb.AppendLine($" - \"{p}...\""); + } + sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요."); + return sb.ToString(); + } + catch { return ""; } + } + + /// 진행률 바와 단계 상태를 업데이트합니다. + private void UpdateProgressBar(AgentEvent evt) + { + if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null) + return; + + var stepIdx = evt.StepCurrent - 1; // 0-based + var total = evt.StepTotal; + + // 진행률 바 업데이트 + _planProgressBar.Value = evt.StepCurrent; + var pct = (int)((double)evt.StepCurrent / total * 100); + _planProgressText.Text = $"{pct}%"; + + // 이전 단계 완료 표시 + 현재 단계 강조 + for (int i = 0; i < _planStepsPanel.Children.Count; i++) + { + if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2) + { + var statusTb = row.Children[0] as TextBlock; + var textTb = row.Children[1] as TextBlock; + if (statusTb == null || textTb == null) continue; + + if (i < stepIdx) + { + // 완료 + statusTb.Text = "●"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")); + } + else if (i == stepIdx) + { + // 현재 진행 중 + statusTb.Text = "◉"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B")); + textTb.FontWeight = FontWeights.SemiBold; + } + else + { + // 대기 + statusTb.Text = "○"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")); + textTb.FontWeight = FontWeights.Normal; + } + } + } + } + + /// Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다. + private static UIElement BuildDiffView(string text) + { + var panel = new StackPanel + { + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")), + MaxWidth = 520, + }; + + var diffStarted = false; + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + + // diff 헤더 전의 일반 텍스트 + if (!diffStarted && !line.StartsWith("--- ")) + { + panel.Children.Add(new TextBlock + { + Text = line, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), + FontFamily = new FontFamily("Consolas"), + Margin = new Thickness(0, 0, 0, 1), + }); + continue; + } + diffStarted = true; + + string bgHex, fgHex; + if (line.StartsWith("---") || line.StartsWith("+++")) + { + bgHex = "#F3F4F6"; fgHex = "#374151"; + } + else if (line.StartsWith("@@")) + { + bgHex = "#EFF6FF"; fgHex = "#3B82F6"; + } + else if (line.StartsWith("+")) + { + bgHex = "#ECFDF5"; fgHex = "#059669"; + } + else if (line.StartsWith("-")) + { + bgHex = "#FEF2F2"; fgHex = "#DC2626"; + } + else + { + bgHex = "Transparent"; fgHex = "#6B7280"; + } + + var tb = new TextBlock + { + Text = line, + FontSize = 10.5, + FontFamily = new FontFamily("Consolas"), + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)), + Padding = new Thickness(4, 1, 4, 1), + }; + if (bgHex != "Transparent") + tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex)); + + panel.Children.Add(tb); + } + + return panel; + } + + private sealed class ReviewSignalSummary + { + public int P0 { get; init; } + public int P1 { get; init; } + public int P2 { get; init; } + public int P3 { get; init; } + public bool HasFixed { get; init; } + public bool HasUnfixed { get; init; } + public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed; + } + + private static ReviewSignalSummary ExtractReviewSignals(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new ReviewSignalSummary(); + + var source = text!; + var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈"); + var hasFixed = ContainsWholeWord(source, "fixed") + || ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료"); + + return new ReviewSignalSummary + { + P0 = CountToken(source, "P0"), + P1 = CountToken(source, "P1"), + P2 = CountToken(source, "P2"), + P3 = CountToken(source, "P3"), + HasUnfixed = hasUnfixed, + HasFixed = hasFixed, + }; + } + + private static int CountToken(string source, string token) + { + if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token)) + return 0; + + var count = 0; + var index = 0; + while (index < source.Length) + { + var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase); + if (hit < 0) + break; + + var before = hit == 0 ? ' ' : source[hit - 1]; + var afterIndex = hit + token.Length; + var after = afterIndex >= source.Length ? ' ' : source[afterIndex]; + if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after)) + count++; + + index = hit + token.Length; + } + + return count; + } + + private static bool ContainsAny(string source, params string[] needles) + { + foreach (var needle in needles) + { + if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + return false; + } + + private static bool ContainsWholeWord(string source, string token) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token)) + return false; + + var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b"; + return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary) + { + if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(toolName) && + (toolName.Contains("review", StringComparison.OrdinalIgnoreCase) || + toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase))) + return true; + + if (!string.IsNullOrWhiteSpace(title) && + title.Contains("review", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(summary) && + (summary.Contains("P0", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P1", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P2", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P3", StringComparison.OrdinalIgnoreCase))) + return true; + + return false; + } + + private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex) + { + return new Border + { + Background = BrushFromHex(bgHex), + BorderBrush = BrushFromHex(borderHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Margin = new Thickness(0, 0, 6, 0), + Padding = new Thickness(8, 2, 8, 2), + Child = new TextBlock + { + Text = text, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(fgHex), + } + }; + } + + private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary) + { + if (!IsReviewContext(kind, toolName, title, summary)) + return null; + + var signals = ExtractReviewSignals(summary); + if (!signals.HasAny) + return null; + + var row = new WrapPanel + { + Margin = new Thickness(0, 6, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + if (signals.P0 > 0) + row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5")); + if (signals.P1 > 0) + row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74")); + if (signals.P2 > 0) + row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A")); + if (signals.P3 > 0) + row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD")); + + if (signals.HasFixed) + row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC")); + if (signals.HasUnfixed) + row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5")); + + return row.Children.Count == 0 ? null : row; + } + + private void AddAgentEventBanner(AgentEvent evt) + { + var logLevel = _settings.Settings.Llm.AgentLogLevel; + + // Planning 이벤트는 단계 목록 카드로 별도 렌더링 + if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 }) + { + AddPlanningCard(evt); + return; + } + + // StepStart 이벤트는 진행률 바 업데이트 + if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0) + { + UpdateProgressBar(evt); + return; + } + + if (evt.Type == AgentEventType.Thinking && + ContainsAny(evt.Summary ?? "", "컨텍스트 압축", "microcompact", "session memory", "compact")) + { + var compactPrimaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var compactSecondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var compactHintBg = TryFindResource("HintBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var compactBorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var compactAccentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var pill = CreateCompactEventPill(string.IsNullOrWhiteSpace(evt.Summary) ? "컨텍스트 압축" : evt.Summary, compactPrimaryText, compactSecondaryText, compactHintBg, compactBorderBrush, compactAccentBrush); + pill.Opacity = 0; + pill.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160))); + MessagePanel.Children.Add(pill); + return; + } + + // simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시) + if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall) + return; + + // 전체 통계 이벤트는 별도 색상 (보라색 계열) + var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; + + var (icon, label, bgHex, fgHex) = isTotalStats + ? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED") + : evt.Type switch + { + AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"), + AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true), + AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false), + AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"), + AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary), + AgentEventType.ToolCall => ("\uE8A7", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 실행" : evt.ToolName, "#EEF6FF", "#3B82F6"), + AgentEventType.ToolResult => ("\uE73E", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 완료" : evt.ToolName, "#EEF9EE", "#16A34A"), + AgentEventType.SkillCall => ("\uE8A5", string.IsNullOrWhiteSpace(evt.ToolName) ? "스킬 실행" : evt.ToolName, "#FFF7ED", "#EA580C"), + AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"), + AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"), + AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"), + AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"), + AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"), + _ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"), + }; + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); + var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)); + + var banner = new Border + { + Background = Brushes.Transparent, + BorderBrush = Brushes.Transparent, + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(0), + Margin = new Thickness(24, 2, 24, 2), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + if (!string.IsNullOrWhiteSpace(evt.RunId)) + _runBannerAnchors[evt.RunId] = banner; + + var sp = new StackPanel(); + + // 헤더: 얇은 실행 줄 형태 + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 좌측: 아이콘 + 라벨 + var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; + headerLeft.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + }); + headerLeft.Children.Add(new TextBlock + { + Text = label, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + Grid.SetColumn(headerLeft, 0); + + // 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정) + var headerRight = new StackPanel { Orientation = Orientation.Horizontal }; + if (logLevel != "simple" && evt.ElapsedMs > 0) + { + headerRight.Children.Add(new TextBlock + { + Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s", + FontSize = 10, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }); + } + if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0)) + { + var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0 + ? $"{evt.InputTokens}→{evt.OutputTokens}t" + : evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t"; + headerRight.Children.Add(new Border + { + Background = hintBg, + BorderBrush = borderColor, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = tokenText, + FontSize = 9.5, + Foreground = secondaryText, + FontFamily = new FontFamily("Consolas"), + }, + }); + } + Grid.SetColumn(headerRight, 1); + + headerGrid.Children.Add(headerLeft); + headerGrid.Children.Add(headerRight); + + // header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용) + var header = headerLeft; + + sp.Children.Add(headerGrid); + + // simple 모드: 요약 한 줄만 표시 (본문 로그) + if (logLevel == "simple") + { + if (!string.IsNullOrEmpty(evt.Summary)) + { + var shortSummary = evt.Summary.Length > 100 + ? evt.Summary[..100] + "…" + : evt.Summary; + sp.Children.Add(new TextBlock + { + Text = shortSummary, + FontSize = 10.5, + Foreground = secondaryText, + TextWrapping = TextWrapping.NoWrap, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(16, 2, 0, 0), + }); + } + } + // detailed/debug 모드: 실행 줄 아래에 얕은 설명만 표시 + else if (!string.IsNullOrEmpty(evt.Summary)) + { + var summaryText = evt.Summary.Length > 180 ? evt.Summary[..180] + "…" : evt.Summary; + sp.Children.Add(new TextBlock + { + Text = summaryText, + FontSize = 10.5, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 2, 0, 0), + }); + } + + var reviewChipRow = BuildReviewSignalChipRow( + kind: null, + toolName: evt.ToolName, + title: label, + summary: evt.Summary); + if (reviewChipRow != null) + { + reviewChipRow.Margin = new Thickness(16, 4, 0, 0); + sp.Children.Add(reviewChipRow); + } + + // debug 모드: ToolInput 파라미터 표시 + if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput)) + { + sp.Children.Add(new Border + { + Background = hintBg, + BorderBrush = borderColor, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(16, 4, 0, 0), + Child = new TextBlock + { + Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput, + FontSize = 10, + Foreground = secondaryText, + FontFamily = new FontFamily("Consolas"), + TextWrapping = TextWrapping.Wrap, + }, + }); + } + + // 파일 경로 배너 (AX Agent 스타일) + if (!string.IsNullOrEmpty(evt.FilePath)) + { + var pathBorder = new Border + { + Background = hintBg, + BorderBrush = borderColor, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 5, 8, 5), + Margin = new Thickness(16, 4, 0, 0), + }; + + var pathGrid = new Grid(); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var left = new StackPanel { Orientation = Orientation.Vertical }; + var fileName = System.IO.Path.GetFileName(evt.FilePath); + var dirName = System.IO.Path.GetDirectoryName(evt.FilePath) ?? ""; + + var topRow = new StackPanel { Orientation = Orientation.Horizontal }; + topRow.Children.Add(new TextBlock + { + Text = "\uE8B7", // folder icon + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + topRow.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(fileName) ? evt.FilePath : fileName, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#374151")), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + left.Children.Add(topRow); + if (!string.IsNullOrWhiteSpace(dirName)) + { + left.Children.Add(new TextBlock + { + Text = dirName, + FontSize = 9.5, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")), + FontFamily = new FontFamily("Consolas"), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + } + Grid.SetColumn(left, 0); + pathGrid.Children.Add(left); + + // 빠른 작업 버튼들 + var quickActions = BuildFileQuickActions(evt.FilePath); + Grid.SetColumn(quickActions, 1); + pathGrid.Children.Add(quickActions); + + pathBorder.Child = pathGrid; + sp.Children.Add(pathBorder); + } + + banner.Child = sp; + + // Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기 + if (isTotalStats) + { + banner.Cursor = Cursors.Hand; + banner.ToolTip = "클릭하여 병목 분석 보기"; + banner.MouseLeftButtonUp += (_, _) => + { + OpenWorkflowAnalyzerIfEnabled(); + _analyzerWindow?.SwitchToBottleneckTab(); + _analyzerWindow?.Activate(); + }; + } + + // 페이드인 애니메이션 + banner.Opacity = 0; + banner.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); + + MessagePanel.Children.Add(banner); + } + + /// 파일 빠른 작업 버튼 패널을 생성합니다. + private StackPanel BuildFileQuickActions(string filePath) + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); + var accentBrush = new SolidColorBrush(accentColor); + + Border MakeBtn(string mdlIcon, string tooltip, Action action) + { + var icon = new TextBlock + { + Text = mdlIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + var btn = new Border + { + Child = icon, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(4), + Width = 22, + Height = 22, + Margin = new Thickness(0, 0, 2, 0), + Cursor = Cursors.Hand, + ToolTip = tooltip, + }; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + btn.MouseLeftButtonUp += (_, _) => action(); + return btn; + } + + // 프리뷰 (지원 확장자만) + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + if (_previewableExtensions.Contains(ext)) + { + var path1 = filePath; + panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1))); + } + + // 외부 열기 + var path2 = filePath; + panel.Children.Add(MakeBtn("\uE8A7", "열기", () => + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { } + })); + + // 폴더 열기 + var path3 = filePath; + panel.Children.Add(MakeBtn("\uED25", "폴더", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { } + })); + + // 경로 복사 + var path4 = filePath; + panel.Children.Add(MakeBtn("\uE8C8", "복사", () => + { + try { Clipboard.SetText(path4); } catch { } + })); + + return panel; + } + + // ─── 응답 재생성 ────────────────────────────────────────────────────── + + private async Task RegenerateLastAsync() + { + if (_isStreaming) return; + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // 마지막 assistant 메시지 제거 + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.RemoveLastAssistantMessage(_activeTab, _storage); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") + { + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + } + + // UI에서 마지막 AI 응답 제거 + if (MessagePanel.Children.Count > 0) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 재전송 + await SendRegenerateAsync(conv); + } + + /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다. + private void ShowRetryWithFeedbackInput() + { + if (_isStreaming) return; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var container = new Border + { + Margin = new Thickness(40, 4, 40, 8), + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = itemBg, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = "어떻게 수정하면 좋을지 알려주세요:", + FontSize = 12, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + + var textBox = new TextBox + { + MinHeight = 38, + MaxHeight = 80, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, + Foreground = primaryText, + CaretBrush = primaryText, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6, 10, 6), + }; + stack.Children.Add(textBox); + + var btnRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 8, 0, 0), + }; + + var sendBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(14, 6, 14, 6), + Cursor = Cursors.Hand, + Margin = new Thickness(6, 0, 0, 0), + }; + sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; + sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; + sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + sendBtn.MouseLeftButtonUp += (_, _) => + { + var feedback = textBox.Text.Trim(); + if (string.IsNullOrEmpty(feedback)) return; + MessagePanel.Children.Remove(container); + _ = RetryWithFeedbackAsync(feedback); + }; + + var cancelBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 6, 12, 6), + Cursor = Cursors.Hand, + }; + cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; + cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container); + + btnRow.Children.Add(cancelBtn); + btnRow.Children.Add(sendBtn); + stack.Children.Add(btnRow); + container.Child = stack; + + ApplyMessageEntryAnimation(container); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); + textBox.Focus(); + } + + /// 사용자 피드백과 함께 마지막 응답을 재생성합니다. + private async Task RetryWithFeedbackAsync(string feedback) + { + if (_isStreaming) return; + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) return; + conv = _currentConversation; + } + + // 마지막 assistant 메시지 제거 + lock (_convLock) + { + if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + + // UI에서 마지막 AI 응답 제거 + if (MessagePanel.Children.Count > 0) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 피드백을 사용자 메시지로 추가 + var feedbackMsg = new ChatMessage + { + Role = "user", + Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요." + }; + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(_activeTab, feedbackMsg, _storage); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(feedbackMsg); + } + } + + // 피드백 메시지 UI 표시 + AddMessageBubble("user", $"[수정 요청] {feedback}", true); + + // 재전송 + await SendRegenerateAsync(conv); + } + + private async Task SendRegenerateAsync(ChatConversation conv) + { + _isStreaming = true; + BtnSend.IsEnabled = false; + BtnSend.Visibility = Visibility.Collapsed; + BtnStop.Visibility = Visibility.Visible; + _streamCts = new CancellationTokenSource(); + ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 + + var assistantContent = string.Empty; + _activeStreamText = null; + _cachedStreamContent = ""; + _displayedLength = 0; + _cursorVisible = true; + _aiIconPulseStopped = false; + _streamStartTime = DateTime.UtcNow; + _elapsedTimer.Start(); + SetStatus("에이전트 작업 중...", spinning: true); + + try + { + List sendMessages; + lock (_convLock) sendMessages = conv.Messages.ToList(); + if (!string.IsNullOrEmpty(conv.SystemCommand)) + sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); + + var response = await _llm.SendAsync(sendMessages, _streamCts.Token); + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; + } + catch (OperationCanceledException) + { + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(취소됨)" : assistantContent; + } + catch (Exception ex) + { + var errMsg = $"⚠ 오류: {ex.Message}"; + assistantContent = errMsg; + AddRetryButton(); + } + finally + { + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리 + StopRainbowGlow(); // 레인보우 글로우 종료 + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(빈 응답)" : assistantContent; + lock (_convLock) + { + var session = ChatSession; + _chatEngine.CommitAssistantMessage(session, conv, _activeTab, assistantContent, _storage); + _currentConversation = session?.CurrentConversation ?? conv; + conv = _currentConversation!; + } + RenderMessages(preserveViewport: true); + AutoScrollIfNeeded(); + + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + ChatSession?.RememberConversation(conv.Tab ?? _activeTab, conv.Id); + SyncTabConversationIdsFromSession(); + RefreshConversationList(); + } + + /// 채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다. + private double GetMessageMaxWidth() + { + var hostWidth = ComposerShell?.ActualWidth ?? 0; + if (hostWidth < 100) + hostWidth = MessageScroll?.ActualWidth ?? 0; + if (hostWidth < 100) + hostWidth = 1120; // 초기화 전 기준 폭 + + // 컴포저와 메시지 버블이 같은 레이아웃 축을 쓰도록 여백만 제외한 폭을 공통 적용 + var maxW = hostWidth - 44; + return Math.Clamp(maxW, 320, 720); + } + + private StackPanel CreateStreamingContainer(out TextBlock streamText) + { + var msgMaxWidth = GetMessageMaxWidth(); + var container = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Left, + Width = msgMaxWidth, + MaxWidth = msgMaxWidth, + Margin = new Thickness(10, 3, 150, 3), + Opacity = 0, + RenderTransform = new TranslateTransform(0, 10) + }; + + // 컨테이너 페이드인 + 슬라이드 업 + container.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); + ((TranslateTransform)container.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) + { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); + + var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) }; + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var aiIcon = new TextBlock + { + Text = "\uE945", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, + Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + VerticalAlignment = VerticalAlignment.Center + }; + // AI 아이콘 펄스 애니메이션 (응답 대기 중) + aiIcon.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) + { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, + EasingFunction = new SineEase() }); + _activeAiIcon = aiIcon; + Grid.SetColumn(aiIcon, 0); + headerGrid.Children.Add(aiIcon); + + var (streamAgentName, _, _) = GetAgentIdentity(); + var aiNameTb = new TextBlock + { + Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(aiNameTb, 1); + headerGrid.Children.Add(aiNameTb); + + // 실시간 경과 시간 (헤더 우측) + _elapsedLabel = new TextBlock + { + Text = "0s", + FontSize = 9.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.5, + }; + Grid.SetColumn(_elapsedLabel, 2); + headerGrid.Children.Add(_elapsedLabel); + + container.Children.Add(headerGrid); + + var streamCard = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(13, 10, 13, 10) + }; + streamText = new TextBlock + { + Text = "\u258c", + FontSize = 12.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + LineHeight = 20, + }; + streamCard.Child = streamText; + container.Children.Add(streamCard); + return container; + } + + // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── + + private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) + { + finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent; + + // 스트리밍 plaintext 카드 제거 + if (streamText.Parent is Border streamCard) + container.Children.Remove(streamCard); + else + container.Children.Remove(streamText); + + // 마크다운 렌더링 + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; + + var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); + mdPanel.Margin = new Thickness(0, 0, 0, 4); + mdPanel.Opacity = 0; + var mdCard = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(13, 10, 13, 10), + Child = mdPanel, + }; + container.Children.Add(mdCard); + mdPanel.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); + + // 액션 버튼 바 + 토큰 표시 + var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var capturedContent = finalContent; + var actionBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(2, 2, 0, 0), + Opacity = 0 + }; + actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => + { + try { Clipboard.SetText(capturedContent); } catch { } + })); + actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); + actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); + AddLinkedFeedbackButtons(actionBar, btnColor, message); + + container.Children.Add(actionBar); + container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar); + container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar); + container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard); + + // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) + var elapsed = DateTime.UtcNow - _streamStartTime; + var elapsedText = elapsed.TotalSeconds < 60 + ? $"{elapsed.TotalSeconds:0.#}s" + : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + + var usage = _llm.LastTokenUsage; + // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 + var isAgentTab = _activeTab is "Cowork" or "Code"; + var (usageService, usageModel) = _llm.GetCurrentModelInfo(); + var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 + ? _agentCumulativeInputTokens + : usage?.PromptTokens ?? 0; + var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0 + ? _agentCumulativeOutputTokens + : usage?.CompletionTokens ?? 0; + + var wasPostCompactionResponse = _pendingPostCompaction; + if (displayInput > 0 || displayOutput > 0) + { + UpdateStatusTokens(displayInput, displayOutput); + Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse); + ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput); + } + string tokenText; + if (displayInput > 0 || displayOutput > 0) + tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; + else if (usage != null) + tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; + else + tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; + + var postCompactSuffix = wasPostCompactionResponse ? " · compact 직후" : ""; + var metaText = new TextBlock + { + Text = $"{elapsedText} · {tokenText}{postCompactSuffix}", + FontSize = 9.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 4, 0, 0), + Opacity = 0.55, + }; + container.Children.Add(metaText); + + // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 + var chips = ParseSuggestionChips(finalContent); + if (chips.Count > 0) + { + var chipPanel = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 4), + HorizontalAlignment = HorizontalAlignment.Left, + }; + foreach (var (num, label) in chips) + { + var chipBorder = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14, 7, 14, 7), + Margin = new Thickness(0, 0, 8, 6), + Cursor = Cursors.Hand, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + chipBorder.Child = new TextBlock + { + Text = $"{num}. {label}", + FontSize = 12.5, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + }; + + var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + chipBorder.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } + }; + chipBorder.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } + }; + + var capturedLabel = $"{num}. {label}"; + var capturedPanel = chipPanel; + chipBorder.MouseLeftButtonDown += (_, _) => + { + // 칩 패널 제거 (1회용) + if (capturedPanel.Parent is Panel parent) + parent.Children.Remove(capturedPanel); + // 선택한 옵션을 사용자 메시지로 전송 + InputBox.Text = capturedLabel; + _ = SendMessageAsync(); + }; + chipPanel.Children.Add(chipBorder); + } + container.Children.Add(chipPanel); + } + } + + /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) + private static List<(string Num, string Label)> ParseSuggestionChips(string content) + { + var chips = new List<(string, string)>(); + if (string.IsNullOrEmpty(content)) return chips; + + var lines = content.Split('\n'); + // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) + var candidates = new List<(string, string)>(); + var lastBlockStart = -1; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 + var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); + if (m.Success) + { + if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) + { + if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + else + { + // 새로운 블록 시작 + lastBlockStart = i; + candidates.Clear(); + candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); + } + } + else if (!string.IsNullOrWhiteSpace(line)) + { + // 번호 목록이 아닌 줄이 나오면 블록 리셋 + lastBlockStart = -1; + candidates.Clear(); + } + // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) + } + + // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 + if (candidates.Count >= 2 && candidates.Count <= 10) + chips.AddRange(candidates); + + return chips; + } + + /// 토큰 수를 k/m 단위로 포맷 + private static string FormatTokenCount(int count) => count switch + { + >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", + >= 1_000 => $"{count / 1_000.0:0.#}k", + _ => count.ToString(), + }; + + /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) + private static int EstimateTokenCount(string text) + { + if (string.IsNullOrEmpty(text)) return 0; + // 한국어 문자 비율에 따라 가중 + int cjk = 0; + foreach (var c in text) + if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; + double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; + double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 + return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); + } + + // ─── 생성 중지 ────────────────────────────────────────────────────── + + private void StopGeneration() + { + _streamCts?.Cancel(); + } + + // ─── 대화 내보내기 ────────────────────────────────────────────────── + + // ─── 대화 분기 (Fork) ────────────────────────────────────────────── + + private void ForkConversation( + ChatConversation source, + int atIndex, + string? branchHint = null, + string? branchContextMessage = null, + string? branchContextRunId = null) + { + var branchCount = _storage.LoadAllMeta() + .Count(m => m.ParentId == source.Id) + 1; + var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId) + ?? new ChatConversation + { + Title = source.Title, + Tab = source.Tab, + Category = source.Category, + WorkFolder = source.WorkFolder, + SystemCommand = source.SystemCommand, + ParentId = source.Id, + BranchLabel = $"분기 {branchCount}", + BranchAtIndex = atIndex, + }; + + try + { + _storage.Save(fork); + ShowToast($"분기 생성: {fork.Title}"); + + // 분기 대화로 전환 + lock (_convLock) + { + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork; + SyncTabConversationIdsFromSession(); + } + ChatTitle.Text = fork.Title; + RenderMessages(); + RefreshConversationList(); + } + catch (Exception ex) + { + ShowToast($"분기 실패: {ex.Message}", "\uE783"); + } + } + + // ─── 커맨드 팔레트 ───────────────────────────────────────────────── + + private void OpenCommandPalette() + { + var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this }; + palette.ShowDialog(); + } + + private void ExecuteCommand(string commandId) + { + switch (commandId) + { + case "tab:chat": TabChat.IsChecked = true; break; + case "tab:cowork": TabCowork.IsChecked = true; break; + case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break; + case "new_conversation": StartNewConversation(); break; + case "search_conversation": ToggleMessageSearch(); break; + case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break; + case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break; + case "open_statistics": new StatisticsWindow().Show(); break; + case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break; + case "toggle_devmode": + var llm = _settings.Settings.Llm; + llm.DevMode = !llm.DevMode; + _settings.Save(); + UpdateAnalyzerButtonVisibility(); + ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐"); + break; + case "open_audit_log": + try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { } + break; + case "paste_clipboard": + try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { } + break; + case "export_conversation": ExportConversation(); break; + } + } + + private void ExportConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null || conv.Messages.Count == 0) return; + + var dlg = new Microsoft.Win32.SaveFileDialog + { + FileName = $"{conv.Title}", + DefaultExt = ".md", + Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt" + }; + if (dlg.ShowDialog() != true) return; + + var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant(); + string content; + + if (ext == ".json") + { + content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + else if (dlg.FileName.EndsWith(".pdf.html")) + { + // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시 + content = PdfExportService.BuildHtml(conv); + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + PdfExportService.OpenInBrowser(dlg.FileName); + ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다"); + return; + } + else if (ext == ".html") + { + content = ExportToHtml(conv); + } + else + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"# {conv.Title}"); + sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_"); + sb.AppendLine(); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var label = msg.Role == "user" ? "**사용자**" : "**AI**"; + sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})"); + sb.AppendLine(); + sb.AppendLine(msg.Content); + if (msg.AttachedFiles is { Count: > 0 }) + { + sb.AppendLine(); + sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_"); + } + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + } + content = sb.ToString(); + } + + System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8); + } + + private static string ExportToHtml(ChatConversation conv) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(""); + sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}"); + sb.AppendLine(""); + sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(conv.Title)}

"); + sb.AppendLine($"

생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}

"); + + foreach (var msg in conv.Messages) + { + if (msg.Role == "system") continue; + var cls = msg.Role == "user" ? "user" : "ai"; + var label = msg.Role == "user" ? "사용자" : "AI"; + sb.AppendLine($"
"); + sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
"); + sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
"); + sb.AppendLine("
"); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + // ─── 버튼 이벤트 ────────────────────────────────────────────────────── + + private void ChatWindow_KeyDown(object sender, KeyEventArgs e) + { + var mod = Keyboard.Modifiers; + + // Ctrl 단축키 + if (mod == ModifierKeys.Control) + { + switch (e.Key) + { + case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.W: Close(); e.Handled = true; break; + case Key.E: ExportConversation(); e.Handled = true; break; + case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; + case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.F: ToggleMessageSearch(); e.Handled = true; break; + case Key.K: OpenSidebarSearch(); e.Handled = true; break; + case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; + case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; + case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; + } + } + + // Ctrl+Shift 단축키 + if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) + { + switch (e.Key) + { + case Key.C: + // 마지막 AI 응답 복사 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv != null) + { + var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); + if (lastAi != null) + try { Clipboard.SetText(lastAi.Content); } catch { } + } + e.Handled = true; + break; + case Key.R: + // 마지막 응답 재생성 + _ = RegenerateLastAsync(); + e.Handled = true; + break; + case Key.D: + // 모든 대화 삭제 + BtnDeleteAll_Click(this, new RoutedEventArgs()); + e.Handled = true; + break; + case Key.P: + // 커맨드 팔레트 + OpenCommandPalette(); + e.Handled = true; + break; + } + } + + // Escape: 검색 바 닫기 또는 스트리밍 중지 + if (e.Key == Key.Escape) + { + if (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; } + else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } + else if (_isStreaming) { StopGeneration(); e.Handled = true; } + } + + // 슬래시 명령 팝업 키 처리 + if (TryHandleSlashNavigationKey(e)) + return; + + if (PermissionPopup.IsOpen && e.Key == Key.Escape) + { + PermissionPopup.IsOpen = false; + e.Handled = true; + } + } + + private bool TryHandleSlashNavigationKey(KeyEventArgs e) + { + if (!SlashPopup.IsOpen) + return false; + + switch (e.Key) + { + case Key.Escape: + SlashPopup.IsOpen = false; + _slashPalette.SelectedIndex = -1; + e.Handled = true; + return true; + case Key.Up: + SlashPopup_ScrollByDelta(120); + e.Handled = true; + return true; + case Key.Down: + SlashPopup_ScrollByDelta(-120); + e.Handled = true; + return true; + case Key.PageUp: + SlashPopup_ScrollByDelta(600); + e.Handled = true; + return true; + case Key.PageDown: + SlashPopup_ScrollByDelta(-600); + e.Handled = true; + return true; + case Key.Home: + { + var visible = GetVisibleSlashOrderedIndices(); + _slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches); + UpdateSlashSelectionVisualState(); + EnsureSlashSelectionVisible(); + e.Handled = true; + return true; + } + case Key.End: + { + var visible = GetVisibleSlashOrderedIndices(); + _slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches); + UpdateSlashSelectionVisualState(); + EnsureSlashSelectionVisible(); + e.Handled = true; + return true; + } + case Key.Tab when _slashPalette.SelectedIndex >= 0: + case Key.Enter when _slashPalette.SelectedIndex >= 0: + ExecuteSlashSelectedItem(); + e.Handled = true; + return true; + default: + return false; + } + } + + private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); + + private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_agentLoop.IsPaused) + { + _agentLoop.Resume(); + PauseIcon.Text = "\uE769"; // 일시정지 아이콘 + BtnPause.ToolTip = "일시정지"; + } + else + { + _ = _agentLoop.PauseAsync(); + PauseIcon.Text = "\uE768"; // 재생 아이콘 + BtnPause.ToolTip = "재개"; + } + } + private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); + + // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── + + private List _searchMatchIndices = new(); + private int _searchCurrentIndex = -1; + + private void ToggleMessageSearch() + { + if (MessageSearchBar.Visibility == Visibility.Visible) + CloseMessageSearch(); + else + { + MessageSearchBar.Visibility = Visibility.Visible; + SearchTextBox.Focus(); + SearchTextBox.SelectAll(); + } + } + + private void CloseMessageSearch() + { + MessageSearchBar.Visibility = Visibility.Collapsed; + SearchTextBox.Text = ""; + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + // 하이라이트 제거 + ClearSearchHighlights(); + } + + private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + var query = SearchTextBox.Text.Trim(); + if (string.IsNullOrEmpty(query)) + { + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + ClearSearchHighlights(); + return; + } + + // 현재 대화의 메시지에서 검색 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + _searchMatchIndices.Clear(); + for (int i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) + _searchMatchIndices.Add(i); + } + + if (_searchMatchIndices.Count > 0) + { + _searchCurrentIndex = 0; + SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + else + { + _searchCurrentIndex = -1; + SearchResultCount.Text = "결과 없음"; + } + } + + private void SearchPrev_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchNext_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); + + private void HighlightSearchResult() + { + if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; + var msgIndex = _searchMatchIndices[_searchCurrentIndex]; + + // MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤 + // 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로 + // (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동 + if (msgIndex < MessagePanel.Children.Count) + { + var element = MessagePanel.Children[msgIndex] as FrameworkElement; + element?.BringIntoView(); + } + else if (MessagePanel.Children.Count > 0) + { + // 범위 밖이면 마지막 자식으로 이동 + (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); + } + } + + private void ClearSearchHighlights() + { + // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 + } + + // ─── 에러 복구 재시도 버튼 ────────────────────────────────────────────── + + private void AddRetryButton() + { + Dispatcher.Invoke(() => + { + var retryBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(40, 4, 80, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Cursor = System.Windows.Input.Cursors.Hand, + }; + var retrySp = new StackPanel { Orientation = Orientation.Horizontal }; + retrySp.Children.Add(new TextBlock + { + Text = "\uE72C", FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + retrySp.Children.Add(new TextBlock + { + Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, + }); + retryBorder.Child = retrySp; + retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeftButtonUp += (_, _) => + { + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.RemoveLastAssistantMessage(_activeTab, _storage); + _currentConversation = session.CurrentConversation; + } + else if (_currentConversation != null) + { + var lastIdx = _currentConversation.Messages.Count - 1; + if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant") + _currentConversation.Messages.RemoveAt(lastIdx); + } + } + _ = RegenerateLastAsync(); + }; + MessagePanel.Children.Add(retryBorder); + ForceScrollToEnd(); + }); + } + + // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── + + private void ShowMessageContextMenu(string content, string role) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); + var (popup, panel) = CreateThemedPopupMenu(); + + // 복사 + panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () => + { + try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { } + })); + + // 마크다운 복사 + panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () => + { + try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { } + })); + + // 인용하여 답장 + panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () => + { + var quote = content.Length > 200 ? content[..200] + "..." : content; + var lines = quote.Split('\n'); + var quoted = string.Join("\n", lines.Select(l => $"> {l}")); + InputBox.Text = quoted + "\n\n"; + InputBox.Focus(); + InputBox.CaretIndex = InputBox.Text.Length; + })); + + AddPopupMenuSeparator(panel, borderBrush); + + // 재생성 (AI 응답만) + if (role == "assistant") + { + panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync())); + } + + // 대화 분기 (Fork) + panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); + if (idx < 0) return; + + ForkConversation(conv, idx); + })); + + AddPopupMenuSeparator(panel, borderBrush); + + // 이후 메시지 모두 삭제 + var msgContent = content; + var msgRole = role; + panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); + if (idx < 0) return; + + var removeCount = conv.Messages.Count - idx; + if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", + "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) + return; + + conv.Messages.RemoveRange(idx, removeCount); + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RenderMessages(); + ShowToast($"{removeCount}개 메시지 삭제됨"); + })); + + Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); + } + + // ─── 팁 알림 ────────────────────────────────────────────────────── + + private static readonly string[] Tips = + [ + "💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", + "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", + "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", + "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", + "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", + "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", + "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", + "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", + "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", + "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", + "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", + "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", + "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", + "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", + "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", + "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", + "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", + "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", + "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", + "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", + "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", + "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", + "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", + "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", + ]; + private int _tipIndex; + private DispatcherTimer? _tipDismissTimer; + + private void ShowRandomTip() + { + if (!_settings.Settings.Llm.ShowTips) return; + if (_activeTab != "Cowork" && _activeTab != "Code") return; + + var tip = Tips[_tipIndex % Tips.Length]; + _tipIndex++; + + // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) + ShowTip(tip); + } + + private void ShowTip(string message) + { + _tipDismissTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = "\uE82F"; // 전구 아이콘 + ToastBorder.Visibility = Visibility.Visible; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + var duration = _settings.Settings.Llm.TipDurationSeconds; + if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음) + + _tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; + _tipDismissTimer.Tick += (_, _) => + { + _tipDismissTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _tipDismissTimer.Start(); + } + + // ─── 프로젝트 문맥 파일 (AGENTS.md) ────────────────────────────────── + + /// + /// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. + /// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다. + /// + private static string LoadProjectContext(string workFolder) + { + if (string.IsNullOrEmpty(workFolder)) return ""; + + // AGENTS.md 탐색 (작업 폴더 → 상위 폴더 순, 레거시 AX.md 폴백) + var searchDir = workFolder; + for (int i = 0; i < 3; i++) // 최대 3단계 상위까지 + { + if (string.IsNullOrEmpty(searchDir)) break; + var agentsPath = System.IO.Path.Combine(searchDir, "AGENTS.md"); + var legacyPath = System.IO.Path.Combine(searchDir, "AX.md"); + var filePath = System.IO.File.Exists(agentsPath) ? agentsPath : legacyPath; + if (System.IO.File.Exists(filePath)) + { + try + { + var content = System.IO.File.ReadAllText(filePath); + if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; + var sourceName = System.IO.Path.GetFileName(filePath); + return $"\n## Project Context (from {sourceName})\n{content}\n"; + } + catch { } + } + searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; + } + return ""; + } + + // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── + + private DispatcherTimer? _rainbowTimer; + private DateTime _rainbowStartTime; + + /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). + private void PlayRainbowGlow() + { + if (!_settings.Settings.Llm.EnableChatRainbowGlow) return; + + _rainbowTimer?.Stop(); + _rainbowStartTime = DateTime.UtcNow; + + // 페이드인 (빠르게) + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))); + + // 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속 + _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _rainbowTimer.Tick += (_, _) => + { + var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; + + // 그라데이션 오프셋 회전 + var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게) + var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; + if (brush == null) return; + + // 시작/끝점 회전 (원형 이동) + var angle = shift * Math.PI * 2; + brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); + brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); + }; + _rainbowTimer.Start(); + } + + /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. + private void StopRainbowGlow() + { + _rainbowTimer?.Stop(); + _rainbowTimer = null; + if (InputGlowBorder.Opacity > 0) + { + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( + InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); + fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0; + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + } + } + + // ─── 토스트 알림 ────────────────────────────────────────────────────── + + private DispatcherTimer? _toastHideTimer; + + private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) + { + _toastHideTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = icon; + ToastBorder.Visibility = Visibility.Visible; + + // 페이드인 + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); + + // 자동 숨기기 + _toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; + _toastHideTimer.Tick += (_, _) => + { + _toastHideTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _toastHideTimer.Start(); + } + + // ─── 대화 주제 버튼 ────────────────────────────────────────────────── + + /// 프리셋에서 대화 주제 버튼을 동적으로 생성합니다. + private void BuildTopicButtons() + { + TopicButtonPanel.Children.Clear(); + TopicButtonPanel.Visibility = Visibility.Visible; + if (TopicPresetScrollViewer != null) + TopicPresetScrollViewer.Visibility = Visibility.Visible; + + // 탭별 EmptyState 텍스트 + if (_activeTab == "Cowork" || _activeTab == "Code") + { + if (EmptyStateTitle != null) EmptyStateTitle.Text = _activeTab == "Code" + ? "코드 작업을 입력하세요" + : "작업 유형을 선택하세요"; + if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code" + ? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다" + : "에이전트가 상세한 데이터를 작성합니다"; + } + else + { + if (EmptyStateTitle != null) EmptyStateTitle.Text = "대화 주제를 선택하세요"; + if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다"; + } + + if (_activeTab == "Code") + { + TopicButtonPanel.Visibility = Visibility.Collapsed; + if (TopicPresetScrollViewer != null) + TopicPresetScrollViewer.Visibility = Visibility.Collapsed; + return; + } + + var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets); + + foreach (var preset in presets) + { + var capturedPreset = preset; + var btnColor = BrushFromHex(preset.Color); + + var border = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent, + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 14, 14, 14), + Margin = new Thickness(6, 6, 6, 10), + Cursor = Cursors.Hand, + Width = 124, + Height = 116, + ClipToBounds = true, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + + var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + + // 아이콘 컨테이너 (원형 배경 + 펄스 애니메이션) + var iconCircle = new Border + { + Width = 40, Height = 40, + CornerRadius = new CornerRadius(20), + Background = new SolidColorBrush(((SolidColorBrush)btnColor).Color) { Opacity = 0.15 }, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 12), + }; + var iconTb = new TextBlock + { + Text = preset.Symbol, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 18, + Foreground = btnColor, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + iconCircle.Child = iconTb; + stack.Children.Add(iconCircle); + + // 제목 + stack.Children.Add(new TextBlock + { + Text = preset.Label, + FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + }); + + // 설명 + stack.Children.Add(new TextBlock + { + Text = preset.Description, + FontSize = 9, TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxHeight = 32, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 2, 0, 0), + TextAlignment = TextAlignment.Center, + }); + + // 커스텀 프리셋: 좌측 상단 뱃지 + if (capturedPreset.IsCustom) + { + var grid = new Grid(); + grid.Children.Add(stack); + var badge = new Border + { + Width = 16, Height = 16, + CornerRadius = new CornerRadius(4), + Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(2, 2, 0, 0), + ToolTip = "커스텀 프리셋", + }; + badge.Child = new TextBlock + { + Text = "\uE710", // + 아이콘 + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 8, + Foreground = btnColor, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + grid.Children.Add(badge); + border.Child = grid; + } + else + { + border.Child = stack; + } + + // 호버 애니메이션 — 스케일 1.05x + 밝기 변경 + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var normalBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + border.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { + st.ScaleX = 1.03; st.ScaleY = 1.03; + b.Background = hoverBg; + } + }; + border.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { + st.ScaleX = 1.0; st.ScaleY = 1.0; + b.Background = normalBg; + } + }; + + // 클릭 → 해당 주제로 새 대화 시작 + border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset); + + // 커스텀 프리셋: 우클릭 메뉴 (편집/삭제) + if (capturedPreset.IsCustom) + { + border.MouseRightButtonUp += (s, e) => + { + e.Handled = true; + ShowCustomPresetContextMenu(s as Border, capturedPreset); + }; + } + + TopicButtonPanel.Children.Add(border); + } + + // "기타" 자유 입력 버튼 추가 + { + var etcColor = BrushFromHex("#6B7280"); // 회색 + var etcBorder = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent, + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 14, 14, 14), + Margin = new Thickness(6, 6, 6, 10), + Cursor = Cursors.Hand, + Width = 124, + Height = 116, + ClipToBounds = true, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + + var etcStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + + var etcIconCircle = new Border + { + Width = 40, Height = 40, + CornerRadius = new CornerRadius(20), + Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 }, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 12), + }; + etcIconCircle.Child = new TextBlock + { + Text = "\uE70F", // Edit 아이콘 + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 18, + Foreground = etcColor, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + etcStack.Children.Add(etcIconCircle); + + etcStack.Children.Add(new TextBlock + { + Text = "기타", + FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + }); + etcStack.Children.Add(new TextBlock + { + Text = "프리셋 없이 자유롭게 대화합니다", + FontSize = 9, TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxHeight = 32, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 2, 0, 0), + TextAlignment = TextAlignment.Center, + }); + + etcBorder.Child = etcStack; + + var hoverBg2 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var normalBg2 = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + etcBorder.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg2; } + }; + etcBorder.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = normalBg2; } + }; + + etcBorder.MouseLeftButtonDown += (_, _) => + { + EmptyState.Visibility = Visibility.Collapsed; + InputBox.Focus(); + }; + TopicButtonPanel.Children.Add(etcBorder); + } + + // ── "+" 커스텀 프리셋 추가 버튼 ── + { + var addColor = BrushFromHex("#6366F1"); + var addBorder = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 14, 14, 14), + Margin = new Thickness(6, 6, 6, 10), + Cursor = Cursors.Hand, + Width = 124, Height = 116, + ClipToBounds = true, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1.5), + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1), + }; + // 점선 효과를 위한 Dashes + if (addBorder.BorderBrush is SolidColorBrush scb) + { + var dashPen = new Pen(scb, 1.5) { DashStyle = DashStyles.Dash }; + } + + var addStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + + // + 아이콘 + var plusIcon = new TextBlock + { + Text = "\uE710", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 24, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 8, 0, 8), + }; + addStack.Children.Add(plusIcon); + + addStack.Children.Add(new TextBlock + { + Text = "프리셋 추가", + FontSize = 12, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + }); + + addBorder.Child = addStack; + + var hoverBg3 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + addBorder.MouseEnter += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg3; } + }; + addBorder.MouseLeave += (s, _) => + { + if (s is Border b && b.RenderTransform is ScaleTransform st) + { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = Brushes.Transparent; } + }; + addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog(); + TopicButtonPanel.Children.Add(addBorder); + } + + UpdateTopicPresetScrollMode(); + } + + private void UpdateTopicPresetScrollMode() + { + if (TopicPresetScrollViewer == null || TopicButtonPanel == null) + return; + + Dispatcher.BeginInvoke(new Action(() => + { + if (TopicPresetScrollViewer == null || TopicButtonPanel == null) + return; + + TopicPresetScrollViewer.UpdateLayout(); + var shouldScroll = TopicPresetScrollViewer.ExtentHeight > TopicPresetScrollViewer.ViewportHeight + 1; + TopicPresetScrollViewer.VerticalScrollBarVisibility = shouldScroll + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Disabled; + TopicPresetScrollViewer.Padding = shouldScroll + ? new Thickness(0, 2, 6, 0) + : new Thickness(0, 2, 0, 0); + }), System.Windows.Threading.DispatcherPriority.Loaded); + } + + // ─── 커스텀 프리셋 관리 ───────────────────────────────────────────── + + /// 커스텀 프리셋 추가 다이얼로그를 표시합니다. + private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null) + { + bool isEdit = existing != null; + var dlg = new CustomPresetDialog( + existingName: existing?.Label ?? "", + existingDesc: existing?.Description ?? "", + existingPrompt: existing?.SystemPrompt ?? "", + existingColor: existing?.Color ?? "#6366F1", + existingSymbol: existing?.Symbol ?? "\uE713", + existingTab: existing?.Tab ?? _activeTab) + { + Owner = this, + }; + + if (dlg.ShowDialog() == true) + { + if (isEdit) + { + existing!.Label = dlg.PresetName; + existing.Description = dlg.PresetDescription; + existing.SystemPrompt = dlg.PresetSystemPrompt; + existing.Color = dlg.PresetColor; + existing.Symbol = dlg.PresetSymbol; + existing.Tab = dlg.PresetTab; + } + else + { + _settings.Settings.Llm.CustomPresets.Add(new Models.CustomPresetEntry + { + Label = dlg.PresetName, + Description = dlg.PresetDescription, + SystemPrompt = dlg.PresetSystemPrompt, + Color = dlg.PresetColor, + Symbol = dlg.PresetSymbol, + Tab = dlg.PresetTab, + }); + } + _settings.Save(); + BuildTopicButtons(); + } + } + + /// 커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다. + private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset) + { + if (anchor == null || preset.CustomId == null) return; + + var popup = new System.Windows.Controls.Primitives.Popup + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, + StaysOpen = false, + AllowsTransparency = true, + }; + + var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var menuBorder = new Border + { + Background = menuBg, + CornerRadius = new CornerRadius(10), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(4), + MinWidth = 120, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, + }, + }; + + var stack = new StackPanel(); + + // 편집 버튼 + var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText); + editItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId); + if (entry != null) ShowCustomPresetDialog(entry); + }; + stack.Children.Add(editItem); + + // 삭제 버튼 + var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText); + deleteItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var result = CustomMessageBox.Show( + $"'{preset.Label}' 프리셋을 삭제하시겠습니까?", + "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + _settings.Settings.Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId); + _settings.Save(); + BuildTopicButtons(); + } + }; + stack.Children.Add(deleteItem); + + menuBorder.Child = stack; + popup.Child = menuBorder; + popup.IsOpen = true; + } + + /// 컨텍스트 메뉴 항목을 생성합니다. + private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg) + { + var item = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 6, 14, 6), + Cursor = Cursors.Hand, + }; + + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, Foreground = fg, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, Foreground = fg, + VerticalAlignment = VerticalAlignment.Center, + }); + item.Child = sp; + + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + + return item; + } + + /// 대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용. + private void SelectTopic(Services.TopicPreset preset) + { + bool hasMessages; + lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0; + + // 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존) + bool hasInput = !string.IsNullOrEmpty(InputBox.Text); + bool keepConversation = hasMessages || hasInput; + + if (!keepConversation) + { + // 메시지도 입력 텍스트도 없으면 새 대화 시작 + StartNewConversation(); + } + + // 프리셋 적용 (기존 대화에도 프리셋 변경 가능) + lock (_convLock) + { + if (_currentConversation != null) + { + var session = ChatSession; + if (session != null) + { + _currentConversation = session.UpdateConversationMetadata(_activeTab, c => + { + c.SystemCommand = preset.SystemPrompt; + c.Category = preset.Category; + }, _storage); + } + else + { + _currentConversation.SystemCommand = preset.SystemPrompt; + _currentConversation.Category = preset.Category; + } + } + } + + UpdateCategoryLabel(); + SaveConversationSettings(); + RefreshConversationList(); + if (EmptyState != null) + EmptyState.Visibility = Visibility.Collapsed; + + InputBox.Focus(); + + if (!string.IsNullOrEmpty(preset.Placeholder)) + { + _promptCardPlaceholder = preset.Placeholder; + if (!keepConversation) ShowPlaceholder(); + } + + if (keepConversation) + ShowToast($"프리셋 변경: {preset.Label}"); + + // Cowork 탭: 하단 바 갱신 + if (_activeTab == "Cowork") + BuildBottomBar(); + } + + + + /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). + private string _selectedMood = null!; // Loaded 이벤트에서 초기화 + private string _selectedLanguage = "auto"; // Code 탭 개발 언어 + private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 + + /// 하단 바를 구성합니다 (Cowork 작업 제어 중심). + private void BuildBottomBar() + { + MoodIconPanel.Children.Clear(); + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; + } + + /// Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심. + private void BuildCodeBottomBar() + { + MoodIconPanel.Children.Clear(); + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; + } + + private Border CreateWorkspaceFolderBarButton() + { + var currentFolder = GetCurrentWorkFolder(); + var label = string.IsNullOrWhiteSpace(currentFolder) + ? "워크스페이스" + : TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18); + var tooltip = string.IsNullOrWhiteSpace(currentFolder) + ? "워크스페이스 선택" + : $"워크스페이스 선택\n현재: {currentFolder}"; + return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC"); + } + + private string GetWorktreeModeLabel() + { + var folder = GetCurrentWorkFolder(); + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + return "로컬"; + + var root = WorktreeStateStore.ResolveRoot(folder); + var active = WorktreeStateStore.Load(root).Active; + return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase) + ? "로컬" + : "워크트리"; + } + + private List GetAvailableWorkspaceVariants(string root, string? active) + { + var variants = new List(); + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) + return variants; + + try + { + var parent = Directory.GetParent(root)?.FullName ?? root; + var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*")); + variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*")); + } + catch + { + // ignore discovery failures + } + + if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active)) + variants.Add(active); + + return variants + .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + .Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(path => Directory.GetLastWriteTime(path)) + .Take(8) + .ToList(); + } + + private void ShowWorktreeMenu(UIElement placementTarget) + { + var (popup, panel) = CreateThemedPopupMenu(placementTarget, PlacementMode.Top, 320); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var currentFolder = GetCurrentWorkFolder(); + var root = string.IsNullOrWhiteSpace(currentFolder) ? "" : WorktreeStateStore.ResolveRoot(currentFolder); + var active = string.IsNullOrWhiteSpace(root) ? currentFolder : WorktreeStateStore.Load(root).Active; + var variants = GetAvailableWorkspaceVariants(root, active); + + panel.Children.Add(CreatePopupSummaryStrip(new[] + { + ("모드", string.Equals(active, root, StringComparison.OrdinalIgnoreCase) ? "로컬" : "워크트리", "#F8FAFC", "#E2E8F0", "#475569"), + ("변형", variants.Count.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"), + })); + panel.Children.Add(CreatePopupSectionLabel("현재 작업 위치", new Thickness(8, 6, 8, 4))); + + panel.Children.Add(CreatePopupMenuRow( + "\uED25", + "로컬", + string.IsNullOrWhiteSpace(root) ? "현재 워크스페이스" : root, + !string.IsNullOrWhiteSpace(root) && string.Equals(active, root, StringComparison.OrdinalIgnoreCase), + accentBrush, + secondaryText, + primaryText, + () => + { + popup.IsOpen = false; + if (!string.IsNullOrWhiteSpace(root)) + SwitchToWorkspace(root, root); + })); + + if (!string.IsNullOrWhiteSpace(active) && !string.Equals(active, root, StringComparison.OrdinalIgnoreCase)) + { + panel.Children.Add(CreatePopupMenuRow( + "\uE7BA", + Path.GetFileName(active), + active, + true, + accentBrush, + secondaryText, + primaryText, + () => + { + popup.IsOpen = false; + SwitchToWorkspace(active, root); + })); + } + + if (variants.Count > 0) + { + panel.Children.Add(CreatePopupSectionLabel($"워크트리 / 복사본 · {variants.Count}", new Thickness(8, 10, 8, 4))); + foreach (var variant in variants) + { + var isActive = !string.IsNullOrWhiteSpace(active) && + string.Equals(Path.GetFullPath(variant), Path.GetFullPath(active), StringComparison.OrdinalIgnoreCase); + panel.Children.Add(CreatePopupMenuRow( + "\uE8B7", + Path.GetFileName(variant), + isActive ? $"현재 선택 · {variant}" : variant, + isActive, + accentBrush, + secondaryText, + primaryText, + () => + { + popup.IsOpen = false; + SwitchToWorkspace(variant, root); + })); + } + } + + panel.Children.Add(CreatePopupSectionLabel("새 작업 위치", new Thickness(8, 10, 8, 4))); + + panel.Children.Add(CreatePopupMenuRow( + "\uE943", + "현재 브랜치로 워크트리 생성", + "Git 저장소면 분리된 작업 복사본을 만들고 전환합니다", + false, + accentBrush, + secondaryText, + primaryText, + () => + { + popup.IsOpen = false; + _ = CreateCurrentBranchWorktreeAsync(); + })); + + popup.IsOpen = true; + } + + private Border CreatePopupMenuRow( + string icon, + string title, + string description, + bool selected, + Brush accentBrush, + Brush secondaryText, + Brush primaryText, + Action? onClick) + { + var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB"); + var hintBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var row = new Border + { + Background = selected ? hintBackground : Brushes.Transparent, + BorderBrush = borderColor, + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(10, 8, 10, 8), + Cursor = Cursors.Hand, + Margin = new Thickness(0), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var iconBlock = new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = selected ? accentBrush : secondaryText, + Margin = new Thickness(0, 1, 8, 0), + VerticalAlignment = VerticalAlignment.Top, + }; + grid.Children.Add(iconBlock); + + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = title, + FontSize = 12, + FontWeight = selected ? FontWeights.SemiBold : FontWeights.Medium, + Foreground = primaryText, + }); + textStack.Children.Add(new TextBlock + { + Text = description, + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(0, 2, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + Grid.SetColumn(textStack, 1); + grid.Children.Add(textStack); + + if (selected) + { + var check = CreateSimpleCheck(accentBrush, 14); + Grid.SetColumn(check, 2); + grid.Children.Add(check); + } + + row.Child = grid; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBg; + row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent; + row.MouseLeftButtonUp += (_, _) => onClick?.Invoke(); + return row; + } + + private void SwitchToWorkspace(string targetPath, string rootPath) + { + if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath)) + return; + + if (!string.IsNullOrWhiteSpace(rootPath)) + { + var state = WorktreeStateStore.Load(rootPath); + state.Active = targetPath; + WorktreeStateStore.Save(rootPath, state); + } + + SetWorkFolder(targetPath); + ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다."); + } + + private async Task CreateCurrentBranchWorktreeAsync() + { + var currentFolder = GetCurrentWorkFolder(); + if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder)) + return; + + var root = WorktreeStateStore.ResolveRoot(currentFolder); + var gitRoot = ResolveGitRoot(root); + if (!string.IsNullOrWhiteSpace(gitRoot)) + { + await CreateGitWorktreeAsync(gitRoot); + return; + } + + var copied = CreateWorkspaceCopy(root); + SwitchToWorkspace(copied, root); + } + + private async Task CreateGitWorktreeAsync(string gitRoot) + { + var gitPath = FindGitExecutablePath(); + if (string.IsNullOrWhiteSpace(gitPath)) + return; + + var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None); + var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree"; + if (string.IsNullOrWhiteSpace(branchName)) + branchName = "worktree"; + + var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-'); + if (string.IsNullOrWhiteSpace(safeBranch)) + safeBranch = "worktree"; + + var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot; + var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var suffix = DateTime.Now.ToString("MMddHHmm"); + var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}"); + var worktreeBranch = $"ax/{safeBranch}-{suffix}"; + + var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None); + if (addResult.ExitCode != 0) + { + CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + SwitchToWorkspace(worktreePath, gitRoot); + await RefreshGitBranchStatusAsync(); + } + + private string CreateWorkspaceCopy(string root) + { + var parent = Directory.GetParent(root)?.FullName ?? root; + var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}"); + CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true); + return copyPath; + } + + private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata) + { + Directory.CreateDirectory(destination); + + foreach (var file in Directory.GetFiles(source)) + { + var name = Path.GetFileName(file); + if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) + continue; + File.Copy(file, Path.Combine(destination, name), overwrite: true); + } + + foreach (var directory in Directory.GetDirectories(source)) + { + var name = Path.GetFileName(directory); + if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) + continue; + CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata); + } + } + + /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. + private void AppendLogLevelButton() + { + // 구분선 + MoodIconPanel.Children.Add(new Border + { + Width = 1, Height = 18, + Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "simple"; + var levelLabel = currentLevel switch + { + "debug" => "디버그", + "detailed" => "상세", + _ => "간략", + }; + var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669"); + logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); }; + try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } } + MoodIconPanel.Children.Add(logBtn); + } + + /// 실행 이력 상세도 팝업 메뉴를 표시합니다. + private void ShowLogLevelMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + var levels = new (string Key, string Label, string Desc)[] + { + ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), + ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), + ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), + }; + + var current = _settings.Settings.Llm.AgentLogLevel ?? "simple"; + + foreach (var (key, label, desc) in levels) + { + var isActive = current == key; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 13, + Foreground = isActive ? accentBrush : primaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = desc, + FontSize = 10, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + var item = new Border + { + Child = sp, + Padding = new Thickness(12, 8, 12, 8), + CornerRadius = new CornerRadius(6), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + }; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; + item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; + item.MouseLeftButtonUp += (_, _) => + { + _settings.Settings.Llm.AgentLogLevel = key; + _settings.Save(); + FormatMenuPopup.IsOpen = false; + if (_activeTab == "Cowork") BuildBottomBar(); + else if (_activeTab == "Code") BuildCodeBottomBar(); + }; + FormatMenuItems.Children.Add(item); + } + + try + { + var target = FindName("BtnLogLevelMenu") as UIElement; + if (target != null) FormatMenuPopup.PlacementTarget = target; + } + catch { } + FormatMenuPopup.IsOpen = true; + } + + private void ShowLanguageMenu() + { + FormatMenuItems.Children.Clear(); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + var languages = new (string Key, string Label, string Icon)[] + { + ("auto", "자동 감지", "🔧"), + ("python", "Python", "🐍"), + ("java", "Java", "☕"), + ("csharp", "C# (.NET)", "🔷"), + ("cpp", "C/C++", "⚙"), + ("javascript", "JavaScript / Vue", "🌐"), + }; + + foreach (var (key, label, icon) in languages) + { + var isActive = _selectedLanguage == key; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) }); + sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal }); + + var itemBorder = new Border + { + Child = sp, Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, + Padding = new Thickness(8, 7, 12, 7), + }; + ApplyMenuItemHover(itemBorder); + + var capturedKey = key; + itemBorder.MouseLeftButtonUp += (_, _) => + { + FormatMenuPopup.IsOpen = false; + _selectedLanguage = capturedKey; + BuildCodeBottomBar(); + }; + FormatMenuItems.Children.Add(itemBorder); + } + + if (FindName("BtnLangMenu") is UIElement langTarget) + FormatMenuPopup.PlacementTarget = langTarget; + FormatMenuPopup.IsOpen = true; + } + + /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일) + private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB"); + var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + if (mdlIcon != null) + { + sp.Children.Add(new TextBlock + { + Text = mdlIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = iconColor, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + } + + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + var chip = new Border + { + Child = sp, + Background = Brushes.Transparent, + BorderBrush = borderColor, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(10, 5, 10, 5), + Margin = new Thickness(0, 0, 4, 0), + Cursor = Cursors.Hand, + ToolTip = tooltip, + }; + chip.MouseEnter += (_, _) => chip.Background = hoverBackground; + chip.MouseLeave += (_, _) => chip.Background = Brushes.Transparent; + return chip; + } + + + private static string GetFormatLabel(string key) => key switch + { + "xlsx" => "Excel", + "html" => "HTML 보고서", + "docx" => "Word", + "md" => "Markdown", + "csv" => "CSV", + _ => "AI 자동", + }; + + /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다. + private (string Name, string Symbol, string Color) GetAgentIdentity() + { + string? category = null; + lock (_convLock) + { + category = _currentConversation?.Category; + } + + return category switch + { + // Cowork 프리셋 카테고리 + "보고서" => ("보고서 에이전트", "◆", "#3B82F6"), + "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"), + "문서" => ("문서 작성 에이전트", "◆", "#6366F1"), + "논문" => ("논문 분석 에이전트", "◆", "#6366F1"), + "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"), + "자동화" => ("자동화 에이전트", "◆", "#EF4444"), + // Code 프리셋 카테고리 + "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"), + "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"), + "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"), + "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"), + "테스트" => ("테스트 에이전트", "◆", "#F59E0B"), + // Chat 카테고리 + "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"), + "시스템" => ("시스템 에이전트", "◆", "#64748B"), + "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"), + "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"), + "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"), + "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"), + "제조기술" => ("제조기술 에이전트", "◆", "#F97316"), + "재무" => ("재무 분석 에이전트", "◆", "#6366F1"), + _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"), + _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"), + _ => ("AX 에이전트", "◆", "#4B5EFC"), + }; + } + + /// 포맷 선택 팝업 메뉴를 표시합니다. + private void ShowFormatMenu() + { + FormatMenuItems.Children.Clear(); + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto"; + + var formats = new (string Key, string Label, string Icon, string Color)[] + { + ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"), + ("xlsx", "Excel", "\uE9F9", "#217346"), + ("html", "HTML 보고서", "\uE12B", "#E44D26"), + ("docx", "Word", "\uE8A5", "#2B579A"), + ("md", "Markdown", "\uE943", "#6B7280"), + ("csv", "CSV", "\uE9D9", "#10B981"), + }; + + foreach (var (key, label, icon, color) in formats) + { + var isActive = key == currentFormat; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + + // 커스텀 체크 아이콘 + sp.Children.Add(CreateCheckIcon(isActive, accentBrush)); + + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, + Foreground = BrushFromHex(color), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + + var itemBorder = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 7, 12, 7), + }; + ApplyMenuItemHover(itemBorder); + + var capturedKey = key; + itemBorder.MouseLeftButtonUp += (_, _) => + { + FormatMenuPopup.IsOpen = false; + _settings.Settings.Llm.DefaultOutputFormat = capturedKey; + _settings.Save(); + RefreshOverlaySettingsPanel(); + BuildBottomBar(); + }; + + FormatMenuItems.Children.Add(itemBorder); + } + + // PlacementTarget을 동적 등록된 버튼으로 설정 + if (FormatMenuPopup.PlacementTarget == null && FindName("BtnFormatMenu") is UIElement formatTarget) + FormatMenuPopup.PlacementTarget = formatTarget; + FormatMenuPopup.IsOpen = true; + } + + private void BtnOverlayDefaultOutputFormat_Click(object sender, RoutedEventArgs e) + { + if (sender is UIElement element) + FormatMenuPopup.PlacementTarget = element; + ShowFormatMenu(); + } + + /// 디자인 무드 선택 팝업 메뉴를 표시합니다. + private void ShowMoodMenu() + { + MoodMenuItems.Children.Clear(); + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + // 2열 갤러리 그리드 + var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; + + foreach (var mood in TemplateService.AllMoods) + { + var isActive = _selectedMood == mood.Key; + var isCustom = _settings.Settings.Llm.CustomMoods.Any(cm => cm.Key == mood.Key); + var colors = TemplateService.GetMoodColors(mood.Key); + + // 미니 프리뷰 카드 + var previewCard = new Border + { + Width = 160, Height = 80, + CornerRadius = new CornerRadius(6), + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)), + BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)), + BorderThickness = new Thickness(isActive ? 2 : 1), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(2), + }; + + var previewContent = new StackPanel(); + // 헤딩 라인 + previewContent.Children.Add(new Border + { + Width = 60, Height = 6, CornerRadius = new CornerRadius(2), + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.PrimaryText)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 4), + }); + // 악센트 라인 + previewContent.Children.Add(new Border + { + Width = 40, Height = 3, CornerRadius = new CornerRadius(1), + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Accent)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 6), + }); + // 텍스트 라인들 + for (int i = 0; i < 3; i++) + { + previewContent.Children.Add(new Border + { + Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1), + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.SecondaryText)) { Opacity = 0.5 }, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 3), + }); + } + // 미니 카드 영역 + var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) }; + for (int i = 0; i < 2; i++) + { + cardRow.Children.Add(new Border + { + Width = 28, Height = 14, CornerRadius = new CornerRadius(2), + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)), + BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)), + BorderThickness = new Thickness(0.5), + Margin = new Thickness(0, 0, 4, 0), + }); + } + previewContent.Children.Add(cardRow); + previewCard.Child = previewContent; + + // 무드 라벨 + var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) }; + var labelRow = new StackPanel { Orientation = Orientation.Horizontal }; + labelRow.Children.Add(new TextBlock + { + Text = mood.Icon, FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + }); + labelRow.Children.Add(new TextBlock + { + Text = mood.Label, FontSize = 11.5, + Foreground = primaryText, + FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, + VerticalAlignment = VerticalAlignment.Center, + }); + if (isActive) + { + labelRow.Children.Add(new TextBlock + { + Text = " ✓", FontSize = 11, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + } + labelPanel.Children.Add(labelRow); + + // 전체 카드 래퍼 + var cardWrapper = new Border + { + CornerRadius = new CornerRadius(8), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + Padding = new Thickness(4), + Margin = new Thickness(2), + }; + var wrapperContent = new StackPanel(); + wrapperContent.Children.Add(previewCard); + wrapperContent.Children.Add(labelPanel); + cardWrapper.Child = wrapperContent; + + // 호버 + cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; + cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + + var capturedMood = mood; + cardWrapper.MouseLeftButtonUp += (_, _) => + { + MoodMenuPopup.IsOpen = false; + _selectedMood = capturedMood.Key; + _settings.Settings.Llm.DefaultMood = capturedMood.Key; + _settings.Save(); + SaveConversationSettings(); + RefreshOverlaySettingsPanel(); + BuildBottomBar(); + }; + + // 커스텀 무드: 우클릭 + if (isCustom) + { + cardWrapper.MouseRightButtonUp += (s, e) => + { + e.Handled = true; + MoodMenuPopup.IsOpen = false; + ShowCustomMoodContextMenu(s as Border, capturedMood.Key); + }; + } + + grid.Children.Add(cardWrapper); + } + + MoodMenuItems.Children.Add(grid); + + // ── 구분선 + 추가 버튼 ── + MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle + { + Height = 1, + Fill = borderBrush, + Margin = new Thickness(8, 4, 8, 4), + Opacity = 0.4, + }); + + var addSp = new StackPanel { Orientation = Orientation.Horizontal }; + addSp.Children.Add(new TextBlock + { + Text = "\uE710", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 8, 0), + }); + addSp.Children.Add(new TextBlock + { + Text = "커스텀 무드 추가", + FontSize = 13, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var addBorder = new Border + { + Child = addSp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(8, 6, 12, 6), + }; + ApplyMenuItemHover(addBorder); + addBorder.MouseLeftButtonUp += (_, _) => + { + MoodMenuPopup.IsOpen = false; + ShowCustomMoodDialog(); + }; + MoodMenuItems.Children.Add(addBorder); + + if (MoodMenuPopup.PlacementTarget == null && FindName("BtnMoodMenu") is UIElement moodTarget) + MoodMenuPopup.PlacementTarget = moodTarget; + MoodMenuPopup.IsOpen = true; + } + + private void BtnOverlayDefaultMood_Click(object sender, RoutedEventArgs e) + { + if (sender is UIElement element) + MoodMenuPopup.PlacementTarget = element; + ShowMoodMenu(); + } + + /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. + private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null) + { + bool isEdit = existing != null; + var dlg = new CustomMoodDialog( + existingKey: existing?.Key ?? "", + existingLabel: existing?.Label ?? "", + existingIcon: existing?.Icon ?? "🎯", + existingDesc: existing?.Description ?? "", + existingCss: existing?.Css ?? "") + { + Owner = this, + }; + + if (dlg.ShowDialog() == true) + { + if (isEdit) + { + existing!.Label = dlg.MoodLabel; + existing.Icon = dlg.MoodIcon; + existing.Description = dlg.MoodDescription; + existing.Css = dlg.MoodCss; + } + else + { + _settings.Settings.Llm.CustomMoods.Add(new Models.CustomMoodEntry + { + Key = dlg.MoodKey, + Label = dlg.MoodLabel, + Icon = dlg.MoodIcon, + Description = dlg.MoodDescription, + Css = dlg.MoodCss, + }); + } + _settings.Save(); + TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); + BuildBottomBar(); + } + } + + /// 커스텀 무드 우클릭 컨텍스트 메뉴. + private void ShowCustomMoodContextMenu(Border? anchor, string moodKey) + { + if (anchor == null) return; + + var popup = new System.Windows.Controls.Primitives.Popup + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Right, + StaysOpen = false, AllowsTransparency = true, + }; + + var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var menuBorder = new Border + { + Background = menuBg, + CornerRadius = new CornerRadius(10), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(4), + MinWidth = 120, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, + }, + }; + + var stack = new StackPanel(); + + var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText); + editItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var entry = _settings.Settings.Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey); + if (entry != null) ShowCustomMoodDialog(entry); + }; + stack.Children.Add(editItem); + + var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText); + deleteItem.MouseLeftButtonDown += (_, _) => + { + popup.IsOpen = false; + var result = CustomMessageBox.Show( + $"이 디자인 무드를 삭제하시겠습니까?", + "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + _settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); + if (_selectedMood == moodKey) _selectedMood = "modern"; + _settings.Save(); + TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); + BuildBottomBar(); + } + }; + stack.Children.Add(deleteItem); + + menuBorder.Child = stack; + popup.Child = menuBorder; + popup.IsOpen = true; + } + + + private string? _promptCardPlaceholder; + + private void ShowPlaceholder() + { + if (string.IsNullOrEmpty(_promptCardPlaceholder)) return; + InputWatermark.Text = _promptCardPlaceholder; + InputWatermark.Visibility = Visibility.Visible; + InputBox.Text = ""; + InputBox.Focus(); + } + + private void UpdateWatermarkVisibility() + { + // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) + if (_slashPalette.ActiveCommand != null) + { + InputWatermark.Visibility = Visibility.Collapsed; + return; + } + + if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text)) + InputWatermark.Visibility = Visibility.Visible; + else + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void ClearPromptCardPlaceholder() + { + _promptCardPlaceholder = null; + InputWatermark.Visibility = Visibility.Collapsed; + } + + private void BtnSettings_Click(object sender, RoutedEventArgs e) + { + OpenAgentSettingsWindow(); + } + + // ─── 프롬프트 템플릿 팝업 ──────────────────────────────────────────── + + private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e) + { + var templates = _settings.Settings.Llm.PromptTemplates; + TemplateItems.Items.Clear(); + var search = TemplateSearchBox?.Text?.Trim() ?? ""; + + if (templates == null || templates.Count == 0) + { + TemplateEmptyHint.Visibility = Visibility.Visible; + TemplatePopup.IsOpen = true; + return; + } + + if (!string.IsNullOrWhiteSpace(search)) + { + templates = templates + .Where(t => t.Name.Contains(search, StringComparison.OrdinalIgnoreCase) + || t.Content.Contains(search, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + TemplateEmptyHint.Visibility = templates.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + if (templates.Count == 0) + { + TemplateEmptyHint.Text = "검색 결과가 없습니다."; + TemplatePopup.IsOpen = true; + return; + } + TemplateEmptyHint.Text = "등록된 템플릿이 없습니다. 설정에서 추가하세요."; + + var favoriteSet = new HashSet(_settings.Settings.Llm.FavoritePromptTemplates ?? [], StringComparer.OrdinalIgnoreCase); + var recentList = _settings.Settings.Llm.RecentPromptTemplates ?? []; + var recentRank = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < recentList.Count; i++) + { + var key = recentList[i]?.Trim(); + if (!string.IsNullOrWhiteSpace(key) && !recentRank.ContainsKey(key)) + recentRank[key] = i; + } + + void AddSectionHeader(string title) + { + TemplateItems.Items.Add(new Border + { + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(10, 8, 10, 6), + Child = new TextBlock + { + Text = title, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + } + }); + } + + void AddTemplateRow(PromptTemplate tpl, bool isFavorite, bool isRecent) + { + var item = new Border + { + Background = Brushes.Transparent, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(10, 10, 10, 10), + Margin = new Thickness(0, 0, 0, 0), + Cursor = System.Windows.Input.Cursors.Hand, + Tag = tpl.Content, + }; + + var stack = new StackPanel(); + var titleRow = new StackPanel { Orientation = Orientation.Horizontal }; + titleRow.Children.Add(new TextBlock + { + Text = tpl.Name, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("PrimaryText"), + }); + if (isFavorite) + { + titleRow.Children.Add(new TextBlock + { + Text = " 고정", + FontSize = 10, + Foreground = BrushFromHex("#B45309"), + }); + } + else if (isRecent) + { + titleRow.Children.Add(new TextBlock + { + Text = " 최근", + FontSize = 10, + Foreground = BrushFromHex("#2563EB"), + }); + } + stack.Children.Add(titleRow); + var preview = tpl.Content.Length > 60 ? tpl.Content[..60] + "…" : tpl.Content; + stack.Children.Add(new TextBlock + { + Text = preview, + FontSize = 10.5, + Foreground = (Brush)FindResource("SecondaryText"), + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 2, 0, 0), + }); + item.Child = stack; + + item.MouseEnter += (s, _) => + { + if (s is Border b) b.Background = (Brush)FindResource("ItemHoverBackground"); + }; + item.MouseLeave += (s, _) => + { + if (s is Border b) b.Background = Brushes.Transparent; + }; + item.MouseLeftButtonUp += (s, _) => + { + if (s is Border b && b.Tag is string content) + { + RegisterRecentPromptTemplate(tpl.Name); + InputBox.Text = content; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + TemplatePopup.IsOpen = false; + } + }; + + item.MouseRightButtonUp += (_, e) => + { + e.Handled = true; + ToggleFavoritePromptTemplate(tpl.Name); + BtnTemplateSelector_Click(this, new RoutedEventArgs()); + }; + + TemplateItems.Items.Add(item); + } + + var favorites = templates.Where(t => favoriteSet.Contains(t.Name)).ToList(); + var recents = templates.Where(t => !favoriteSet.Contains(t.Name) && recentRank.ContainsKey(t.Name)) + .OrderBy(t => recentRank[t.Name]) + .ToList(); + var remaining = templates.Where(t => !favoriteSet.Contains(t.Name) && !recentRank.ContainsKey(t.Name)) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (favorites.Count > 0) + { + AddSectionHeader("고정"); + foreach (var tpl in favorites) + AddTemplateRow(tpl, true, false); + } + if (recents.Count > 0) + { + AddSectionHeader("최근"); + foreach (var tpl in recents) + AddTemplateRow(tpl, false, true); + } + if (remaining.Count > 0) + { + AddSectionHeader("전체"); + foreach (var tpl in remaining) + AddTemplateRow(tpl, false, false); + } + + TemplatePopup.IsOpen = true; + } + + private void TemplateSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (TemplatePopup?.IsOpen == true) + BtnTemplateSelector_Click(this, new RoutedEventArgs()); + } + + private void RegisterRecentPromptTemplate(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return; + + var recent = _settings.Settings.Llm.RecentPromptTemplates ??= new List(); + recent.RemoveAll(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase)); + recent.Insert(0, name); + if (recent.Count > 10) + recent.RemoveRange(10, recent.Count - 10); + _settings.Save(); + } + + private void ToggleFavoritePromptTemplate(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return; + + var favorites = _settings.Settings.Llm.FavoritePromptTemplates ??= new List(); + var existing = favorites.FirstOrDefault(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + favorites.Remove(existing); + else + favorites.Add(name); + _settings.Save(); + } + + // ─── 모델 전환 ────────────────────────────────────────────────────── + + // Gemini/Claude 사전 정의 모델 목록 + private static readonly (string Id, string Label)[] GeminiModels = + { + ("gemini-2.5-pro", "Gemini 2.5 Pro"), + ("gemini-2.5-flash", "Gemini 2.5 Flash"), + ("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), + ("gemini-2.0-flash", "Gemini 2.0 Flash"), + ("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"), + }; + private static readonly (string Id, string Label)[] ClaudeModels = + { + (string.Concat("cl", "aude-opus-4-6"), "Claude Opus 4.6"), + (string.Concat("cl", "aude-sonnet-4-6"), "Claude Sonnet 4.6"), + (string.Concat("cl", "aude-haiku-4-5-20251001"), "Claude Haiku 4.5"), + (string.Concat("cl", "aude-sonnet-4-5-20250929"), "Claude Sonnet 4.5"), + (string.Concat("cl", "aude-opus-4-20250514"), "Claude Opus 4"), + }; + + /// 현재 선택된 모델의 표시명을 반환합니다. + private string GetCurrentModelDisplayName() + { + var llm = _settings.Settings.Llm; + var service = llm.Service.ToLowerInvariant(); + + if (service is "ollama" or "vllm") + { + // 등록 모델에서 별칭 찾기 + var registered = llm.RegisteredModels + .FirstOrDefault(rm => rm.EncryptedModelName == llm.Model); + if (registered != null) return registered.Alias; + return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : "••••"; + } + + if (service == "gemini") + { + var m = GeminiModels.FirstOrDefault(g => g.Id == llm.Model); + return m.Label ?? llm.Model; + } + if (service is "sigmoid" or "cl" + "aude") + { + var m = ClaudeModels.FirstOrDefault(c => c.Id == llm.Model); + return m.Label ?? llm.Model; + } + return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : llm.Model; + } + + private void UpdateModelLabel() + { + var service = _settings.Settings.Llm.Service.ToLowerInvariant(); + var serviceLabel = service switch + { + "gemini" => "Gemini", + "sigmoid" or "cl" + "aude" => "Claude", + "vllm" => "vLLM", + _ => "Ollama", + }; + var model = GetCurrentModelDisplayName(); + const int maxLen = 22; + if (model.Length > maxLen) + model = model[..(maxLen - 1)] + "…"; + ModelLabel.Text = $"서비스 {serviceLabel} · {model}"; + if (BtnModelSelector != null) + BtnModelSelector.ToolTip = $"현재 서비스: {serviceLabel}\n현재 모델: {GetCurrentModelDisplayName()}\n모델/추론 빠른 설정"; + } + + private static string NextPlanMode(string current) => (current ?? "off").ToLowerInvariant() switch + { + "off" => "auto", + "auto" => "always", + _ => "off", + }; + private static string PlanModeLabel(string value) => (value ?? "off").ToLowerInvariant() switch + { + "always" => "항상 계획", + "auto" => "자동 계획", + _ => "끄기", + }; + private static string NextReasoning(string current) => (current ?? "normal").ToLowerInvariant() switch + { + "minimal" => "normal", + "normal" => "detailed", + _ => "minimal", + }; + private static string ReasoningLabel(string value) => (value ?? "normal").ToLowerInvariant() switch + { + "minimal" => "낮음", + "detailed" => "높음", + _ => "중간", + }; + private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch + { + "deny" => "Default", + "default" => "AcceptEdits", + "acceptedits" => "Plan", + "plan" => "BypassPermissions", + "bypasspermissions" => "Default", + _ => "Default", + }; + private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch + { + "gemini" => "Gemini", + "sigmoid" or "cl" + "aude" => "Claude", + "vllm" => "vLLM", + _ => "Ollama", + }; + + private List<(string Id, string Label)> GetModelCandidates(string service) + { + var llm = _settings.Settings.Llm; + var normalized = (service ?? "ollama").ToLowerInvariant(); + + if (normalized is "ollama" or "vllm") + { + return llm.RegisteredModels + .Where(rm => string.Equals(rm.Service, normalized, StringComparison.OrdinalIgnoreCase)) + .Select(rm => (rm.EncryptedModelName, rm.Alias)) + .ToList(); + } + + if (normalized == "gemini") + return GeminiModels.Select(m => (m.Id, m.Label)).ToList(); + if (normalized is "sigmoid" or "cl" + "aude") + return ClaudeModels.Select(m => (m.Id, m.Label)).ToList(); + + return []; + } + + private static bool SupportsOverlayRegisteredModels(string service) + => string.Equals(service, "ollama", StringComparison.OrdinalIgnoreCase) + || string.Equals(service, "vllm", StringComparison.OrdinalIgnoreCase); + + private void RefreshInlineSettingsPanel() + { + if (InlineSettingsPanel == null) return; + + var llm = _settings.Settings.Llm; + var service = (llm.Service ?? "ollama").ToLowerInvariant(); + var models = GetModelCandidates(service); + + _isInlineSettingsSyncing = true; + try + { + if (InlineServiceCardPanel != null) + InlineServiceCardPanel.Children.Clear(); + if (InlineModelListPanel != null) + InlineModelListPanel.Children.Clear(); + + CmbInlineService.Items.Clear(); + foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) + { + CmbInlineService.Items.Add(new ComboBoxItem + { + Content = ServiceLabel(svc), + Tag = svc, + }); + } + var normalizedService = service == "sigmoid" ? string.Concat("cl", "aude") : service; + CmbInlineService.SelectedIndex = Math.Max(0, new[] { "ollama", "vllm", "gemini", "claude" }.ToList().IndexOf(normalizedService)); + + BuildInlineServiceCards(normalizedService); + + CmbInlineModel.Items.Clear(); + InlineModelChipPanel.Children.Clear(); + if (models.Count == 0) + { + CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false }); + CmbInlineModel.SelectedIndex = 0; + if (InlineModelChipPanel != null) + InlineModelChipPanel.Visibility = Visibility.Collapsed; + } + else + { + foreach (var (id, label) in models) + { + CmbInlineModel.Items.Add(new ComboBoxItem + { + Content = label, + Tag = id, + }); + } + + var selectedIndex = models.FindIndex(m => m.Id == llm.Model); + CmbInlineModel.SelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; + BuildInlineModelRows(models, llm.Model); + + if (InlineModelChipPanel != null) + { + InlineModelChipPanel.Visibility = Visibility.Visible; + foreach (var (id, label) in models.Take(6)) + { + var isActive = string.Equals(id, llm.Model, StringComparison.OrdinalIgnoreCase); + var chip = new Border + { + Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(10, 5, 10, 5), + Margin = new Thickness(0, 0, 6, 6), + Cursor = Cursors.Hand, + }; + var text = new TextBlock + { + Text = label, + FontSize = 10.5, + Foreground = isActive + ? BrushFromHex("#1D4ED8") + : (TryFindResource("PrimaryText") as Brush ?? Brushes.White), + }; + chip.Child = text; + var capturedId = id; + var capturedLabel = label; + chip.MouseEnter += (_, _) => + { + if (!isActive) + chip.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + }; + chip.MouseLeave += (_, _) => + { + if (!isActive) + chip.Background = Brushes.Transparent; + }; + chip.MouseLeftButtonUp += (_, _) => + { + llm.Model = capturedId; + _settings.Save(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + SetStatus($"모델 전환: {capturedLabel}", spinning: false); + }; + InlineModelChipPanel.Children.Add(chip); + } + } + } + + BtnInlineFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐"); + BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel)); + BtnInlinePlanMode.Content = GetQuickActionLabel("계획", PlanModeLabel(llm.PlanMode)); + BtnInlinePermission.Content = GetQuickActionLabel("권한", PermissionModeCatalog.ToDisplayLabel(llm.FilePermission)); + BtnInlineSkill.Content = $"스킬 · {(llm.EnableSkillSystem ? "On" : "Off")}"; + BtnInlineCommandBrowser.Content = "명령/스킬 브라우저"; + + var mcpTotal = llm.McpServers?.Count ?? 0; + var mcpEnabled = llm.McpServers?.Count(x => x.Enabled) ?? 0; + BtnInlineMcp.Content = $"MCP 상태 · {mcpEnabled}/{mcpTotal}"; + + ApplyQuickActionVisual(BtnInlineFastMode, llm.FreeTierMode, "#ECFDF5", "#166534"); + ApplyQuickActionVisual(BtnInlineReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8"); + ApplyQuickActionVisual(BtnInlinePlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA"); + ApplyQuickActionVisual(BtnInlinePermission, + !string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase), + "#FFF7ED", + "#C2410C"); + } + finally + { + _isInlineSettingsSyncing = false; + } + } + + private void BuildInlineServiceCards(string selectedService) + { + if (InlineServiceCardPanel == null) + return; + + foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) + { + var isActive = string.Equals(svc, selectedService, StringComparison.OrdinalIgnoreCase); + var card = new Border + { + Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(0, 0, 6, 6), + Cursor = Cursors.Hand, + }; + var text = new TextBlock + { + Text = ServiceLabel(svc), + FontSize = 10.5, + Foreground = isActive ? BrushFromHex("#1D4ED8") : (TryFindResource("PrimaryText") as Brush ?? Brushes.White), + }; + card.Child = text; + var capturedService = svc; + card.MouseEnter += (_, _) => + { + if (!isActive) + card.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + }; + card.MouseLeave += (_, _) => + { + if (!isActive) + card.Background = Brushes.Transparent; + }; + card.MouseLeftButtonUp += (_, _) => + { + if (_isInlineSettingsSyncing) + return; + _settings.Settings.Llm.Service = capturedService; + _settings.Save(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + }; + InlineServiceCardPanel.Children.Add(card); + } + } + + private void BuildInlineModelRows(List<(string Id, string Label)> models, string? selectedModel) + { + if (InlineModelListPanel == null) + return; + + foreach (var (id, label) in models.Take(8)) + { + var isActive = string.Equals(id, selectedModel, StringComparison.OrdinalIgnoreCase); + var row = new Border + { + Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1), + Padding = new Thickness(8, 8, 8, 8), + Cursor = Cursors.Hand, + }; + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var labelText = new TextBlock + { + Text = label, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + }; + grid.Children.Add(labelText); + var stateText = new TextBlock + { + Text = isActive ? "사용 중" : "선택", + FontSize = 10, + Foreground = isActive ? BrushFromHex("#2563EB") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(stateText, 1); + grid.Children.Add(stateText); + row.Child = grid; + var capturedId = id; + var capturedLabel = label; + row.MouseEnter += (_, _) => + { + row.Background = BrushFromHex("#F8FAFC"); + row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0"); + }; + row.MouseLeave += (_, _) => + { + row.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent; + row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"); + }; + row.MouseLeftButtonUp += (_, _) => + { + if (_isInlineSettingsSyncing) + return; + _settings.Settings.Llm.Model = capturedId; + _settings.Save(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + SetStatus($"모델 전환: {capturedLabel}", spinning: false); + }; + InlineModelListPanel.Children.Add(row); + } + } + + private void BtnModelSelector_Click(object sender, RoutedEventArgs e) + { + RefreshInlineSettingsPanel(); + InlineSettingsPanel.IsOpen = !InlineSettingsPanel.IsOpen; + + if (InlineSettingsPanel.IsOpen) + { + Dispatcher.BeginInvoke(() => + { + if (CmbInlineModel.Items.Count > 0) + CmbInlineModel.Focus(); + else + InputBox.Focus(); + }, DispatcherPriority.Input); + } + } + + private void OpenAgentSettingsWindow() + { + RefreshOverlaySettingsPanel(); + AgentSettingsOverlay.Visibility = Visibility.Visible; + InlineSettingsPanel.IsOpen = false; + SetOverlaySection("basic"); + Dispatcher.BeginInvoke(() => + { + if (OverlayNavBasic != null) + OverlayNavBasic.Focus(); + else + InputBox.Focus(); + }, DispatcherPriority.Input); + } + + public void OpenAgentSettingsFromExternal() + { + Dispatcher.BeginInvoke(() => + { + Show(); + Activate(); + OpenAgentSettingsWindow(); + }, DispatcherPriority.Input); + } + + public void RefreshFromSavedSettings() + { + Dispatcher.BeginInvoke(() => + { + ApplyAgentThemeResources(); + LoadConversationSettings(); + UpdatePermissionUI(); + UpdateDataUsageUI(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + RefreshOverlaySettingsPanel(); + RefreshContextUsageVisual(); + UpdateTabUI(); + BuildBottomBar(); + RefreshDraftQueueUi(); + RefreshConversationList(); + }, DispatcherPriority.Input); + } + + private void BtnOverlaySettingsClose_Click(object sender, RoutedEventArgs e) + { + ApplyOverlaySettingsChanges(showToast: false, closeOverlay: true); + } + + private void ApplyOverlaySettingsChanges(bool showToast, bool closeOverlay) + { + var llm = _settings.Settings.Llm; + + _settings.Settings.AiEnabled = true; + llm.EnableProactiveContextCompact = ChkOverlayEnableProactiveCompact?.IsChecked == true; + llm.EnableSkillSystem = ChkOverlayEnableSkillSystem?.IsChecked == true; + llm.EnableToolHooks = ChkOverlayEnableToolHooks?.IsChecked == true; + llm.EnableHookInputMutation = ChkOverlayEnableHookInputMutation?.IsChecked == true; + llm.EnableHookPermissionUpdate = ChkOverlayEnableHookPermissionUpdate?.IsChecked == true; + llm.EnableCoworkVerification = ChkOverlayEnableCoworkVerification?.IsChecked == true; + llm.Code.EnableCodeVerification = ChkOverlayEnableCodeVerification?.IsChecked == true; + llm.Code.EnableCodeReview = ChkOverlayEnableCodeReview?.IsChecked == true; + llm.EnableImageInput = ChkOverlayEnableImageInput?.IsChecked == true; + llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true; + llm.EnableProjectRules = ChkOverlayEnableProjectRules?.IsChecked == true; + llm.EnableAgentMemory = ChkOverlayEnableAgentMemory?.IsChecked == true; + llm.Code.EnablePlanModeTools = ChkOverlayEnablePlanModeTools?.IsChecked == true; + llm.Code.EnableWorktreeTools = ChkOverlayEnableWorktreeTools?.IsChecked == true; + llm.Code.EnableTeamTools = ChkOverlayEnableTeamTools?.IsChecked == true; + llm.Code.EnableCronTools = ChkOverlayEnableCronTools?.IsChecked == true; + llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer?.IsChecked == true; + llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats?.IsChecked == true; + llm.EnableAuditLog = ChkOverlayEnableAuditLog?.IsChecked == true; + llm.FolderDataUsage = _folderDataUsage; + + CommitOverlayEndpointInput(normalizeOnInvalid: true); + CommitOverlayApiKeyInput(); + CommitOverlayModelInput(normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, llm.ContextCompactTriggerPercent, 10, 95, value => llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxContextTokens, llm.MaxContextTokens, 1024, 1_000_000, value => llm.MaxContextTokens = value, normalizeOnInvalid: true); + CommitOverlayTemperatureInput(normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, llm.MaxAgentIterations, 1, 200, value => llm.MaxAgentIterations = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, llm.FreeTierDelaySeconds, 0, 60, value => llm.FreeTierDelaySeconds = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxSubAgents, llm.MaxSubAgents, 1, 10, value => llm.MaxSubAgents = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, llm.ToolHookTimeoutMs, 3000, 30000, value => llm.ToolHookTimeoutMs = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, llm.MaxFavoriteSlashCommands, 1, 30, value => llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, llm.MaxRecentSlashCommands, 5, 50, value => llm.MaxRecentSlashCommands = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, llm.PlanDiffSeverityMediumCount, 1, 999, value => llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, llm.PlanDiffSeverityHighCount, 1, 999, value => llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: true); + CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: true); + if (TxtOverlayPdfExportPath != null) + llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim(); + + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + if (closeOverlay) + AgentSettingsOverlay.Visibility = Visibility.Collapsed; + if (showToast) + ShowToast("AX Agent 설정이 저장되었습니다."); + InputBox.Focus(); + } + + private void PersistOverlaySettingsState(bool refreshOverlayDeferredInputs) + { + _settings.Save(); + _appState.LoadFromSettings(_settings); + ApplyAgentThemeResources(); + UpdatePermissionUI(); + UpdateDataUsageUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + UpdateModelLabel(); + UpdateTabUI(); + RefreshOverlayVisualState(refreshOverlayDeferredInputs); + } + + private void RefreshOverlayVisualState(bool loadDeferredInputs) + { + var llm = _settings.Settings.Llm; + var service = NormalizeOverlayService(llm.Service); + var models = GetModelCandidates(service); + + _isOverlaySettingsSyncing = true; + try + { + if (CmbOverlayService != null) + { + CmbOverlayService.Items.Clear(); + foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" }) + { + CmbOverlayService.Items.Add(new ComboBoxItem + { + Content = ServiceLabel(svc), + Tag = svc + }); + } + + CmbOverlayService.SelectedItem = CmbOverlayService.Items + .OfType() + .FirstOrDefault(i => string.Equals(i.Tag as string, service, StringComparison.OrdinalIgnoreCase)); + } + + if (CmbOverlayModel != null) + { + CmbOverlayModel.Items.Clear(); + foreach (var model in models) + { + CmbOverlayModel.Items.Add(new ComboBoxItem + { + Content = model.Label, + Tag = model.Id + }); + } + + CmbOverlayModel.SelectedItem = CmbOverlayModel.Items + .OfType() + .FirstOrDefault(i => string.Equals(i.Tag as string, llm.Model, StringComparison.OrdinalIgnoreCase)); + + if (CmbOverlayModel.SelectedItem == null && CmbOverlayModel.Items.Count > 0) + CmbOverlayModel.SelectedIndex = 0; + } + + if (OverlaySelectedServiceText != null) + OverlaySelectedServiceText.Text = ServiceLabel(service); + if (OverlaySelectedModelText != null) + OverlaySelectedModelText.Text = string.IsNullOrWhiteSpace(llm.Model) + ? "미선택" + : (models.FirstOrDefault(m => string.Equals(m.Id, llm.Model, StringComparison.OrdinalIgnoreCase)).Label ?? llm.Model); + + PopulateOverlayMoodCombo(); + + if (loadDeferredInputs) + { + if (ChkOverlayAiEnabled != null) + ChkOverlayAiEnabled.IsChecked = true; + if (TxtOverlayServiceEndpoint != null) + TxtOverlayServiceEndpoint.Text = GetOverlayServiceEndpoint(service); + if (TxtOverlayServiceApiKey != null) + TxtOverlayServiceApiKey.Password = GetOverlayServiceApiKey(service); + if (TxtOverlayContextCompactTriggerPercent != null) + TxtOverlayContextCompactTriggerPercent.Text = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95).ToString(); + if (TxtOverlayMaxContextTokens != null) + TxtOverlayMaxContextTokens.Text = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000).ToString(); + if (TxtOverlayTemperature != null) + TxtOverlayTemperature.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); + if (SldOverlayTemperature != null) + SldOverlayTemperature.Value = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1); + if (TxtOverlayTemperatureValue != null) + TxtOverlayTemperatureValue.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); + if (TxtOverlayMaxRetryOnError != null) + TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString(); + if (SldOverlayMaxRetryOnError != null) + SldOverlayMaxRetryOnError.Value = Math.Clamp(llm.MaxRetryOnError, 0, 10); + if (TxtOverlayMaxRetryOnErrorValue != null) + TxtOverlayMaxRetryOnErrorValue.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString(); + if (TxtOverlayMaxAgentIterations != null) + TxtOverlayMaxAgentIterations.Text = Math.Clamp(llm.MaxAgentIterations, 1, 200).ToString(); + if (SldOverlayMaxAgentIterations != null) + SldOverlayMaxAgentIterations.Value = Math.Clamp(llm.MaxAgentIterations, 1, 100); + if (TxtOverlayMaxAgentIterationsValue != null) + TxtOverlayMaxAgentIterationsValue.Text = Math.Clamp(llm.MaxAgentIterations, 1, 100).ToString(); + if (TxtOverlayFreeTierDelaySeconds != null) + TxtOverlayFreeTierDelaySeconds.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString(); + if (SldOverlayFreeTierDelaySeconds != null) + SldOverlayFreeTierDelaySeconds.Value = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60); + if (TxtOverlayFreeTierDelaySecondsValue != null) + TxtOverlayFreeTierDelaySecondsValue.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString(); + if (TxtOverlayMaxSubAgents != null) + TxtOverlayMaxSubAgents.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString(); + if (SldOverlayMaxSubAgents != null) + SldOverlayMaxSubAgents.Value = Math.Clamp(llm.MaxSubAgents, 1, 10); + if (TxtOverlayMaxSubAgentsValue != null) + TxtOverlayMaxSubAgentsValue.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString(); + if (TxtOverlayToolHookTimeoutMs != null) + TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString(); + if (SldOverlayToolHookTimeoutMs != null) + SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000); + if (TxtOverlayToolHookTimeoutMsValue != null) + TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s"; + if (TxtOverlayMaxFavoriteSlashCommands != null) + TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); + if (SldOverlayMaxFavoriteSlashCommands != null) + SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30); + if (TxtOverlayMaxFavoriteSlashCommandsValue != null) + TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); + if (TxtOverlayMaxRecentSlashCommands != null) + TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); + if (SldOverlayMaxRecentSlashCommands != null) + SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50); + if (TxtOverlayMaxRecentSlashCommandsValue != null) + TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); + if (TxtOverlayPlanDiffMediumCount != null) + TxtOverlayPlanDiffMediumCount.Text = Math.Clamp(llm.PlanDiffSeverityMediumCount, 1, 999).ToString(); + if (TxtOverlayPlanDiffHighCount != null) + TxtOverlayPlanDiffHighCount.Text = Math.Clamp(llm.PlanDiffSeverityHighCount, 1, 999).ToString(); + if (TxtOverlayPlanDiffMediumRatio != null) + TxtOverlayPlanDiffMediumRatio.Text = Math.Clamp(llm.PlanDiffSeverityMediumRatioPercent, 1, 100).ToString(); + if (TxtOverlayPlanDiffHighRatio != null) + TxtOverlayPlanDiffHighRatio.Text = Math.Clamp(llm.PlanDiffSeverityHighRatioPercent, 1, 100).ToString(); + if (TxtOverlayPdfExportPath != null) + TxtOverlayPdfExportPath.Text = llm.PdfExportPath ?? ""; + if (ChkOverlayEnableProactiveCompact != null) + ChkOverlayEnableProactiveCompact.IsChecked = llm.EnableProactiveContextCompact; + if (ChkOverlayEnableSkillSystem != null) + ChkOverlayEnableSkillSystem.IsChecked = llm.EnableSkillSystem; + if (ChkOverlayEnableToolHooks != null) + ChkOverlayEnableToolHooks.IsChecked = llm.EnableToolHooks; + if (ChkOverlayEnableHookInputMutation != null) + ChkOverlayEnableHookInputMutation.IsChecked = llm.EnableHookInputMutation; + if (ChkOverlayEnableHookPermissionUpdate != null) + ChkOverlayEnableHookPermissionUpdate.IsChecked = llm.EnableHookPermissionUpdate; + if (ChkOverlayEnableCoworkVerification != null) + ChkOverlayEnableCoworkVerification.IsChecked = llm.EnableCoworkVerification; + if (ChkOverlayEnableCodeVerification != null) + ChkOverlayEnableCodeVerification.IsChecked = llm.Code.EnableCodeVerification; + if (ChkOverlayEnableCodeReview != null) + ChkOverlayEnableCodeReview.IsChecked = llm.Code.EnableCodeReview; + if (ChkOverlayEnableImageInput != null) + ChkOverlayEnableImageInput.IsChecked = llm.EnableImageInput; + if (ChkOverlayEnableParallelTools != null) + ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools; + if (ChkOverlayEnableProjectRules != null) + ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules; + if (ChkOverlayEnableAgentMemory != null) + ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory; + if (ChkOverlayEnablePlanModeTools != null) + ChkOverlayEnablePlanModeTools.IsChecked = llm.Code.EnablePlanModeTools; + if (ChkOverlayEnableWorktreeTools != null) + ChkOverlayEnableWorktreeTools.IsChecked = llm.Code.EnableWorktreeTools; + if (ChkOverlayEnableTeamTools != null) + ChkOverlayEnableTeamTools.IsChecked = llm.Code.EnableTeamTools; + if (ChkOverlayEnableCronTools != null) + ChkOverlayEnableCronTools.IsChecked = llm.Code.EnableCronTools; + if (ChkOverlayWorkflowVisualizer != null) + ChkOverlayWorkflowVisualizer.IsChecked = llm.WorkflowVisualizer; + if (ChkOverlayShowTotalCallStats != null) + ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats; + if (ChkOverlayEnableAuditLog != null) + ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog; + } + + RefreshOverlayThemeCards(); + RefreshOverlayServiceCards(); + RefreshOverlayModeButtons(); + RefreshOverlayTokenPresetCards(); + RefreshOverlayServiceFieldLabels(service); + BuildOverlayModelChips(service); + BuildOverlayRegisteredModelsPanel(service); + RefreshOverlayAdvancedChoiceButtons(); + } + finally + { + _isOverlaySettingsSyncing = false; + } + } + + private static string NormalizeOverlayService(string? service) + => string.Equals(service, "sigmoid", StringComparison.OrdinalIgnoreCase) ? "claude" : (service ?? "ollama").Trim().ToLowerInvariant(); + + private void CommitOverlayEndpointInput(bool normalizeOnInvalid) + { + var service = NormalizeOverlayService(_settings.Settings.Llm.Service); + var endpoint = TxtOverlayServiceEndpoint?.Text.Trim() ?? ""; + ClearOverlayValidation(TxtOverlayServiceEndpoint); + switch (service) + { + case "ollama": + _settings.Settings.Llm.OllamaEndpoint = endpoint; + _settings.Settings.Llm.Endpoint = endpoint; + break; + case "vllm": + _settings.Settings.Llm.VllmEndpoint = endpoint; + _settings.Settings.Llm.Endpoint = endpoint; + break; + default: + _settings.Settings.Llm.Endpoint = endpoint; + break; + } + + if (normalizeOnInvalid && TxtOverlayServiceEndpoint != null) + TxtOverlayServiceEndpoint.Text = endpoint; + } + + private void CommitOverlayApiKeyInput() + { + var service = NormalizeOverlayService(_settings.Settings.Llm.Service); + var apiKey = TxtOverlayServiceApiKey?.Password ?? ""; + switch (service) + { + case "ollama": + _settings.Settings.Llm.OllamaApiKey = apiKey; + _settings.Settings.Llm.ApiKey = apiKey; + break; + case "vllm": + _settings.Settings.Llm.VllmApiKey = apiKey; + _settings.Settings.Llm.ApiKey = apiKey; + break; + case "gemini": + _settings.Settings.Llm.GeminiApiKey = apiKey; + _settings.Settings.Llm.ApiKey = apiKey; + break; + default: + _settings.Settings.Llm.ClaudeApiKey = apiKey; + _settings.Settings.Llm.ApiKey = apiKey; + break; + } + } + + private void CommitOverlayModelInput(bool normalizeOnInvalid) + { + if (TxtOverlayModelInput == null || TxtOverlayModelInput.Visibility != Visibility.Visible) + return; + + var value = TxtOverlayModelInput?.Text.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(value)) + { + MarkOverlayValidation(TxtOverlayModelInput, "모델명을 입력하세요."); + if (normalizeOnInvalid && TxtOverlayModelInput != null) + { + TxtOverlayModelInput.Text = _settings.Settings.Llm.Model ?? ""; + ClearOverlayValidation(TxtOverlayModelInput); + } + + return; + } + + ClearOverlayValidation(TxtOverlayModelInput); + _settings.Settings.Llm.Model = value; + var service = NormalizeOverlayService(_settings.Settings.Llm.Service); + switch (service) + { + case "ollama": + _settings.Settings.Llm.OllamaModel = value; + break; + case "vllm": + _settings.Settings.Llm.VllmModel = value; + break; + case "gemini": + _settings.Settings.Llm.GeminiModel = value; + break; + default: + _settings.Settings.Llm.ClaudeModel = value; + break; + } + } + + private bool CommitOverlayNumericInput(TextBox? textBox, int currentValue, int min, int max, Action applyValue, bool normalizeOnInvalid) + { + if (textBox == null) + return false; + + if (!int.TryParse(textBox.Text?.Trim(), out var parsed)) + { + MarkOverlayValidation(textBox, $"{min}~{max} 사이 숫자를 입력하세요."); + if (normalizeOnInvalid) + { + textBox.Text = Math.Clamp(currentValue, min, max).ToString(); + ClearOverlayValidation(textBox); + } + + return false; + } + + parsed = Math.Clamp(parsed, min, max); + applyValue(parsed); + textBox.Text = parsed.ToString(); + ClearOverlayValidation(textBox); + return true; + } + + private LlmSettings? TryGetOverlayLlmSettings() + => _settings?.Settings?.Llm; + + private void MarkOverlayValidation(Control? control, string message) + { + if (control == null) + return; + + control.BorderBrush = BrushFromHex("#DC2626"); + control.ToolTip = message; + } + + private void ClearOverlayValidation(Control? control) + { + if (control == null) + return; + + control.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + control.ClearValue(ToolTipProperty); + } + + private void CommitOverlayModelSelection(string modelId) + { + _settings.Settings.Llm.Model = modelId; + var service = NormalizeOverlayService(_settings.Settings.Llm.Service); + switch (service) + { + case "ollama": + _settings.Settings.Llm.OllamaModel = modelId; + break; + case "vllm": + _settings.Settings.Llm.VllmModel = modelId; + break; + case "gemini": + _settings.Settings.Llm.GeminiModel = modelId; + break; + default: + _settings.Settings.Llm.ClaudeModel = modelId; + break; + } + + if (TxtOverlayModelInput != null && TxtOverlayModelInput.Visibility == Visibility.Visible) + { + TxtOverlayModelInput.Text = modelId; + ClearOverlayValidation(TxtOverlayModelInput); + } + } + + private void ChkOverlayAiEnabled_Changed(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + _settings.Settings.AiEnabled = true; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void ChkOverlayVllmAllowInsecureTls_Changed(object sender, RoutedEventArgs e) + { + // vLLM SSL 우회는 모델 등록 단계에서만 관리합니다. + } + + private void TxtOverlayServiceEndpoint_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + CommitOverlayEndpointInput(normalizeOnInvalid: false); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayServiceApiKey_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + CommitOverlayApiKeyInput(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayModelInput_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + CommitOverlayModelInput(normalizeOnInvalid: false); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void RefreshOverlayAdvancedChoiceButtons() + { + // ToggleSwitch 기반으로 바뀌면서 별도 버튼 시각 동기화는 사용하지 않습니다. + } + + private void TxtOverlayContextCompactTriggerPercent_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, _settings.Settings.Llm.ContextCompactTriggerPercent, 10, 95, value => _settings.Settings.Llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxContextTokens_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxContextTokens, _settings.Settings.Llm.MaxContextTokens, 1024, 1_000_000, value => _settings.Settings.Llm.MaxContextTokens = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private bool CommitOverlayTemperatureInput(bool normalizeOnInvalid) + { + if (TxtOverlayTemperature == null) + return false; + + var raw = TxtOverlayTemperature.Text.Trim(); + if (!double.TryParse(raw, out var parsed)) + { + ClearOverlayValidation(TxtOverlayTemperature); + if (normalizeOnInvalid) + TxtOverlayTemperature.Text = Math.Round(Math.Clamp(_settings.Settings.Llm.Temperature, 0.0, 2.0), 1).ToString("0.0"); + return false; + } + + var normalized = Math.Round(Math.Clamp(parsed, 0.0, 2.0), 1); + var changed = Math.Abs(_settings.Settings.Llm.Temperature - normalized) > 0.0001; + _settings.Settings.Llm.Temperature = normalized; + ClearOverlayValidation(TxtOverlayTemperature); + if (normalizeOnInvalid || changed) + TxtOverlayTemperature.Text = normalized.ToString("0.0"); + return changed; + } + + private void TxtOverlayTemperature_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayTemperatureInput(normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayTemperature_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = Math.Round(Math.Clamp(e.NewValue, 0.0, 2.0), 1); + llm.Temperature = value; + if (TxtOverlayTemperature != null) + TxtOverlayTemperature.Text = value.ToString("0.0"); + if (TxtOverlayTemperatureValue != null) + TxtOverlayTemperatureValue.Text = value.ToString("0.0"); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayMaxRetryOnError_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 10)); + llm.MaxRetryOnError = value; + if (TxtOverlayMaxRetryOnError != null) + TxtOverlayMaxRetryOnError.Text = value.ToString(); + if (TxtOverlayMaxRetryOnErrorValue != null) + TxtOverlayMaxRetryOnErrorValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayMaxAgentIterations_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 100)); + llm.MaxAgentIterations = value; + if (TxtOverlayMaxAgentIterations != null) + TxtOverlayMaxAgentIterations.Text = value.ToString(); + if (TxtOverlayMaxAgentIterationsValue != null) + TxtOverlayMaxAgentIterationsValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayFreeTierDelaySeconds_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 60)); + llm.FreeTierDelaySeconds = value; + if (TxtOverlayFreeTierDelaySeconds != null) + TxtOverlayFreeTierDelaySeconds.Text = value.ToString(); + if (TxtOverlayFreeTierDelaySecondsValue != null) + TxtOverlayFreeTierDelaySecondsValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayMaxSubAgents_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 10)); + llm.MaxSubAgents = value; + if (TxtOverlayMaxSubAgents != null) + TxtOverlayMaxSubAgents.Text = value.ToString(); + if (TxtOverlayMaxSubAgentsValue != null) + TxtOverlayMaxSubAgentsValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayToolHookTimeoutMs_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 3000, 30000)); + llm.ToolHookTimeoutMs = value; + if (TxtOverlayToolHookTimeoutMs != null) + TxtOverlayToolHookTimeoutMs.Text = value.ToString(); + if (TxtOverlayToolHookTimeoutMsValue != null) + TxtOverlayToolHookTimeoutMsValue.Text = $"{value / 1000}s"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlaySlashPopupPageSize_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 3, 20)); + llm.SlashPopupPageSize = value; + if (TxtOverlaySlashPopupPageSize != null) + TxtOverlaySlashPopupPageSize.Text = value.ToString(); + if (TxtOverlaySlashPopupPageSizeValue != null) + TxtOverlaySlashPopupPageSizeValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayMaxFavoriteSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 30)); + llm.MaxFavoriteSlashCommands = value; + if (TxtOverlayMaxFavoriteSlashCommands != null) + TxtOverlayMaxFavoriteSlashCommands.Text = value.ToString(); + if (TxtOverlayMaxFavoriteSlashCommandsValue != null) + TxtOverlayMaxFavoriteSlashCommandsValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void SldOverlayMaxRecentSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + var llm = TryGetOverlayLlmSettings(); + if (_isOverlaySettingsSyncing || llm == null) + return; + + var value = (int)Math.Round(Math.Clamp(e.NewValue, 5, 50)); + llm.MaxRecentSlashCommands = value; + if (TxtOverlayMaxRecentSlashCommands != null) + TxtOverlayMaxRecentSlashCommands.Text = value.ToString(); + if (TxtOverlayMaxRecentSlashCommandsValue != null) + TxtOverlayMaxRecentSlashCommandsValue.Text = value.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayCompactPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value)) + return; + + _settings.Settings.Llm.ContextCompactTriggerPercent = Math.Clamp(value, 10, 95); + if (TxtOverlayContextCompactTriggerPercent != null) + TxtOverlayContextCompactTriggerPercent.Text = _settings.Settings.Llm.ContextCompactTriggerPercent.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayContextPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value)) + return; + + _settings.Settings.Llm.MaxContextTokens = Math.Clamp(value, 1024, 1_000_000); + if (TxtOverlayMaxContextTokens != null) + TxtOverlayMaxContextTokens.Text = _settings.Settings.Llm.MaxContextTokens.ToString(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxRetryOnError_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, _settings.Settings.Llm.MaxRetryOnError, 0, 10, value => _settings.Settings.Llm.MaxRetryOnError = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxAgentIterations_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, _settings.Settings.Llm.MaxAgentIterations, 1, 200, value => _settings.Settings.Llm.MaxAgentIterations = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayFreeTierDelaySeconds_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, _settings.Settings.Llm.FreeTierDelaySeconds, 0, 60, value => _settings.Settings.Llm.FreeTierDelaySeconds = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxSubAgents_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxSubAgents, _settings.Settings.Llm.MaxSubAgents, 1, 10, value => _settings.Settings.Llm.MaxSubAgents = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlaySlashPopupPageSize_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlaySlashPopupPageSize, _settings.Settings.Llm.SlashPopupPageSize, 3, 20, value => _settings.Settings.Llm.SlashPopupPageSize = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayToolHookTimeoutMs_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, _settings.Settings.Llm.ToolHookTimeoutMs, 3000, 30000, value => _settings.Settings.Llm.ToolHookTimeoutMs = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxFavoriteSlashCommands_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, _settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30, value => _settings.Settings.Llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayMaxRecentSlashCommands_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, _settings.Settings.Llm.MaxRecentSlashCommands, 5, 50, value => _settings.Settings.Llm.MaxRecentSlashCommands = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayPdfExportPath_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing || TxtOverlayPdfExportPath == null) + return; + + _settings.Settings.Llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayPlanDiffMediumCount_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, _settings.Settings.Llm.PlanDiffSeverityMediumCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayPlanDiffHighCount_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, _settings.Settings.Llm.PlanDiffSeverityHighCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayPlanDiffMediumRatio_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void TxtOverlayPlanDiffHighRatio_LostFocus(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing) + return; + + if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: false)) + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayBrowseSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + var dlg = new System.Windows.Forms.FolderBrowserDialog + { + Description = "스킬 파일이 있는 폴더를 선택하세요", + ShowNewFolderButton = true, + }; + + var current = _settings.Settings.Llm.SkillsFolderPath; + if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current)) + dlg.SelectedPath = current; + + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + _settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath; + SkillService.LoadSkills(dlg.SelectedPath); + RefreshOverlayEtcPanels(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayOpenSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + var folder = !string.IsNullOrWhiteSpace(_settings.Settings.Llm.SkillsFolderPath) && Directory.Exists(_settings.Settings.Llm.SkillsFolderPath) + ? _settings.Settings.Llm.SkillsFolderPath + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); + + if (!Directory.Exists(folder)) + Directory.CreateDirectory(folder); + + try { System.Diagnostics.Process.Start("explorer.exe", folder); } catch { } + } + + private void OverlayAddHookBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => ShowOverlayHookEditDialog(null, -1); + + private void OverlayOpenAuditLogBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { } + } + + private void ChkOverlayEnableDragDropAiActions_Changed(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing || ChkOverlayEnableDragDropAiActions == null) + return; + + _settings.Settings.Llm.EnableDragDropAiActions = ChkOverlayEnableDragDropAiActions.IsChecked == true; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void ChkOverlayDragDropAutoSend_Changed(object sender, RoutedEventArgs e) + { + if (_isOverlaySettingsSyncing || ChkOverlayDragDropAutoSend == null) + return; + + _settings.Settings.Llm.DragDropAutoSend = ChkOverlayDragDropAutoSend.IsChecked == true; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayNav_Checked(object sender, RoutedEventArgs e) + { + if (sender is not RadioButton rb || rb.Tag is not string tag) + return; + + SetOverlaySection(tag); + } + + private void SetOverlaySection(string tag) + { + if (OverlaySectionService == null || OverlaySectionQuick == null || OverlaySectionDetail == null) + return; + + var section = string.IsNullOrWhiteSpace(tag) ? "basic" : tag.Trim().ToLowerInvariant(); + var showBasic = section == "basic"; + var showChat = section == "chat"; + var showShared = section == "shared"; + var showCowork = section == "cowork"; + var showCode = section == "code"; + var showDev = section == "dev"; + var showTools = section == "tools"; + var showEtc = section == "etc"; + + OverlaySectionService.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + OverlaySectionQuick.Visibility = showShared || showCowork || showCode ? Visibility.Visible : Visibility.Collapsed; + OverlaySectionDetail.Visibility = Visibility.Visible; + + var headingTitle = section switch + { + "chat" => "채팅 설정", + "shared" => "코워크/코드 공통 설정", + "cowork" => "코워크 설정", + "code" => "코드 설정", + "dev" => "개발자 설정", + "tools" => "도구 설정", + "etc" => "스킬/차단 설정", + _ => "공통 설정" + }; + var headingDescription = section switch + { + "chat" => "Chat 탭에서 쓰는 입력/내보내기 같은 채팅 전용 설정입니다.", + "shared" => "Cowork와 Code에서 함께 쓰는 문맥/압축 관련 기본 설정입니다.", + "cowork" => "문서/업무 협업 흐름에 맞춘 코워크 전용 설정입니다.", + "code" => "코드 작업, 검증, 개발 도구 사용에 맞춘 설정입니다.", + "dev" => "실행 이력, 감사, 시각화 같은 개발자용 설정입니다.", + "tools" => "AX Agent가 사용할 도구와 훅 동작을 관리합니다.", + "etc" => "스킬 로드와 차단 규칙, 보조 연결을 관리합니다.", + _ => "Chat, Cowork, Code에서 공통으로 쓰는 기본 설정입니다." + }; + + if (OverlayTopHeadingTitle != null) + OverlayTopHeadingTitle.Text = headingTitle; + if (OverlayTopHeadingDescription != null) + OverlayTopHeadingDescription.Text = headingDescription; + if (OverlayAnchorCommon != null) + OverlayAnchorCommon.Text = headingTitle; + + if (OverlayAiEnabledRow != null) + OverlayAiEnabledRow.Visibility = Visibility.Collapsed; + if (OverlayThemePanel != null) + OverlayThemePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + if (OverlayThemeStylePanel != null) + OverlayThemeStylePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + if (OverlayDefaultOutputFormatRow != null) + OverlayDefaultOutputFormatRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed; + if (OverlayDefaultMoodRow != null) + OverlayDefaultMoodRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed; + if (OverlayPdfExportPathRow != null) + OverlayPdfExportPathRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleImageInput != null) + OverlayToggleImageInput.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; + if (OverlayModelEditorPanel != null) + OverlayModelEditorPanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + if (OverlayAnchorPermission != null) + OverlayAnchorPermission.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + if (OverlayFolderDataUsageRow != null) + OverlayFolderDataUsageRow.Visibility = showShared || showCowork ? Visibility.Visible : Visibility.Collapsed; + if (OverlayTlsRow != null) + OverlayTlsRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; + if (OverlayAnchorAdvanced != null) + OverlayAnchorAdvanced.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; + if (TxtOverlayContextCompactTriggerPercent != null) + TxtOverlayContextCompactTriggerPercent.Visibility = Visibility.Collapsed; + if (OverlayMaxContextTokensRow != null) + OverlayMaxContextTokensRow.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; + if (OverlayTemperatureRow != null) + OverlayTemperatureRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayMaxRetryRow != null) + OverlayMaxRetryRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayMaxAgentIterationsRow != null) + OverlayMaxAgentIterationsRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayDeveloperRuntimePanel != null) + OverlayDeveloperRuntimePanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayDeveloperExtraPanel != null) + OverlayDeveloperExtraPanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayAdvancedTogglePanel != null) + OverlayAdvancedTogglePanel.Visibility = showDev || showCowork || showCode || showTools || showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToolsInfoPanel != null) + OverlayToolsInfoPanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToolsRuntimePanel != null) + OverlayToolsRuntimePanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; + if (OverlayEtcInfoPanel != null) + OverlayEtcInfoPanel.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayEtcRuntimePanel != null) + OverlayEtcRuntimePanel.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleProactiveCompact != null) + OverlayToggleProactiveCompact.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleSkillSystem != null) + OverlayToggleSkillSystem.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleToolHooks != null) + OverlayToggleToolHooks.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleHookInputMutation != null) + OverlayToggleHookInputMutation.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleHookPermissionUpdate != null) + OverlayToggleHookPermissionUpdate.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleCoworkVerification != null) + OverlayToggleCoworkVerification.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleCodeVerification != null) + OverlayToggleCodeVerification.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleCodeReview != null) + OverlayToggleCodeReview.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleParallelTools != null) + OverlayToggleParallelTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleProjectRules != null) + OverlayToggleProjectRules.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleAgentMemory != null) + OverlayToggleAgentMemory.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayTogglePlanModeTools != null) + OverlayTogglePlanModeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleWorktreeTools != null) + OverlayToggleWorktreeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleTeamTools != null) + OverlayToggleTeamTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleCronTools != null) + OverlayToggleCronTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed; + + if (showTools || showEtc) + RefreshOverlayEtcPanels(); + } + + private void RefreshOverlaySettingsPanel() + { + RefreshOverlayVisualState(loadDeferredInputs: true); + RefreshOverlayEtcPanels(); + } + + private void RefreshOverlayEtcPanels() + { + var llm = _settings.Settings.Llm; + + if (OverlaySkillsFolderPathText != null) + { + var defaultFolder = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", + "skills"); + OverlaySkillsFolderPathText.Text = string.IsNullOrWhiteSpace(llm.SkillsFolderPath) + ? defaultFolder + : llm.SkillsFolderPath.Trim(); + } + + if (TxtOverlaySlashPopupPageSize != null) + TxtOverlaySlashPopupPageSize.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString(); + if (SldOverlaySlashPopupPageSize != null) + SldOverlaySlashPopupPageSize.Value = Math.Clamp(llm.SlashPopupPageSize, 3, 20); + if (TxtOverlaySlashPopupPageSizeValue != null) + TxtOverlaySlashPopupPageSizeValue.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString(); + if (TxtOverlayToolHookTimeoutMs != null) + TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString(); + if (SldOverlayToolHookTimeoutMs != null) + SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000); + if (TxtOverlayToolHookTimeoutMsValue != null) + TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s"; + if (TxtOverlayMaxFavoriteSlashCommands != null) + TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); + if (SldOverlayMaxFavoriteSlashCommands != null) + SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30); + if (TxtOverlayMaxFavoriteSlashCommandsValue != null) + TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString(); + if (TxtOverlayMaxRecentSlashCommands != null) + TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); + if (SldOverlayMaxRecentSlashCommands != null) + SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50); + if (TxtOverlayMaxRecentSlashCommandsValue != null) + TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString(); + if (ChkOverlayEnableDragDropAiActions != null) + ChkOverlayEnableDragDropAiActions.IsChecked = llm.EnableDragDropAiActions; + if (ChkOverlayDragDropAutoSend != null) + ChkOverlayDragDropAutoSend.IsChecked = llm.DragDropAutoSend; + + BuildOverlayBlockedItems(); + BuildOverlayHookCards(); + BuildOverlaySkillListPanel(); + BuildOverlayFallbackModelsPanel(); + BuildOverlayMcpServerCards(); + BuildOverlayToolRegistryPanel(); + } + + private void BuildOverlayBlockedItems() + { + if (OverlayBlockedPathsPanel != null) + { + OverlayBlockedPathsPanel.Children.Clear(); + foreach (var path in _settings.Settings.Llm.BlockedPaths.Where(x => !string.IsNullOrWhiteSpace(x))) + { + OverlayBlockedPathsPanel.Children.Add(new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 0, 4), + Child = new TextBlock + { + Text = path, + FontSize = 11.5, + FontFamily = new FontFamily("Consolas, Malgun Gothic"), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray + } + }); + } + } + + if (OverlayBlockedExtensionsPanel != null) + { + OverlayBlockedExtensionsPanel.Children.Clear(); + foreach (var ext in _settings.Settings.Llm.BlockedExtensions.Where(x => !string.IsNullOrWhiteSpace(x))) + { + OverlayBlockedExtensionsPanel.Children.Add(new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 6, 6), + Child = new TextBlock + { + Text = ext, + FontSize = 11.5, + FontFamily = new FontFamily("Consolas, Malgun Gothic"), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray + } + }); + } + } + } + + private void BuildOverlaySkillListPanel() + { + if (OverlaySkillListPanel == null) + return; + + OverlaySkillListPanel.Children.Clear(); + var skills = SkillService.Skills.ToList(); + if (skills.Count == 0) + { + OverlaySkillListPanel.Children.Add(new TextBlock + { + Text = "로드된 스킬이 없습니다.", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + return; + } + + var unavailable = skills + .Where(skill => !skill.IsAvailable) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var autoSkills = skills + .Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths))) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var directSkills = skills + .Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths)) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + AddOverlaySkillSection("overlay-skill-direct", "직접 호출 스킬", "슬래시(/)로 직접 실행하는 스킬입니다.", directSkills, "#2563EB"); + AddOverlaySkillSection("overlay-skill-auto", "자동/조건부 스킬", "조건에 따라 자동으로 붙거나 보조적으로 동작하는 스킬입니다.", autoSkills, "#0F766E"); + AddOverlaySkillSection("overlay-skill-unavailable", "현재 사용 불가", "필요한 런타임이 없어 지금은 호출되지 않는 스킬입니다.", unavailable, "#9A3412"); + } + + private void AddOverlaySkillSection(string key, string title, string subtitle, List skills, string accentHex) + { + if (OverlaySkillListPanel == null || skills.Count == 0) + return; + + var body = new StackPanel(); + foreach (var skill in skills) + { + var card = new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6), + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = "/" + skill.Name, + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black + }); + + if (!string.IsNullOrWhiteSpace(skill.Label) && + !string.Equals(skill.Label.Trim(), skill.Name, StringComparison.OrdinalIgnoreCase)) + { + stack.Children.Add(new TextBlock + { + Text = skill.Label.Trim(), + Margin = new Thickness(0, 3, 0, 0), + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + } + + stack.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(skill.Description) + ? (string.IsNullOrWhiteSpace(skill.Label) ? skill.Name : skill.Label) + : skill.Description, + Margin = new Thickness(0, 4, 0, 0), + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + + card.Child = stack; + body.Children.Add(card); + } + + OverlaySkillListPanel.Children.Add(CreateOverlayCollapsibleSection( + key, + $"{title} ({skills.Count})", + subtitle, + body, + defaultExpanded: false, + accentHex: accentHex)); + } + + private void BuildOverlayFallbackModelsPanel() + { + if (OverlayFallbackModelsPanel == null) + return; + + OverlayFallbackModelsPanel.Children.Clear(); + var llm = _settings.Settings.Llm; + var sections = new[] + { + ("ollama", "Ollama"), + ("vllm", "vLLM"), + ("gemini", "Gemini"), + ("claude", "Claude") + }; + + foreach (var (service, label) in sections) + { + var candidates = GetModelCandidates(service); + OverlayFallbackModelsPanel.Children.Add(new TextBlock + { + Text = label, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, + Margin = new Thickness(0, 0, 0, 6) + }); + + if (candidates.Count == 0) + { + OverlayFallbackModelsPanel.Children.Add(new TextBlock + { + Text = "등록된 모델 없음", + FontSize = 10.5, + Margin = new Thickness(8, 0, 0, 8), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + continue; + } + + foreach (var candidate in candidates) + { + var enabled = llm.FallbackModels.Any(x => x.Equals(candidate.Id, StringComparison.OrdinalIgnoreCase)); + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var nameText = new TextBlock + { + Text = candidate.Label, + FontSize = 11.5, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black + }; + var stateText = new TextBlock + { + Text = enabled ? "사용" : "미사용", + FontSize = 10.5, + Foreground = enabled + ? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue) + : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + Margin = new Thickness(12, 0, 0, 0) + }; + Grid.SetColumn(stateText, 1); + grid.Children.Add(nameText); + grid.Children.Add(stateText); + + OverlayFallbackModelsPanel.Children.Add(new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 0, 0, 6), + Child = grid + }); + } + } + } + + private void BuildOverlayMcpServerCards() + { + if (OverlayMcpServerListPanel == null) + return; + + OverlayMcpServerListPanel.Children.Clear(); + var servers = _settings.Settings.Llm.McpServers; + if (servers == null || servers.Count == 0) + { + OverlayMcpServerListPanel.Children.Add(new TextBlock + { + Text = "등록된 MCP 서버가 없습니다.", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + return; + } + + foreach (var server in servers) + { + var card = new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6) + }; + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = server.Name, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black + }); + stack.Children.Add(new TextBlock + { + Text = $"{server.Command} {(server.Enabled ? "· 활성" : "· 비활성")}", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + card.Child = stack; + OverlayMcpServerListPanel.Children.Add(card); + } + } + + private void BuildOverlayToolRegistryPanel() + { + if (OverlayToolRegistryPanel == null) + return; + + OverlayToolRegistryPanel.Children.Clear(); + var grouped = _toolRegistry.All + .GroupBy(tool => GetOverlayToolCategory(tool.Name)) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var group in grouped) + { + var body = new StackPanel(); + foreach (var tool in group.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) + { + var isDisabled = _settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase); + var row = new Border + { + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var rowStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) }; + rowStack.Children.Add(new TextBlock + { + Text = tool.Name, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = isDisabled + ? (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray) + : (TryFindResource("PrimaryText") as Brush ?? Brushes.Black) + }); + rowStack.Children.Add(new TextBlock + { + Text = (isDisabled ? "비활성" : "활성") + " · " + tool.Description, + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.8, + TextWrapping = TextWrapping.Wrap, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + grid.Children.Add(rowStack); + + var toggle = new CheckBox + { + IsChecked = !isDisabled, + VerticalAlignment = VerticalAlignment.Center, + Style = TryFindResource("ToggleSwitch") as Style, + }; + toggle.Checked += (_, _) => + { + _settings.Settings.Llm.DisabledTools.RemoveAll(name => string.Equals(name, tool.Name, StringComparison.OrdinalIgnoreCase)); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + RefreshOverlayEtcPanels(); + }; + toggle.Unchecked += (_, _) => + { + if (!_settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase)) + _settings.Settings.Llm.DisabledTools.Add(tool.Name); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + RefreshOverlayEtcPanels(); + }; + Grid.SetColumn(toggle, 1); + grid.Children.Add(toggle); + + row.Child = grid; + body.Children.Add(row); + } + + OverlayToolRegistryPanel.Children.Add(CreateOverlayCollapsibleSection( + "overlay-tool-" + group.Key, + $"{group.Key} ({group.Count()})", + "카테고리별 도구 목록입니다. 펼치면 상세 이름과 사용 여부를 바로 바꿀 수 있습니다.", + body, + defaultExpanded: false, + accentHex: "#4F46E5")); + } + } + + private void BuildOverlayHookCards() + { + if (OverlayHookListPanel == null) + return; + + OverlayHookListPanel.Children.Clear(); + + var hooks = _settings.Settings.Llm.AgentHooks; + if (hooks.Count == 0) + { + OverlayHookListPanel.Children.Add(new TextBlock + { + Text = "등록된 훅이 없습니다.", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + return; + } + + var body = new StackPanel(); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + + for (int i = 0; i < hooks.Count; i++) + { + var hook = hooks[i]; + var idx = i; + var card = new Border + { + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var toggle = new CheckBox + { + IsChecked = hook.Enabled, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("ToggleSwitch") as Style, + }; + toggle.Checked += (_, _) => + { + hook.Enabled = true; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }; + toggle.Unchecked += (_, _) => + { + hook.Enabled = false; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }; + Grid.SetColumn(toggle, 0); + grid.Children.Add(toggle); + + var info = new StackPanel(); + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(new TextBlock + { + Text = hook.Name, + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + header.Children.Add(new Border + { + Background = BrushFromHex(hook.Timing == "pre" ? "#FFEDD5" : "#DCFCE7"), + BorderBrush = BrushFromHex(hook.Timing == "pre" ? "#FDBA74" : "#86EFAC"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(5), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = hook.Timing == "pre" ? "PRE" : "POST", + FontSize = 9.5, + FontWeight = FontWeights.Bold, + Foreground = BrushFromHex(hook.Timing == "pre" ? "#9A3412" : "#166534"), + } + }); + if (!string.IsNullOrWhiteSpace(hook.ToolName) && hook.ToolName != "*") + { + header.Children.Add(new Border + { + Background = BrushFromHex("#EEF2FF"), + BorderBrush = BrushFromHex("#C7D2FE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(5), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = hook.ToolName, + FontSize = 9.5, + Foreground = BrushFromHex("#3730A3"), + } + }); + } + info.Children.Add(header); + info.Children.Add(new TextBlock + { + Text = Path.GetFileName(hook.ScriptPath), + Margin = new Thickness(0, 4, 0, 0), + FontSize = 11, + Foreground = secondaryText + }); + if (!string.IsNullOrWhiteSpace(hook.Arguments)) + { + info.Children.Add(new TextBlock + { + Text = hook.Arguments, + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText + }); + } + Grid.SetColumn(info, 1); + grid.Children.Add(info); + + var editBtn = new Border + { + Cursor = Cursors.Hand, + Padding = new Thickness(6), + Margin = new Thickness(6, 0, 2, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "\uE70F", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = accentBrush, + } + }; + editBtn.MouseLeftButtonUp += (_, _) => ShowOverlayHookEditDialog(hooks[idx], idx); + Grid.SetColumn(editBtn, 2); + grid.Children.Add(editBtn); + + var deleteBtn = new Border + { + Cursor = Cursors.Hand, + Padding = new Thickness(6), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "\uE74D", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = BrushFromHex("#DC2626"), + } + }; + deleteBtn.MouseLeftButtonUp += (_, _) => + { + hooks.RemoveAt(idx); + BuildOverlayHookCards(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }; + Grid.SetColumn(deleteBtn, 3); + grid.Children.Add(deleteBtn); + + card.Child = grid; + body.Children.Add(card); + } + + OverlayHookListPanel.Children.Add(CreateOverlayCollapsibleSection( + "overlay-hooks", + $"등록된 훅 ({hooks.Count})", + "도구 실행 전후에 연결되는 스크립트입니다.", + body, + defaultExpanded: false, + accentHex: "#0F766E")); + } + + private Border CreateOverlayCollapsibleSection(string key, string title, string subtitle, UIElement content, bool defaultExpanded, string accentHex) + { + var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; + var launcherBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var expanded = _overlaySectionExpandedStates.TryGetValue(key, out var stored) ? stored : defaultExpanded; + + var bodyBorder = new Border + { + Margin = new Thickness(0, 10, 0, 0), + Child = content, + Visibility = expanded ? Visibility.Visible : Visibility.Collapsed + }; + + var caret = new TextBlock + { + Text = expanded ? "\uE70D" : "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center + }; + + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var headerText = new StackPanel(); + headerText.Children.Add(new TextBlock + { + Text = title, + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText + }); + if (!string.IsNullOrWhiteSpace(subtitle)) + { + headerText.Children.Add(new TextBlock + { + Text = subtitle, + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.8, + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText + }); + } + headerGrid.Children.Add(headerText); + Grid.SetColumn(caret, 1); + headerGrid.Children.Add(caret); + + var headerBorder = new Border + { + Background = launcherBackground, + BorderBrush = BrushFromHex(accentHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Cursor = Cursors.Hand, + Child = headerGrid + }; + + void Toggle() + { + var nextExpanded = bodyBorder.Visibility != Visibility.Visible; + bodyBorder.Visibility = nextExpanded ? Visibility.Visible : Visibility.Collapsed; + caret.Text = nextExpanded ? "\uE70D" : "\uE76C"; + _overlaySectionExpandedStates[key] = nextExpanded; + } + + headerBorder.MouseLeftButtonUp += (_, _) => Toggle(); + + return new Border + { + Background = itemBackground, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10), + Margin = new Thickness(0, 0, 0, 8), + Child = new StackPanel + { + Children = + { + headerBorder, + bodyBorder + } + } + }; + } + + private static TextBlock CreateOverlayPlaceholder(string text, Brush foreground, string? currentValue) + { + return new TextBlock + { + Text = text, + FontSize = 13, + Foreground = foreground, + Opacity = 0.45, + IsHitTestVisible = false, + VerticalAlignment = VerticalAlignment.Center, + Padding = new Thickness(14, 8, 14, 8), + Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed, + }; + } + + private void ShowOverlayHookEditDialog(AgentHookEntry? existing, int index) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? BrushFromHex("#FFFFFF"); + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + + var isNew = existing == null; + var dlg = new Window + { + Title = isNew ? "훅 추가" : "훅 편집", + Width = 420, + SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, + ResizeMode = ResizeMode.NoResize, + WindowStyle = WindowStyle.None, + AllowsTransparency = true, + Background = Brushes.Transparent, + ShowInTaskbar = false + }; + + var border = new Border + { + Background = bgBrush, + CornerRadius = new CornerRadius(12), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(20) + }; + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = isNew ? "훅 추가" : "훅 편집", + FontSize = 15, + FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, + Margin = new Thickness(0, 0, 0, 14) + }); + + dlg.KeyDown += (_, e) => { if (e.Key == Key.Escape) dlg.Close(); }; + + stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) }); + var nameBox = new TextBox + { + Text = existing?.Name ?? "", + FontSize = 13, + Foreground = fgBrush, + Background = itemBg, + BorderBrush = borderBrush, + Padding = new Thickness(12, 8, 12, 8) + }; + var nameHolder = CreateOverlayPlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name); + nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed; + var nameGrid = new Grid(); + nameGrid.Children.Add(nameBox); + nameGrid.Children.Add(nameHolder); + stack.Children.Add(nameGrid); + + stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); + var toolBox = new TextBox + { + Text = existing?.ToolName ?? "*", + FontSize = 13, + Foreground = fgBrush, + Background = itemBg, + BorderBrush = borderBrush, + Padding = new Thickness(12, 8, 12, 8) + }; + var toolHolder = CreateOverlayPlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*"); + toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed; + var toolGrid = new Grid(); + toolGrid.Children.Add(toolBox); + toolGrid.Children.Add(toolHolder); + stack.Children.Add(toolGrid); + + stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); + var timingPanel = new StackPanel { Orientation = Orientation.Horizontal }; + var preRadio = new RadioButton + { + Content = "Pre (실행 전)", + Foreground = fgBrush, + FontSize = 13, + Margin = new Thickness(0, 0, 16, 0), + IsChecked = (existing?.Timing ?? "post") == "pre" + }; + var postRadio = new RadioButton + { + Content = "Post (실행 후)", + Foreground = fgBrush, + FontSize = 13, + IsChecked = (existing?.Timing ?? "post") != "pre" + }; + timingPanel.Children.Add(preRadio); + timingPanel.Children.Add(postRadio); + stack.Children.Add(timingPanel); + + stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); + var pathGrid = new Grid(); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition()); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var pathInnerGrid = new Grid(); + var pathBox = new TextBox + { + Text = existing?.ScriptPath ?? "", + FontSize = 13, + Foreground = fgBrush, + Background = itemBg, + BorderBrush = borderBrush, + Padding = new Thickness(12, 8, 12, 8) + }; + var pathHolder = CreateOverlayPlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath); + pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed; + pathInnerGrid.Children.Add(pathBox); + pathInnerGrid.Children.Add(pathHolder); + pathGrid.Children.Add(pathInnerGrid); + + var browseBtn = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(6, 0, 0, 0), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "...", + FontSize = 13, + Foreground = accentBrush + } + }; + browseBtn.MouseLeftButtonUp += (_, _) => + { + var ofd = new OpenFileDialog + { + Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*", + Title = "훅 스크립트 선택", + }; + if (ofd.ShowDialog() == true) + pathBox.Text = ofd.FileName; + }; + Grid.SetColumn(browseBtn, 1); + pathGrid.Children.Add(browseBtn); + stack.Children.Add(pathGrid); + + stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) }); + var argsBox = new TextBox + { + Text = existing?.Arguments ?? "", + FontSize = 13, + Foreground = fgBrush, + Background = itemBg, + BorderBrush = borderBrush, + Padding = new Thickness(12, 8, 12, 8) + }; + var argsHolder = CreateOverlayPlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments); + argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed; + var argsGrid = new Grid(); + argsGrid.Children.Add(argsBox); + argsGrid.Children.Add(argsHolder); + stack.Children.Add(argsGrid); + + var btnRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 16, 0, 0) + }; + var cancelBorder = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(16, 8, 16, 8), + Margin = new Thickness(0, 0, 8, 0), + Cursor = Cursors.Hand, + Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush } + }; + cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close(); + btnRow.Children.Add(cancelBorder); + + var saveBorder = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(16, 8, 16, 8), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = isNew ? "추가" : "저장", + FontSize = 13, + Foreground = Brushes.White, + FontWeight = FontWeights.SemiBold + } + }; + saveBorder.MouseLeftButtonUp += (_, _) => + { + if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text)) + { + CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var entry = new AgentHookEntry + { + Name = nameBox.Text.Trim(), + ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(), + Timing = preRadio.IsChecked == true ? "pre" : "post", + ScriptPath = pathBox.Text.Trim(), + Arguments = argsBox.Text.Trim(), + Enabled = existing?.Enabled ?? true, + }; + + var hooks = _settings.Settings.Llm.AgentHooks; + if (isNew) + hooks.Add(entry); + else if (index >= 0 && index < hooks.Count) + hooks[index] = entry; + + BuildOverlayHookCards(); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + dlg.Close(); + }; + btnRow.Children.Add(saveBorder); + stack.Children.Add(btnRow); + + border.Child = stack; + dlg.Content = border; + dlg.ShowDialog(); + } + + private static string GetOverlayToolCategory(string toolName) + { + return toolName switch + { + "file_read" or "file_write" or "file_edit" or "glob" or "grep_tool" or "folder_map" or "document_read" or "file_manage" or "file_info" or "multi_read" + => "파일/검색", + "process" or "build_run" or "dev_env_detect" or "snippet_runner" + => "프로세스/빌드", + "search_codebase" or "code_search" or "code_review" or "lsp" or "test_loop" or "git_tool" or "project_rules" or "project_rule" or "diff_preview" + => "코드 분석", + "excel_create" or "docx_create" or "csv_create" or "markdown_create" or "html_create" or "chart_create" or "batch_create" or "pptx_create" or "document_review" or "format_convert" or "document_planner" or "document_assembler" or "template_render" + => "문서 생성", + "json_tool" or "regex_tool" or "diff_tool" or "base64_tool" or "hash_tool" or "datetime_tool" or "math_tool" or "xml_tool" or "sql_tool" or "data_pivot" or "text_summarize" + => "데이터 처리", + "clipboard_tool" or "notify_tool" or "env_tool" or "zip_tool" or "http_tool" or "open_external" or "image_analyze" or "file_watch" + => "시스템/환경", + "spawn_agent" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook" + => "에이전트", + _ => "기타" + }; + } + + private void CmbOverlayService_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service) + return; + + var llm = _settings.Settings.Llm; + llm.Service = service; + var candidates = GetModelCandidates(service); + var preferredModel = service switch + { + "ollama" => llm.OllamaModel, + "vllm" => llm.VllmModel, + "gemini" => llm.GeminiModel, + _ => llm.ClaudeModel + }; + if (!string.IsNullOrWhiteSpace(preferredModel)) + llm.Model = preferredModel; + else if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model)) + llm.Model = candidates[0].Id; + + PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); + } + + private void CmbOverlayModel_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId) + return; + + CommitOverlayModelSelection(modelId); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void RefreshOverlayThemeCards() + { + var selected = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant(); + SetOverlayCardSelection(OverlayThemeSystemCard, selected == "system"); + SetOverlayCardSelection(OverlayThemeLightCard, selected == "light"); + SetOverlayCardSelection(OverlayThemeDarkCard, selected == "dark"); + var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").ToLowerInvariant(); + SetOverlayCardSelection(OverlayThemeStyleClawCard, preset == "claw"); + SetOverlayCardSelection(OverlayThemeStyleCodexCard, preset == "codex"); + SetOverlayCardSelection(OverlayThemeStyleSlateCard, preset == "slate"); + } + + private void RefreshOverlayServiceCards() + { + var service = (_settings.Settings.Llm.Service ?? "ollama").ToLowerInvariant(); + SetOverlayCardSelection(OverlaySvcOllamaCard, service == "ollama"); + SetOverlayCardSelection(OverlaySvcVllmCard, service == "vllm"); + SetOverlayCardSelection(OverlaySvcGeminiCard, service == "gemini"); + SetOverlayCardSelection(OverlaySvcClaudeCard, service is "claude" or "sigmoid"); + } + + private void RefreshOverlayTokenPresetCards() + { + var llm = _settings.Settings.Llm; + var compact = llm.ContextCompactTriggerPercent switch + { + <= 60 => 60, + <= 70 => 70, + <= 80 => 80, + _ => 90 + }; + SetOverlayCardSelection(OverlayCompact60Card, compact == 60); + SetOverlayCardSelection(OverlayCompact70Card, compact == 70); + SetOverlayCardSelection(OverlayCompact80Card, compact == 80); + SetOverlayCardSelection(OverlayCompact90Card, compact == 90); + + var context = llm.MaxContextTokens switch + { + <= 4096 => 4096, + <= 16384 => 16384, + <= 65536 => 65536, + <= 262144 => 262144, + _ => 1_000_000 + }; + SetOverlayCardSelection(OverlayContext4KCard, context == 4096); + SetOverlayCardSelection(OverlayContext16KCard, context == 16384); + SetOverlayCardSelection(OverlayContext64KCard, context == 65536); + SetOverlayCardSelection(OverlayContext256KCard, context == 262144); + SetOverlayCardSelection(OverlayContext1MCard, context == 1_000_000); + } + + private void RefreshOverlayModeButtons() + { + var llm = _settings.Settings.Llm; + SelectComboTag(CmbOverlayOperationMode, OperationModePolicy.Normalize(_settings.Settings.OperationMode)); + SelectComboTag(CmbOverlayFolderDataUsage, _folderDataUsage); + SelectComboTag(CmbOverlayPermission, PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission)); + SelectComboTag(CmbOverlayPlanMode, llm.PlanMode); + SelectComboTag(CmbOverlayReasoning, llm.AgentDecisionLevel); + SelectComboTag(CmbOverlayFastMode, llm.FreeTierMode ? "on" : "off"); + SelectComboTag(CmbOverlayDefaultOutputFormat, llm.DefaultOutputFormat ?? "auto"); + SelectComboTag(CmbOverlayDefaultMood, _selectedMood ?? llm.DefaultMood ?? "modern"); + SelectComboTag(CmbOverlayAgentLogLevel, llm.AgentLogLevel ?? "simple"); + UpdateDataUsageUI(); + } + + private static void SelectComboTag(ComboBox? combo, string? tag) + { + if (combo == null) return; + var normalized = (tag ?? "").Trim(); + combo.SelectedItem = combo.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag as string, normalized, StringComparison.OrdinalIgnoreCase)); + } + + private void PopulateOverlayMoodCombo() + { + if (CmbOverlayDefaultMood == null) + return; + + var selected = _selectedMood ?? _settings.Settings.Llm.DefaultMood ?? "modern"; + CmbOverlayDefaultMood.Items.Clear(); + foreach (var mood in TemplateService.AllMoods) + { + CmbOverlayDefaultMood.Items.Add(new ComboBoxItem + { + Content = $"{mood.Icon} {mood.Label}", + Tag = mood.Key + }); + } + + SelectComboTag(CmbOverlayDefaultMood, selected); + } + + private static string GetQuickActionLabel(string title, string value) + => $"{title} · {value}"; + + private void RefreshOverlayServiceFieldLabels(string service) + { + if (OverlayEndpointLabel == null || OverlayEndpointHint == null || OverlayApiKeyLabel == null || OverlayApiKeyHint == null) + return; + + switch (service) + { + case "ollama": + OverlayEndpointLabel.Text = "Ollama 서버 주소"; + OverlayEndpointHint.Text = "사내 로컬 Ollama 기본 주소를 입력합니다."; + OverlayApiKeyLabel.Text = "Ollama API 키"; + OverlayApiKeyHint.Text = "사내 게이트웨이를 쓰는 경우에만 입력합니다."; + break; + case "vllm": + OverlayEndpointLabel.Text = "vLLM 서버 주소"; + OverlayEndpointHint.Text = "OpenAI 호환 엔드포인트 주소를 입력합니다."; + OverlayApiKeyLabel.Text = "vLLM API 키"; + OverlayApiKeyHint.Text = "사내 인증 게이트웨이를 쓰는 경우에만 입력합니다."; + break; + case "gemini": + OverlayEndpointLabel.Text = "기본 서버 주소"; + OverlayEndpointHint.Text = "Gemini는 기본 주소를 사용합니다. 비워두면 기본값을 사용합니다."; + OverlayApiKeyLabel.Text = "Gemini API 키"; + OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다."; + break; + default: + OverlayEndpointLabel.Text = "기본 서버 주소"; + OverlayEndpointHint.Text = "Claude는 기본 주소를 사용합니다. 비워두면 기본값을 사용합니다."; + OverlayApiKeyLabel.Text = "Claude API 키"; + OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다."; + break; + } + } + + private string GetOverlayServiceEndpoint(string service) + { + var llm = _settings.Settings.Llm; + return service switch + { + "ollama" => llm.OllamaEndpoint ?? "", + "vllm" => llm.VllmEndpoint ?? "", + "gemini" => llm.Endpoint ?? "", + "claude" or "sigmoid" => llm.Endpoint ?? "", + _ => llm.Endpoint ?? "" + }; + } + + private string GetOverlayServiceApiKey(string service) + { + var llm = _settings.Settings.Llm; + return service switch + { + "ollama" => llm.OllamaApiKey ?? "", + "vllm" => llm.VllmApiKey ?? "", + "gemini" => llm.GeminiApiKey ?? "", + "claude" or "sigmoid" => llm.ClaudeApiKey ?? "", + _ => llm.ApiKey ?? "" + }; + } + + private void BuildOverlayModelChips(string service) + { + if (OverlayModelChipPanel == null) + return; + + OverlayModelChipPanel.Children.Clear(); + foreach (var model in GetModelCandidates(service)) + { + var captured = model.Id; + var isActive = string.Equals(model.Id, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase); + var border = new Border + { + Cursor = Cursors.Hand, + CornerRadius = new CornerRadius(8), + BorderThickness = new Thickness(1), + BorderBrush = isActive + ? BrushFromHex("#C7D2FE") + : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray), + Background = isActive + ? BrushFromHex("#EEF2FF") + : Brushes.Transparent, + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 0, 8, 8), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = model.Label, + FontSize = 11.5, + FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = isActive + ? BrushFromHex("#1D4ED8") + : (TryFindResource("PrimaryText") as Brush ?? Brushes.Black), + }, + new TextBlock + { + Text = model.Id, + Margin = new Thickness(0, 2, 0, 0), + FontSize = 10, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + } + } + } + }; + border.MouseEnter += (_, _) => + { + if (!isActive) + border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + }; + border.MouseLeave += (_, _) => + { + if (!isActive) + border.Background = Brushes.Transparent; + }; + border.MouseLeftButtonUp += (_, _) => + { + CommitOverlayModelSelection(captured); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }; + OverlayModelChipPanel.Children.Add(border); + } + } + + private void BuildOverlayRegisteredModelsPanel(string service) + { + if (OverlayRegisteredModelsPanel == null || OverlayRegisteredModelsHeader == null || BtnOverlayAddModel == null) + return; + + var normalized = NormalizeOverlayService(service); + var supportsRegistered = SupportsOverlayRegisteredModels(normalized); + OverlayRegisteredModelsHeader.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; + OverlayRegisteredModelsPanel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; + BtnOverlayAddModel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed; + + OverlayRegisteredModelsPanel.Children.Clear(); + if (!supportsRegistered) + return; + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.White; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + + var models = _settings.Settings.Llm.RegisteredModels + .Where(m => string.Equals(m.Service, normalized, StringComparison.OrdinalIgnoreCase)) + .OrderBy(m => m.Alias, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (models.Count == 0) + { + OverlayRegisteredModelsPanel.Children.Add(new Border + { + CornerRadius = new CornerRadius(10), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Background = Brushes.Transparent, + Padding = new Thickness(12, 10, 12, 10), + Child = new TextBlock + { + Text = "등록된 모델이 없습니다. `모델 추가`로 사내 모델을 먼저 등록하세요.", + FontSize = 11.5, + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + } + }); + return; + } + + foreach (var model in models) + { + var decryptedModelName = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled); + var displayName = string.IsNullOrWhiteSpace(model.Alias) ? decryptedModelName : model.Alias; + var endpointText = string.IsNullOrWhiteSpace(model.Endpoint) ? "기본 서버 사용" : model.Endpoint; + var authLabel = string.Equals(model.AuthType, "cp4d", StringComparison.OrdinalIgnoreCase) ? "CP4D" : "Bearer"; + var isActive = string.Equals(model.EncryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase) + || string.Equals(decryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase); + + var row = new Border + { + CornerRadius = new CornerRadius(10), + BorderBrush = isActive ? accentBrush : borderBrush, + BorderThickness = new Thickness(1), + Background = isActive ? (TryFindResource("HintBackground") as Brush ?? BrushFromHex("#EFF6FF")) : itemBg, + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(0, 0, 0, 8), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var info = new StackPanel(); + info.Children.Add(new TextBlock + { + Text = displayName, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + info.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(decryptedModelName) ? "(모델명 없음)" : decryptedModelName, + Margin = new Thickness(0, 3, 0, 0), + FontSize = 11, + Foreground = secondaryText, + }); + info.Children.Add(new TextBlock + { + Text = $"엔드포인트: {endpointText} · 인증: {authLabel}", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + }); + Grid.SetColumn(info, 0); + grid.Children.Add(info); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + }; + + Button CreateAction(string text, RoutedEventHandler onClick, Brush foreground) + { + var button = new Button + { + Content = text, + Style = TryFindResource("GhostBtn") as Style, + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(6, 0, 0, 0), + Foreground = foreground, + Cursor = Cursors.Hand, + }; + button.Click += onClick; + return button; + } + + actions.Children.Add(CreateAction("선택", (_, _) => + { + CommitOverlayModelSelection(model.EncryptedModelName); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }, accentBrush)); + + actions.Children.Add(CreateAction("편집", (_, _) => EditOverlayRegisteredModel(model), primaryText)); + actions.Children.Add(CreateAction("삭제", (_, _) => DeleteOverlayRegisteredModel(model), BrushFromHex("#DC2626"))); + + Grid.SetColumn(actions, 1); + grid.Children.Add(actions); + + row.Child = grid; + row.MouseEnter += (_, _) => + { + if (!isActive) + row.Background = hoverBg; + }; + row.MouseLeave += (_, _) => + { + if (!isActive) + row.Background = itemBg; + }; + + OverlayRegisteredModelsPanel.Children.Add(row); + } + } + + private void BtnOverlayAddModel_Click(object sender, RoutedEventArgs e) + { + var service = NormalizeOverlayService(_settings.Settings.Llm.Service); + if (!SupportsOverlayRegisteredModels(service)) + return; + + var dlg = new ModelRegistrationDialog(service) { Owner = this }; + if (dlg.ShowDialog() != true) + return; + + _settings.Settings.Llm.RegisteredModels.Add(new RegisteredModel + { + Alias = dlg.ModelAlias, + EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled), + Service = service, + Endpoint = dlg.Endpoint, + ApiKey = dlg.ApiKey, + AllowInsecureTls = dlg.AllowInsecureTls, + AuthType = dlg.AuthType, + Cp4dUrl = dlg.Cp4dUrl, + Cp4dUsername = dlg.Cp4dUsername, + Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled), + }); + + PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); + } + + private void EditOverlayRegisteredModel(RegisteredModel model) + { + var currentModel = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled); + var cp4dPassword = Services.CryptoService.DecryptIfEnabled(model.Cp4dPassword ?? "", IsOverlayEncryptionEnabled); + var service = NormalizeOverlayService(model.Service); + + var dlg = new ModelRegistrationDialog( + service, + model.Alias, + currentModel, + model.Endpoint, + model.ApiKey, + model.AllowInsecureTls, + model.AuthType ?? "bearer", + model.Cp4dUrl ?? "", + model.Cp4dUsername ?? "", + cp4dPassword) + { Owner = this }; + + if (dlg.ShowDialog() != true) + return; + + model.Alias = dlg.ModelAlias; + model.EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled); + model.Service = service; + model.Endpoint = dlg.Endpoint; + model.ApiKey = dlg.ApiKey; + model.AllowInsecureTls = dlg.AllowInsecureTls; + model.AuthType = dlg.AuthType; + model.Cp4dUrl = dlg.Cp4dUrl; + model.Cp4dUsername = dlg.Cp4dUsername; + model.Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled); + + PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); + } + + private void DeleteOverlayRegisteredModel(RegisteredModel model) + { + var result = CustomMessageBox.Show($"'{model.Alias}' 모델을 삭제하시겠습니까?", "모델 삭제", + MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) + return; + + _settings.Settings.Llm.RegisteredModels.Remove(model); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); + } + + private void SetOverlayCardSelection(Border border, bool selected) + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + border.BorderBrush = selected ? accent : normal; + border.Background = selected + ? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent) + : Brushes.Transparent; + } + + private void OverlayThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentTheme = "system"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentTheme = "light"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentTheme = "dark"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayThemeStyleClawCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentThemePreset = "claw"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayThemeStyleCodexCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentThemePreset = "codex"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlayThemeStyleSlateCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _settings.Settings.Llm.AgentThemePreset = "slate"; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void OverlaySvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("ollama"); + private void OverlaySvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("vllm"); + private void OverlaySvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("gemini"); + private void OverlaySvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("claude"); + + private void SetOverlayService(string service) + { + _settings.Settings.Llm.Service = service; + var llm = _settings.Settings.Llm; + var candidates = GetModelCandidates(service); + var preferredModel = service switch + { + "ollama" => llm.OllamaModel, + "vllm" => llm.VllmModel, + "gemini" => llm.GeminiModel, + _ => llm.ClaudeModel + }; + llm.Model = !string.IsNullOrWhiteSpace(preferredModel) + ? preferredModel + : candidates.FirstOrDefault().Id ?? llm.Model; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: true); + } + + private void BtnOverlayOperationMode_Click(object sender, RoutedEventArgs e) + { + var next = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode + ? OperationModePolicy.InternalMode + : OperationModePolicy.ExternalMode; + + if (!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요.")) + return; + + _settings.Settings.OperationMode = next; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void BtnOverlayFolderDataUsage_Click(object sender, RoutedEventArgs e) + { + _folderDataUsage = _folderDataUsage switch + { + "none" => "passive", + "passive" => "active", + _ => "none", + }; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayFastMode_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayFastMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.FreeTierMode = string.Equals(tag, "on", StringComparison.OrdinalIgnoreCase); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayReasoning_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayReasoning.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.AgentDecisionLevel = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayPlanMode_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayPlanMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.PlanMode = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayPermission_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayPermission.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + var normalized = PermissionModeCatalog.NormalizeGlobalMode(tag); + var llm = _settings.Settings.Llm; + llm.FilePermission = normalized; + llm.DefaultAgentPermission = normalized; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayDefaultOutputFormat_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayDefaultOutputFormat.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.DefaultOutputFormat = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayDefaultMood_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayDefaultMood.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _selectedMood = tag; + _settings.Settings.Llm.DefaultMood = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayOperationMode_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayOperationMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + var normalized = OperationModePolicy.Normalize(tag); + var current = OperationModePolicy.Normalize(_settings.Settings.OperationMode); + if (string.Equals(normalized, current, StringComparison.OrdinalIgnoreCase)) + return; + + if (string.Equals(normalized, OperationModePolicy.ExternalMode, StringComparison.OrdinalIgnoreCase) && + !PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요.")) + { + RefreshOverlayModeButtons(); + return; + } + + _settings.Settings.OperationMode = normalized; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayFolderDataUsage_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayFolderDataUsage.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _folderDataUsage = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void CmbOverlayAgentLogLevel_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayAgentLogLevel.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.AgentLogLevel = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e) + { + if (System.Windows.Application.Current is App app) + app.OpenSettingsFromChat(); + } + + private bool PromptOverlayPasswordDialog(string title, string header, string message) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; + + var dlg = new Window + { + Title = title, + Width = 340, + SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, + ResizeMode = ResizeMode.NoResize, + WindowStyle = WindowStyle.None, + AllowsTransparency = true, + Background = Brushes.Transparent, + ShowInTaskbar = false, + }; + + var border = new Border + { + Background = bgBrush, + CornerRadius = new CornerRadius(12), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(20), + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12) }); + stack.Children.Add(new TextBlock { Text = message, FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6) }); + + var pwBox = new PasswordBox + { + FontSize = 14, + Padding = new Thickness(8, 6, 8, 6), + Background = itemBg, + Foreground = fgBrush, + BorderBrush = borderBrush, + PasswordChar = '*', + }; + stack.Children.Add(pwBox); + + var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) }; + var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) }; + cancelBtn.Click += (_, _) => dlg.DialogResult = false; + btnRow.Children.Add(cancelBtn); + + var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true }; + okBtn.Click += (_, _) => + { + if (pwBox.Password == UnifiedAdminPassword) + dlg.DialogResult = true; + else + { + pwBox.Clear(); + pwBox.Focus(); + } + }; + btnRow.Children.Add(okBtn); + stack.Children.Add(btnRow); + + border.Child = stack; + dlg.Content = border; + dlg.Loaded += (_, _) => pwBox.Focus(); + return dlg.ShowDialog() == true; + } + + private static int ParseOverlayInt(string? text, int fallback, int min, int max) + { + if (!int.TryParse(text, out var value)) + value = fallback; + return Math.Clamp(value, min, max); + } + + private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e) + => InlineSettingsPanel.IsOpen = false; + + private void CmbInlineService_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isInlineSettingsSyncing || CmbInlineService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service) + return; + + var llm = _settings.Settings.Llm; + llm.Service = service; + + var candidates = GetModelCandidates(service); + if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model)) + llm.Model = candidates[0].Id; + + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + } + + private void CmbInlineModel_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isInlineSettingsSyncing || CmbInlineModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId) + return; + + _settings.Settings.Llm.Model = modelId; + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + } + + private void BtnInlineFastMode_Click(object sender, RoutedEventArgs e) + { + _settings.Settings.Llm.FreeTierMode = !_settings.Settings.Llm.FreeTierMode; + _settings.Save(); + _appState.LoadFromSettings(_settings); + RefreshInlineSettingsPanel(); + RefreshOverlayVisualState(loadDeferredInputs: false); + } + + private void BtnInlineReasoning_Click(object sender, RoutedEventArgs e) + { + var llm = _settings.Settings.Llm; + llm.AgentDecisionLevel = NextReasoning(llm.AgentDecisionLevel); + _settings.Save(); + _appState.LoadFromSettings(_settings); + RefreshInlineSettingsPanel(); + RefreshOverlayVisualState(loadDeferredInputs: false); + } + + private void BtnInlinePlanMode_Click(object sender, RoutedEventArgs e) + { + var llm = _settings.Settings.Llm; + llm.PlanMode = NextPlanMode(llm.PlanMode); + _settings.Save(); + _appState.LoadFromSettings(_settings); + RefreshInlineSettingsPanel(); + RefreshOverlayVisualState(loadDeferredInputs: false); + } + + private void BtnInlinePermission_Click(object sender, RoutedEventArgs e) + { + var llm = _settings.Settings.Llm; + llm.FilePermission = NextPermission(llm.FilePermission); + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + RefreshOverlayVisualState(loadDeferredInputs: false); + } + + private void BtnInlineSkill_Click(object sender, RoutedEventArgs e) + { + var llm = _settings.Settings.Llm; + llm.EnableSkillSystem = !llm.EnableSkillSystem; + if (llm.EnableSkillSystem) + { + SkillService.EnsureSkillFolder(); + SkillService.LoadSkills(llm.SkillsFolderPath); + UpdateConditionalSkillActivation(reset: true); + } + + _settings.Save(); + _appState.LoadFromSettings(_settings); + RefreshInlineSettingsPanel(); + + if (llm.EnableSkillSystem) + OpenCommandSkillBrowser("/"); + } + + private void BtnInlineCommandBrowser_Click(object sender, RoutedEventArgs e) + => OpenCommandSkillBrowser("/"); + + private void BtnInlineMcp_Click(object sender, RoutedEventArgs e) + { + var app = System.Windows.Application.Current as App; + app?.OpenSettingsFromChat(); + } + + private void BtnNewChat_Click(object sender, RoutedEventArgs e) + { + StartNewConversation(); + InputBox.Focus(); + } + + public void ResumeConversation(string conversationId) + { + var conv = _storage.Load(conversationId); + if (conv != null) + { + var targetTab = NormalizeTabName(conv.Tab); + if (!string.Equals(_activeTab, targetTab, StringComparison.OrdinalIgnoreCase)) + { + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + PersistPerTabUiState(); + _activeTab = targetTab; + RestorePerTabUiState(); + UpdateTabUI(); + + if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase)) + TabChat.IsChecked = true; + else if (string.Equals(targetTab, "Cowork", StringComparison.OrdinalIgnoreCase)) + TabCowork.IsChecked = true; + else if (TabCode.IsEnabled) + TabCode.IsChecked = true; + } + + lock (_convLock) + { + conv.Tab = targetTab; + _currentConversation = ChatSession?.SetCurrentConversation(targetTab, conv, _storage) ?? conv; + SyncTabConversationIdsFromSession(); + } + SaveLastConversations(); + UpdateChatTitle(); + RefreshConversationList(); + RenderMessages(); + UpdateFolderBar(); + RefreshDraftQueueUi(); + } + InputBox.Focus(); + } + + private static string NormalizeTabName(string? tab) + { + var normalized = (tab ?? "").Trim(); + if (string.IsNullOrEmpty(normalized)) + return "Chat"; + + if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase)) + return "Cowork"; + + var canonical = new string(normalized + .Where(char.IsLetterOrDigit) + .ToArray()) + .ToLowerInvariant(); + + if (canonical is "cowork" or "coworkcode" or "coworkcodetab") + return "Cowork"; + + if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase) + || canonical is "code" or "codetab") + return "Code"; + + return "Chat"; + } + + public void StartNewAndFocus() + { + StartNewConversation(); + InputBox.Focus(); + } + + private void BtnDeleteAll_Click(object sender, RoutedEventArgs e) + { + var result = CustomMessageBox.Show( + "저장된 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", + "대화 전체 삭제", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) return; + + _storage.DeleteAll(); + lock (_convLock) + { + ChatSession?.ClearCurrentConversation(_activeTab); + _currentConversation = null; + SyncTabConversationIdsFromSession(); + } + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + UpdateChatTitle(); + RefreshConversationList(); + } + + // ─── 미리보기 패널 (탭 기반) ───────────────────────────────────────────── + + private static readonly HashSet _previewableExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log", + }; + + /// 열려 있는 프리뷰 탭 목록 (파일 경로 기준). + private readonly List _previewTabs = new(); + private string? _activePreviewTab; + + private void TryShowPreview(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath)) + return; + + // 별도 커스텀 창으로 미리보기 (WebView2 HWND airspace 문제 근본 해결) + PreviewWindow.ShowPreview(filePath, _selectedMood); + } + + private void ShowPreviewPanel(string filePath) + { + // 탭에 없으면 추가 + if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase)) + _previewTabs.Add(filePath); + + _activePreviewTab = filePath; + + // 패널 열기 + if (PreviewColumn.Width.Value < 100) + { + PreviewColumn.Width = new GridLength(420); + SplitterColumn.Width = new GridLength(5); + } + PreviewPanel.Visibility = Visibility.Visible; + PreviewSplitter.Visibility = Visibility.Visible; + BtnPreviewToggle.Visibility = Visibility.Visible; + + RebuildPreviewTabs(); + LoadPreviewContent(filePath); + } + + /// 탭 바 UI를 다시 구성합니다. + private void RebuildPreviewTabs() + { + PreviewTabPanel.Children.Clear(); + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + foreach (var tabPath in _previewTabs) + { + var fileName = System.IO.Path.GetFileName(tabPath); + var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase); + + var tabBorder = new Border + { + Background = isActive + ? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)) + : Brushes.Transparent, + BorderBrush = isActive ? accentBrush : Brushes.Transparent, + BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0), + Padding = new Thickness(8, 6, 4, 6), + Cursor = Cursors.Hand, + MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100), + }; + + var tabContent = new StackPanel { Orientation = Orientation.Horizontal }; + + // 파일명 + tabContent.Children.Add(new TextBlock + { + Text = fileName, + FontSize = 11, + Foreground = isActive ? primaryText : secondaryText, + FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = tabBorder.MaxWidth - 30, + ToolTip = tabPath, + }); + + // 닫기 버튼 (x) — 활성 탭은 항상 표시, 비활성 탭은 호버 시에만 표시 + var closeFg = isActive ? primaryText : secondaryText; + var closeBtnText = new TextBlock + { + Text = "\uE711", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = closeFg, + VerticalAlignment = VerticalAlignment.Center, + }; + var closeBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(3), + Padding = new Thickness(3, 2, 3, 2), + Margin = new Thickness(5, 0, 0, 0), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + // 비활성 탭은 초기에 숨김, 활성 탭은 항상 표시 + Visibility = isActive ? Visibility.Visible : Visibility.Hidden, + Child = closeBtnText, + }; + + var closePath = tabPath; + closeBtn.MouseEnter += (s, _) => + { + if (s is Border b) + { + b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50)); + if (b.Child is TextBlock tb) tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60)); + } + }; + closeBtn.MouseLeave += (s, _) => + { + if (s is Border b) + { + b.Background = Brushes.Transparent; + if (b.Child is TextBlock tb) tb.Foreground = closeFg; + } + }; + closeBtn.Tag = "close"; // 닫기 버튼 식별용 + closeBtn.MouseLeftButtonUp += (_, e) => + { + e.Handled = true; // 부모 탭 클릭 이벤트 차단 + ClosePreviewTab(closePath); + }; + + tabContent.Children.Add(closeBtn); + tabBorder.Child = tabContent; + + // 탭 클릭 → 활성화 (MouseLeftButtonUp 사용: 닫기 버튼의 PreviewMouseLeftButtonDown보다 늦게 실행되어 충돌 방지) + var clickPath = tabPath; + tabBorder.MouseLeftButtonUp += (_, e) => + { + if (e.Handled) return; + e.Handled = true; + _activePreviewTab = clickPath; + RebuildPreviewTabs(); + LoadPreviewContent(clickPath); + }; + + // 우클릭 → 컨텍스트 메뉴 + var ctxPath = tabPath; + tabBorder.MouseRightButtonUp += (_, e) => + { + e.Handled = true; + ShowPreviewTabContextMenu(ctxPath); + }; + + // 더블클릭 → 별도 창에서 보기 + var dblPath = tabPath; + tabBorder.MouseLeftButtonDown += (_, e) => + { + if (e.Handled) return; + if (e.ClickCount == 2) + { + e.Handled = true; + OpenPreviewPopupWindow(dblPath); + } + }; + + // 호버 효과 — 비활성 탭에서 배경 강조 + 닫기 버튼 표시 + var capturedIsActive = isActive; + var capturedCloseBtn = closeBtn; + tabBorder.MouseEnter += (s, _) => + { + if (s is Border b && !capturedIsActive) + b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); + // 비활성 탭도 호버 시 닫기 버튼 표시 + if (!capturedIsActive) + capturedCloseBtn.Visibility = Visibility.Visible; + }; + tabBorder.MouseLeave += (s, _) => + { + if (s is Border b && !capturedIsActive) + b.Background = Brushes.Transparent; + // 비활성 탭 호버 해제 시 닫기 버튼 숨김 + if (!capturedIsActive) + capturedCloseBtn.Visibility = Visibility.Hidden; + }; + + PreviewTabPanel.Children.Add(tabBorder); + + // 탭 사이 구분선 + if (tabPath != _previewTabs[^1]) + { + PreviewTabPanel.Children.Add(new Border + { + Width = 1, Height = 14, + Background = borderBrush, + Margin = new Thickness(0, 4, 0, 4), + VerticalAlignment = VerticalAlignment.Center, + }); + } + } + } + + private void ClosePreviewTab(string filePath) + { + _previewTabs.Remove(filePath); + + if (_previewTabs.Count == 0) + { + HidePreviewPanel(); + return; + } + + // 닫힌 탭이 활성 탭이면 마지막 탭으로 전환 + if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase)) + { + _activePreviewTab = _previewTabs[^1]; + LoadPreviewContent(_activePreviewTab); + } + + RebuildPreviewTabs(); + } + + private async void LoadPreviewContent(string filePath) + { + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + + // 모든 콘텐츠 숨기기 + PreviewWebView.Visibility = Visibility.Collapsed; + PreviewTextScroll.Visibility = Visibility.Collapsed; + PreviewDataGrid.Visibility = Visibility.Collapsed; + PreviewEmpty.Visibility = Visibility.Collapsed; + + if (!System.IO.File.Exists(filePath)) + { + PreviewEmpty.Text = "파일을 찾을 수 없습니다"; + PreviewEmpty.Visibility = Visibility.Visible; + return; + } + + try + { + switch (ext) + { + case ".html": + case ".htm": + await EnsureWebViewInitializedAsync(); + PreviewWebView.Source = new Uri(filePath); + PreviewWebView.Visibility = Visibility.Visible; + break; + + case ".csv": + LoadCsvPreview(filePath); + PreviewDataGrid.Visibility = Visibility.Visible; + break; + + case ".md": + await EnsureWebViewInitializedAsync(); + var mdText = System.IO.File.ReadAllText(filePath); + if (mdText.Length > 50000) mdText = mdText[..50000]; + var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood); + PreviewWebView.NavigateToString(mdHtml); + PreviewWebView.Visibility = Visibility.Visible; + break; + + case ".txt": + case ".json": + case ".xml": + case ".log": + var text = System.IO.File.ReadAllText(filePath); + if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)"; + PreviewTextBlock.Text = text; + PreviewTextScroll.Visibility = Visibility.Visible; + break; + + default: + PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다"; + PreviewEmpty.Visibility = Visibility.Visible; + break; + } + } + catch (Exception ex) + { + PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}"; + PreviewTextScroll.Visibility = Visibility.Visible; + } + } + + private bool _webViewInitialized; + private static readonly string WebView2DataFolder = + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "WebView2"); + + private async Task EnsureWebViewInitializedAsync() + { + if (_webViewInitialized) return; + try + { + var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( + userDataFolder: WebView2DataFolder); + await PreviewWebView.EnsureCoreWebView2Async(env); + _webViewInitialized = true; + } + catch (Exception ex) + { + Services.LogService.Warn($"WebView2 초기화 실패: {ex.Message}"); + } + } + + private void LoadCsvPreview(string filePath) + { + try + { + var lines = System.IO.File.ReadAllLines(filePath); + if (lines.Length == 0) return; + + var dt = new System.Data.DataTable(); + var headers = ParseCsvLine(lines[0]); + foreach (var h in headers) + dt.Columns.Add(h); + + var maxRows = Math.Min(lines.Length, 501); + for (int i = 1; i < maxRows; i++) + { + var vals = ParseCsvLine(lines[i]); + var row = dt.NewRow(); + for (int j = 0; j < Math.Min(vals.Length, headers.Length); j++) + row[j] = vals[j]; + dt.Rows.Add(row); + } + + PreviewDataGrid.ItemsSource = dt.DefaultView; + } + catch (Exception ex) + { + PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}"; + PreviewTextScroll.Visibility = Visibility.Visible; + PreviewDataGrid.Visibility = Visibility.Collapsed; + } + } + + private static string[] ParseCsvLine(string line) + { + var fields = new System.Collections.Generic.List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inQuotes) + { + if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') + { + current.Append('"'); + i++; + } + else if (c == '"') + inQuotes = false; + else + current.Append(c); + } + else + { + if (c == '"') + inQuotes = true; + else if (c == ',') + { + fields.Add(current.ToString()); + current.Clear(); + } + else + current.Append(c); + } + } + fields.Add(current.ToString()); + return fields.ToArray(); + } + + private void HidePreviewPanel() + { + _previewTabs.Clear(); + _activePreviewTab = null; + PreviewColumn.Width = new GridLength(0); + SplitterColumn.Width = new GridLength(0); + PreviewPanel.Visibility = Visibility.Collapsed; + PreviewSplitter.Visibility = Visibility.Collapsed; + PreviewWebView.Visibility = Visibility.Collapsed; + PreviewTextScroll.Visibility = Visibility.Collapsed; + PreviewDataGrid.Visibility = Visibility.Collapsed; + try { if (_webViewInitialized) PreviewWebView.CoreWebView2?.NavigateToString(""); } catch { } + } + + /// 프리뷰 탭 바 클릭 시 WebView2에서 포커스를 회수 (HWND airspace 문제 방지). + private void PreviewTabBar_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // WebView2가 포커스를 잡고 있으면 WPF 버튼 클릭이 무시될 수 있으므로 포커스를 강제 이동 + if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin) + { + var border = sender as Border; + border?.Focus(); + } + } + + private void BtnClosePreview_Click(object sender, RoutedEventArgs e) + { + HidePreviewPanel(); + BtnPreviewToggle.Visibility = Visibility.Collapsed; + } + + private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e) + { + if (PreviewPanel.Visibility == Visibility.Visible) + { + // 숨기기 (탭은 유지) + PreviewPanel.Visibility = Visibility.Collapsed; + PreviewSplitter.Visibility = Visibility.Collapsed; + PreviewColumn.Width = new GridLength(0); + SplitterColumn.Width = new GridLength(0); + } + else if (_previewTabs.Count > 0) + { + // 다시 열기 + PreviewPanel.Visibility = Visibility.Visible; + PreviewSplitter.Visibility = Visibility.Visible; + PreviewColumn.Width = new GridLength(420); + SplitterColumn.Width = new GridLength(5); + RebuildPreviewTabs(); + if (_activePreviewTab != null) LoadPreviewContent(_activePreviewTab); + } + } + + private void BtnOpenExternal_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrEmpty(_activePreviewTab) || !System.IO.File.Exists(_activePreviewTab)) + return; + + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = _activePreviewTab, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}"); + } + } + + /// 프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다. + private Popup? _previewTabPopup; + + private void ShowPreviewTabContextMenu(string filePath) + { + // 기존 팝업 닫기 + if (_previewTabPopup != null) _previewTabPopup.IsOpen = false; + + var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + var stack = new StackPanel(); + + void AddItem(string icon, string iconColor, string label, Action action) + { + var itemBorder = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 7, 16, 7), + Cursor = Cursors.Hand, + }; + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor) + ? secondaryText + : new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 13, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + itemBorder.Child = sp; + itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + itemBorder.MouseLeftButtonUp += (_, _) => + { + _previewTabPopup!.IsOpen = false; + action(); + }; + stack.Children.Add(itemBorder); + } + + void AddSeparator() + { + stack.Children.Add(new Border + { + Height = 1, + Background = borderBrush, + Margin = new Thickness(8, 3, 8, 3), + }); + } + + AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = filePath, UseShellExecute = true, + }); + } + catch { } + }); + + AddItem("\uE838", "#FFB74D", "파일 위치 열기", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } + catch { } + }); + + AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath)); + + AddSeparator(); + + AddItem("\uE8C8", "", "경로 복사", () => + { + try { Clipboard.SetText(filePath); } catch { } + }); + + AddSeparator(); + + AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath)); + + if (_previewTabs.Count > 1) + { + AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () => + { + var keep = filePath; + _previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase)); + _activePreviewTab = keep; + RebuildPreviewTabs(); + LoadPreviewContent(keep); + }); + } + + var popupBorder = new Border + { + Background = bg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(4, 6, 4, 6), + MinWidth = 180, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4, + Color = Colors.Black, + }, + Child = stack, + }; + + _previewTabPopup = new Popup + { + Child = popupBorder, + Placement = PlacementMode.MousePoint, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + }; + _previewTabPopup.IsOpen = true; + } + + /// 프리뷰를 별도 팝업 창에서 엽니다. + private void OpenPreviewPopupWindow(string filePath) + { + if (!System.IO.File.Exists(filePath)) return; + + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + var fileName = System.IO.Path.GetFileName(filePath); + var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); + var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + + var win = new Window + { + Title = $"미리보기 — {fileName}", + Width = 900, + Height = 700, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Background = bg, + }; + + FrameworkElement content; + + switch (ext) + { + case ".html": + case ".htm": + var wv = new Microsoft.Web.WebView2.Wpf.WebView2(); + wv.Loaded += async (_, _) => + { + try + { + var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( + userDataFolder: WebView2DataFolder); + await wv.EnsureCoreWebView2Async(env); + wv.Source = new Uri(filePath); + } + catch { } + }; + content = wv; + break; + + case ".md": + var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2(); + var mdMood = _selectedMood; + mdWv.Loaded += async (_, _) => + { + try + { + var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync( + userDataFolder: WebView2DataFolder); + await mdWv.EnsureCoreWebView2Async(env); + var mdSrc = System.IO.File.ReadAllText(filePath); + if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000]; + var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood); + mdWv.NavigateToString(html); + } + catch { } + }; + content = mdWv; + break; + + case ".csv": + var dg = new System.Windows.Controls.DataGrid + { + AutoGenerateColumns = true, + IsReadOnly = true, + Background = Brushes.Transparent, + Foreground = Brushes.White, + BorderThickness = new Thickness(0), + FontSize = 12, + }; + try + { + var lines = System.IO.File.ReadAllLines(filePath); + if (lines.Length > 0) + { + var dt = new System.Data.DataTable(); + var headers = ParseCsvLine(lines[0]); + foreach (var h in headers) dt.Columns.Add(h); + for (int i = 1; i < Math.Min(lines.Length, 1001); i++) + { + var vals = ParseCsvLine(lines[i]); + var row = dt.NewRow(); + for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++) + row[j] = vals[j]; + dt.Rows.Add(row); + } + dg.ItemsSource = dt.DefaultView; + } + } + catch { } + content = dg; + break; + + default: + var text = System.IO.File.ReadAllText(filePath); + if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)"; + var sv = new ScrollViewer + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(20), + Content = new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Consolas"), + FontSize = 13, + Foreground = fg, + }, + }; + content = sv; + break; + } + + win.Content = content; + win.Show(); + } + + // ─── 에이전트 스티키 진행률 바 ────────────────────────────────────────── + + private DateTime _progressStartTime; + private DispatcherTimer? _progressElapsedTimer; + + private void UpdateAgentProgressBar(AgentEvent evt) + { + switch (evt.Type) + { + case AgentEventType.Planning when evt.Steps is { Count: > 0 }: + ShowStickyProgress(evt.Steps.Count); + break; + + case AgentEventType.StepStart when evt.StepTotal > 0: + UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary); + break; + + case AgentEventType.Complete: + HideStickyProgress(); + break; + } + } + + private void ShowStickyProgress(int totalSteps) + { + _progressStartTime = DateTime.Now; + AgentProgressBar.Visibility = Visibility.Visible; + ProgressIcon.Text = "\uE768"; // play + ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})"; + ProgressPercent.Text = "0%"; + ProgressElapsed.Text = "0:00"; + ProgressFill.Width = 0; + + // 경과 시간 타이머 + _progressElapsedTimer?.Stop(); + _progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _progressElapsedTimer.Tick += (_, _) => + { + var elapsed = DateTime.Now - _progressStartTime; + ProgressElapsed.Text = elapsed.TotalHours >= 1 + ? elapsed.ToString(@"h\:mm\:ss") + : elapsed.ToString(@"m\:ss"); + }; + _progressElapsedTimer.Start(); + } + + private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription) + { + if (AgentProgressBar.Visibility != Visibility.Visible) return; + + var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0; + ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})"; + ProgressPercent.Text = $"{(int)(pct * 100)}%"; + + // 프로그레스 바 너비 애니메이션 + var parentBorder = ProgressFill.Parent as Border; + if (parentBorder != null) + { + var targetWidth = parentBorder.ActualWidth * pct; + var anim = new System.Windows.Media.Animation.DoubleAnimation( + ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300)) + { + EasingFunction = new System.Windows.Media.Animation.QuadraticEase(), + }; + ProgressFill.BeginAnimation(WidthProperty, anim); + } + } + + private void HideStickyProgress() + { + _progressElapsedTimer?.Stop(); + _progressElapsedTimer = null; + + if (AgentProgressBar.Visibility != Visibility.Visible) return; + + // 완료 표시 후 페이드아웃 + ProgressIcon.Text = "\uE930"; // check + ProgressStepLabel.Text = "작업 완료"; + ProgressPercent.Text = "100%"; + + // 프로그레스 바 100% + var parentBorder = ProgressFill.Parent as Border; + if (parentBorder != null) + { + var anim = new System.Windows.Media.Animation.DoubleAnimation( + ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200)); + ProgressFill.BeginAnimation(WidthProperty, anim); + } + + // 3초 후 숨기기 + var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; + hideTimer.Tick += (_, _) => + { + hideTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => + { + AgentProgressBar.Visibility = Visibility.Collapsed; + AgentProgressBar.Opacity = 1; + ProgressFill.BeginAnimation(WidthProperty, null); + ProgressFill.Width = 0; + }; + AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + hideTimer.Start(); + } + + // ─── 파일 탐색기 ────────────────────────────────────────────────────── + + private static readonly HashSet _ignoredDirs = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode", + "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", + ".cache", ".next", ".nuxt", "coverage", ".terraform", + }; + + private DispatcherTimer? _fileBrowserRefreshTimer; + + private void ToggleFileBrowser() + { + if (FileBrowserPanel.Visibility == Visibility.Visible) + { + FileBrowserPanel.Visibility = Visibility.Collapsed; + _settings.Settings.Llm.ShowFileBrowser = false; + } + else + { + FileBrowserPanel.Visibility = Visibility.Visible; + _settings.Settings.Llm.ShowFileBrowser = true; + BuildFileTree(); + } + _settings.Save(); + } + + private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree(); + + private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e) + { + var folder = GetCurrentWorkFolder(); + if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return; + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch { } + } + + private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e) + { + FileBrowserPanel.Visibility = Visibility.Collapsed; + } + + private void BuildFileTree() + { + FileTreeView.Items.Clear(); + var folder = GetCurrentWorkFolder(); + if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) + { + FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false }); + return; + } + + FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}"; + var count = 0; + PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count); + } + + private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count) + { + if (depth > 4 || count > 200) return; + + // 디렉터리 + try + { + foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name)) + { + if (count > 200) break; + if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue; + + count++; + var dirItem = new TreeViewItem + { + Header = CreateFileTreeHeader("\uED25", subDir.Name, null), + Tag = subDir.FullName, + IsExpanded = depth < 1, + }; + + // 지연 로딩: 더미 자식 → 펼칠 때 실제 로드 + if (depth < 3) + { + dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미 + var capturedDir = subDir; + var capturedDepth = depth; + dirItem.Expanded += (s, _) => + { + if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...") + { + ti.Items.Clear(); + int c = 0; + PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c); + } + }; + } + else + { + PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count); + } + + items.Add(dirItem); + } + } + catch { } + + // 파일 + try + { + foreach (var file in dir.GetFiles().OrderBy(f => f.Name)) + { + if (count > 200) break; + count++; + + var ext = file.Extension.ToLowerInvariant(); + var icon = GetFileIcon(ext); + var size = FormatFileSize(file.Length); + + var fileItem = new TreeViewItem + { + Header = CreateFileTreeHeader(icon, file.Name, size), + Tag = file.FullName, + }; + + // 더블클릭 → 프리뷰 + var capturedPath = file.FullName; + fileItem.MouseDoubleClick += (s, e) => + { + e.Handled = true; + TryShowPreview(capturedPath); + }; + + // 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음) + fileItem.MouseRightButtonUp += (s, e) => + { + e.Handled = true; + if (s is TreeViewItem ti) ti.IsSelected = true; + ShowFileTreeContextMenu(capturedPath); + }; + + items.Add(fileItem); + } + } + catch { } + } + + private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 5, 0), + }); + sp.Children.Add(new TextBlock + { + Text = name, + FontSize = 11.5, + VerticalAlignment = VerticalAlignment.Center, + }); + if (sizeText != null) + { + sp.Children.Add(new TextBlock + { + Text = $" {sizeText}", + FontSize = 10, + Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)), + VerticalAlignment = VerticalAlignment.Center, + }); + } + return sp; + } + + private static string GetFileIcon(string ext) => ext switch + { + ".html" or ".htm" => "\uEB41", + ".xlsx" or ".xls" => "\uE9F9", + ".docx" or ".doc" => "\uE8A5", + ".pdf" => "\uEA90", + ".csv" => "\uE80A", + ".md" => "\uE70B", + ".json" or ".xml" => "\uE943", + ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F", + ".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943", + ".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756", + ".txt" or ".log" => "\uE8A5", + _ => "\uE7C3", + }; + + private static string FormatFileSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB", + }; + + private void ShowFileTreeContextMenu(string filePath) + { + var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C)); + + var popup = new Popup + { + StaysOpen = false, AllowsTransparency = true, PopupAnimation = PopupAnimation.Fade, + Placement = PlacementMode.MousePoint, + }; + var panel = new StackPanel { Margin = new Thickness(2) }; + var container = new Border + { + Background = bg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), Padding = new Thickness(6), MinWidth = 200, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, + Color = Colors.Black, Direction = 270, + }, + Child = panel, + }; + popup.Child = container; + + void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, Foreground = iconColor ?? secondaryText, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 12.5, Foreground = labelColor ?? primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var item = new Border + { + Child = sp, Background = Brushes.Transparent, + CornerRadius = new CornerRadius(7), Cursor = Cursors.Hand, + Padding = new Thickness(10, 8, 14, 8), Margin = new Thickness(0, 1, 0, 1), + }; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; action(); }; + panel.Children.Add(item); + } + + void AddSep() + { + panel.Children.Add(new Border + { + Height = 1, Margin = new Thickness(10, 4, 10, 4), + Background = borderBrush, Opacity = 0.3, + }); + } + + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + if (_previewableExtensions.Contains(ext)) + AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath)); + + AddItem("\uE8A7", "외부 프로그램으로 열기", () => + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch { } + }); + AddItem("\uED25", "폴더에서 보기", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch { } + }); + AddItem("\uE8C8", "경로 복사", () => + { + try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch { } + }); + + AddSep(); + + // 이름 변경 + AddItem("\uE8AC", "이름 변경", () => + { + var dir = System.IO.Path.GetDirectoryName(filePath) ?? ""; + var oldName = System.IO.Path.GetFileName(filePath); + var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this }; + if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText)) + { + var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim()); + try + { + System.IO.File.Move(filePath, newPath); + BuildFileTree(); + ShowToast($"이름 변경: {dlg.ResponseText.Trim()}"); + } + catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); } + } + }); + + // 삭제 + AddItem("\uE74D", "삭제", () => + { + var result = CustomMessageBox.Show( + $"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}", + "파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + { + try + { + System.IO.File.Delete(filePath); + BuildFileTree(); + ShowToast("파일 삭제됨"); + } + catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); } + } + }, dangerBrush, dangerBrush); + + // Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음 + Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, + System.Windows.Threading.DispatcherPriority.Input); + } + + /// 에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다. + private void RefreshFileTreeIfVisible() + { + if (FileBrowserPanel.Visibility != Visibility.Visible) return; + + // 디바운스: 500ms 내 중복 호출 방지 + _fileBrowserRefreshTimer?.Stop(); + _fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _fileBrowserRefreshTimer.Tick += (_, _) => + { + _fileBrowserRefreshTimer.Stop(); + BuildFileTree(); + }; + _fileBrowserRefreshTimer.Start(); + } + + // ─── 하단 상태바 ────────────────────────────────────────────────────── + + private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard; + + private void UpdateStatusBar(AgentEvent evt) + { + var toolLabel = evt.ToolName switch + { + "file_read" or "document_read" => "파일 읽기", + "file_write" => "파일 쓰기", + "file_edit" => "파일 수정", + "html_create" => "HTML 생성", + "xlsx_create" => "Excel 생성", + "docx_create" => "Word 생성", + "csv_create" => "CSV 생성", + "md_create" => "Markdown 생성", + "folder_map" => "폴더 탐색", + "glob" => "파일 검색", + "grep" => "내용 검색", + "process" => "명령 실행", + _ => evt.ToolName, + }; + + switch (evt.Type) + { + case AgentEventType.Thinking: + SetStatus("생각 중...", spinning: true); + break; + case AgentEventType.Planning: + SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true); + break; + case AgentEventType.PermissionRequest: + SetStatus($"권한 확인 중: {toolLabel}", spinning: false); + break; + case AgentEventType.PermissionGranted: + SetStatus($"권한 승인됨: {toolLabel}", spinning: false); + break; + case AgentEventType.PermissionDenied: + SetStatus($"권한 거부됨: {toolLabel}", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Decision: + SetStatus(GetDecisionStatusText(evt.Summary), spinning: IsDecisionPending(evt.Summary)); + break; + case AgentEventType.ToolCall: + SetStatus($"{toolLabel} 실행 중...", spinning: true); + break; + case AgentEventType.ToolResult: + SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false); + break; + case AgentEventType.StepStart: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.StepDone: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true); + break; + case AgentEventType.SkillCall: + SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.Complete: + SetStatus("작업 완료", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Error: + SetStatus("오류 발생", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Paused: + SetStatus("⏸ 일시정지", spinning: false); + break; + case AgentEventType.Resumed: + SetStatus("▶ 재개됨", spinning: true); + break; + } + } + + private void SetStatus(string text, bool spinning) + { + if (StatusLabel != null) StatusLabel.Text = text; + if (spinning) StartStatusAnimation(); + } + +private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary) +{ + if (IsDecisionApproved(summary)) + return ("\uE73E", "계획 승인", "#ECFDF5", "#059669"); + if (IsDecisionRejected(summary)) + return ("\uE783", "계획 반려", "#FEF2F2", "#DC2626"); + return ("\uE70F", "계획 확인", "#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", "명령 권한 요청", "#FEF2F2", "#DC2626") + : ("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669"); + + if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) + return pending + ? ("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669"); + + if (tool.Contains("file")) + return pending + ? ("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669"); + + return pending + ? ("\uE897", "권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "권한 허용", "#ECFDF5", "#059669"); +} + + private static bool IsDecisionPending(string? summary) + { + var text = summary?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + return true; + + return text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase) + || text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDecisionApproved(string? summary) + { + var text = summary?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + return false; + + return text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDecisionRejected(string? summary) + { + var text = summary?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + return false; + + return text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase) + || text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase) + || text.Contains("취소", StringComparison.OrdinalIgnoreCase); + } + + private static string GetDecisionStatusText(string? summary) + { + if (IsDecisionPending(summary)) + return "계획 승인 대기 중"; + if (IsDecisionApproved(summary)) + return "계획 승인됨 — 실행 시작"; + if (IsDecisionRejected(summary)) + return "계획 반려됨 — 계획 재작성"; + return string.IsNullOrWhiteSpace(summary) ? "사용자 의사결정 대기 중" : TruncateForStatus(summary); + } + + private void StartStatusAnimation() + { + if (_statusSpinStoryboard != null) return; + + var anim = new System.Windows.Media.Animation.DoubleAnimation + { + From = 0, To = 360, + Duration = TimeSpan.FromSeconds(2), + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + }; + + _statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard(); + System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim, + new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); + _statusSpinStoryboard.Children.Add(anim); + _statusSpinStoryboard.Begin(); + } + + private void StopStatusAnimation() + { + _statusSpinStoryboard?.Stop(); + _statusSpinStoryboard = null; + } + + private void SetStatusIdle() + { + StopStatusAnimation(); + if (StatusLabel != null) StatusLabel.Text = "대기 중"; + if (StatusElapsed != null) StatusElapsed.Text = ""; + if (StatusTokens != null) StatusTokens.Text = ""; + RefreshContextUsageVisual(); + ScheduleGitBranchRefresh(250); + } + + private void UpdateStatusTokens(int inputTokens, int outputTokens) + { + if (StatusTokens == null) return; + var llm = _settings.Settings.Llm; + var (inCost, outCost) = Services.TokenEstimator.EstimateCost( + inputTokens, outputTokens, llm.Service, llm.Model); + var totalCost = inCost + outCost; + var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : ""; + StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}"; + RefreshContextUsageVisual(); + } + + private void BtnCompactNow_Click(object sender, RoutedEventArgs e) + { + if (_isStreaming) + { + SetStatus("응답 생성 중에는 압축을 실행할 수 없습니다", spinning: false); + return; + } + + _ = ExecuteManualCompactAsync("/compact", _activeTab); + } + + private async void BtnGitBranch_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(_currentGitBranchName) || GitBranchPopup == null || GitBranchItems == null) + return; + + if (GitBranchPopup.IsOpen) + { + GitBranchPopup.IsOpen = false; + return; + } + + await RefreshGitBranchStatusAsync(); + _gitBranchSearchText = ""; + if (GitBranchSearchBox != null) + GitBranchSearchBox.Text = ""; + BuildGitBranchPopup(); + GitBranchPopup.IsOpen = true; + GitBranchSearchBox?.Focus(); + } + + private void GitBranchSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + _gitBranchSearchText = GitBranchSearchBox?.Text?.Trim() ?? ""; + if (GitBranchPopup?.IsOpen == true) + BuildGitBranchPopup(); + } + + private void TrackRecentGitBranch(string? branchName) + { + if (string.IsNullOrWhiteSpace(branchName)) + return; + + _recentGitBranches.RemoveAll(branch => string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase)); + _recentGitBranches.Insert(0, branchName); + if (_recentGitBranches.Count > 6) + _recentGitBranches.RemoveRange(6, _recentGitBranches.Count - 6); + } + + private void RefreshContextUsageVisual() + { + if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null + || TokenUsageSummaryText == null || TokenUsageHintText == null + || TokenUsageThresholdMarker == null || CompactNowLabel == null) + return; + + var showContextUsage = _activeTab is "Cowork" or "Code"; + TokenUsageCard.Visibility = showContextUsage ? Visibility.Visible : Visibility.Collapsed; + if (!showContextUsage) + { + return; + } + + var llm = _settings.Settings.Llm; + var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); + var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); + var triggerRatio = triggerPercent / 100.0; + + int messageTokens; + lock (_convLock) + messageTokens = _currentConversation?.Messages?.Count > 0 + ? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages) + : 0; + + var draftText = InputBox?.Text ?? ""; + var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; + var currentTokens = Math.Max(0, messageTokens + draftTokens); + var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens); + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + Brush progressBrush = accentBrush; + string summary; + string compactLabel; + + if (usageRatio >= 1.0) + { + progressBrush = Brushes.IndianRed; + summary = "컨텍스트 한도 초과"; + compactLabel = "지금 압축"; + } + else if (usageRatio >= triggerRatio) + { + progressBrush = Brushes.DarkOrange; + summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 임계 도달"; + compactLabel = "압축 권장"; + } + else if (usageRatio >= triggerRatio * 0.7) + { + progressBrush = Brushes.Goldenrod; + summary = "컨텍스트 사용 증가"; + compactLabel = "미리 압축"; + } + else + { + summary = "컨텍스트 여유"; + compactLabel = "압축"; + } + + TokenUsageArc.Stroke = progressBrush; + TokenUsageThresholdMarker.Fill = progressBrush; + var percentText = $"{Math.Round(usageRatio * 100):0}%"; + var (currentService, currentModel) = _llm.GetCurrentModelInfo(); + var todayUsage = Services.UsageStatisticsService.GetTodaySnapshot(); + var currentModelUsageKey = BuildUsageModelKey(currentService, currentModel); + var currentModelPromptTokens = GetUsageValue(todayUsage.ModelPromptTokens, currentModelUsageKey); + var currentModelCompletionTokens = GetUsageValue(todayUsage.ModelCompletionTokens, currentModelUsageKey); + var currentModelTotalTokens = currentModelPromptTokens + currentModelCompletionTokens; + var currentModelPostPromptTokens = GetUsageValue(todayUsage.PostCompactionPromptTokens, currentModelUsageKey); + var currentModelPostCompletionTokens = GetUsageValue(todayUsage.PostCompactionCompletionTokens, currentModelUsageKey); + var currentModelPostTotalTokens = currentModelPostPromptTokens + currentModelPostCompletionTokens; + TokenUsagePercentText.Text = percentText; + TokenUsageSummaryText.Text = $"컨텍스트 {percentText}"; + TokenUsageHintText.Text = + $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}" + + (currentModelTotalTokens > 0 + ? $" · 오늘 {FormatTokenCount(currentModelTotalTokens)}" + : ""); + CompactNowLabel.Text = compactLabel; + var compactHistory = _lastCompactionAt.HasValue && _lastCompactionBeforeTokens.HasValue && _lastCompactionAfterTokens.HasValue + ? $"\n최근 압축: {(_lastCompactionWasAutomatic ? "자동" : "수동")} · {_lastCompactionAt.Value:HH:mm:ss}\n" + + $"절감: {_lastCompactionBeforeTokens.Value:N0} → {_lastCompactionAfterTokens.Value:N0} tokens " + + $"(-{Math.Max(0, _lastCompactionBeforeTokens.Value - _lastCompactionAfterTokens.Value):N0}, " + + $"{Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)})\n" + + $"단계: {(!string.IsNullOrWhiteSpace(_lastCompactionStageSummary) ? _lastCompactionStageSummary : "기본")}" + : ""; + + var compactSession = _sessionCompactionCount > 0 + ? $"\n세션 누적: {_sessionCompactionCount:N0}회 (자동 {_sessionAutomaticCompactionCount:N0} / 수동 {_sessionManualCompactionCount:N0})\n" + + $"세션 절감: {Services.TokenEstimator.Format(_sessionCompactionSavedTokens)} tokens\n" + + $"세션 메모리 {_sessionMemoryCompactionCount:N0}회 · 경계 {_sessionMicrocompactBoundaryCount:N0}건 · snip {_sessionSnipCompactionCount:N0}건" + : ""; + var postCompactionUsage = _sessionPostCompactionResponseCount > 0 + ? $"\ncompact 이후 응답: {_sessionPostCompactionResponseCount:N0}회\n" + + $"compact 이후 사용량: {Services.TokenEstimator.Format(_sessionPostCompactionPromptTokens)} + {Services.TokenEstimator.Format(_sessionPostCompactionCompletionTokens)} = {Services.TokenEstimator.Format(_sessionPostCompactionPromptTokens + _sessionPostCompactionCompletionTokens)} tokens" + : ""; + var pendingPostCompaction = _pendingPostCompaction ? "\ncompact 후 첫 응답 대기 중" : ""; + var currentModelUsageText = !string.IsNullOrWhiteSpace(currentModelUsageKey) + ? $"\n현재 모델: {currentService} · {currentModel}\n" + + $"오늘 모델 사용량: {FormatTokenCount(currentModelPromptTokens)} + {FormatTokenCount(currentModelCompletionTokens)} = {FormatTokenCount(currentModelTotalTokens)} tokens" + + (currentModelPostTotalTokens > 0 + ? $"\n현재 모델 compact 이후: {FormatTokenCount(currentModelPostPromptTokens)} + {FormatTokenCount(currentModelPostCompletionTokens)} = {FormatTokenCount(currentModelPostTotalTokens)} tokens" + : "") + : ""; + var topModelsText = BuildTopModelUsageSummary(todayUsage); + + TokenUsageCard.ToolTip = + $"상태: {summary}\n" + + $"사용량: {currentTokens:N0} / {maxContextTokens:N0} tokens ({percentText})\n" + + $"간단 표기: {Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}\n" + + $"자동 압축 시작: {triggerPercent}%\n" + + $"현재 입력 초안 포함" + + currentModelUsageText + + topModelsText + + compactHistory + + compactSession + + postCompactionUsage + + pendingPostCompaction; + + UpdateCircularUsageArc(TokenUsageArc, usageRatio, 18, 18, 14); + PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 18, 18, 14, 3); + } + + private static string BuildUsageModelKey(string? service, string? model) + { + var normalizedService = (service ?? "").Trim().ToLowerInvariant(); + var normalizedModel = (model ?? "").Trim(); + if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel)) + return ""; + return $"{normalizedService}:{normalizedModel}"; + } + + private static long GetUsageValue(Dictionary? source, string key) + { + if (source == null || string.IsNullOrWhiteSpace(key)) + return 0; + return source.TryGetValue(key, out var value) ? value : 0; + } + + private string BuildTopModelUsageSummary(DailyUsageStats todayUsage) + { + if (todayUsage.ModelPromptTokens.Count == 0 && todayUsage.ModelCompletionTokens.Count == 0) + return ""; + + var totals = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in todayUsage.ModelPromptTokens) + totals[pair.Key] = pair.Value; + foreach (var pair in todayUsage.ModelCompletionTokens) + totals[pair.Key] = totals.TryGetValue(pair.Key, out var current) ? current + pair.Value : pair.Value; + + var topEntries = totals + .OrderByDescending(pair => pair.Value) + .Take(3) + .Select(pair => + { + var label = pair.Key.Replace(":", " · "); + var postPrompt = GetUsageValue(todayUsage.PostCompactionPromptTokens, pair.Key); + var postCompletion = GetUsageValue(todayUsage.PostCompactionCompletionTokens, pair.Key); + var postTotal = postPrompt + postCompletion; + return postTotal > 0 + ? $"- {label}: {FormatTokenCount(pair.Value)} tokens (compact 이후 {FormatTokenCount(postTotal)})" + : $"- {label}: {FormatTokenCount(pair.Value)} tokens"; + }) + .ToList(); + + return topEntries.Count == 0 + ? "" + : "\n오늘 상위 모델\n" + string.Join("\n", topEntries); + } + + private static string FormatTokenCount(long value) + { + if (value <= int.MaxValue) + return Services.TokenEstimator.Format((int)value); + + if (value >= 1_000_000) + return $"{value / 1_000_000d:0.#}M"; + if (value >= 1_000) + return $"{value / 1_000d:0.#}K"; + return value.ToString("N0"); + } + + private static void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius) + { + ratio = Math.Clamp(ratio, 0, 0.9999); + if (ratio <= 0) + { + path.Data = Geometry.Empty; + return; + } + + var start = GetCirclePoint(centerX, centerY, radius, -90); + var end = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90); + var figure = new PathFigure + { + StartPoint = start, + IsClosed = false, + IsFilled = false, + }; + figure.Segments.Add(new ArcSegment + { + Point = end, + Size = new Size(radius, radius), + SweepDirection = SweepDirection.Clockwise, + IsLargeArc = ratio >= 0.5, + }); + path.Data = new PathGeometry(new[] { figure }); + } + + private static void PositionThresholdMarker(FrameworkElement marker, double ratio, double centerX, double centerY, double radius, double halfSize) + { + ratio = Math.Clamp(ratio, 0, 0.9999); + var point = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90); + Canvas.SetLeft(marker, point.X - halfSize); + Canvas.SetTop(marker, point.Y - halfSize); + } + + private static Point GetCirclePoint(double centerX, double centerY, double radius, double angleDegrees) + { + var radians = angleDegrees * Math.PI / 180.0; + return new Point( + centerX + radius * Math.Cos(radians), + centerY + radius * Math.Sin(radians)); + } + + private void RecordCompactionStats(ContextCompactionResult result, bool wasAutomatic) + { + var beforeTokens = Math.Max(0, result.BeforeTokens); + var afterTokens = Math.Max(0, result.AfterTokens); + var savedTokens = Math.Max(0, beforeTokens - afterTokens); + + _lastCompactionBeforeTokens = beforeTokens; + _lastCompactionAfterTokens = afterTokens; + _lastCompactionAt = DateTime.Now; + _lastCompactionWasAutomatic = wasAutomatic; + _lastCompactionStageSummary = result.StageSummary; + _sessionCompactionCount++; + _sessionCompactionSavedTokens += savedTokens; + _sessionMemoryCompactionCount += result.SessionMemoryApplied ? 1 : 0; + _sessionMicrocompactBoundaryCount += result.MicrocompactBoundaryCount; + _sessionSnipCompactionCount += result.SnippedMessageCount + result.CollapsedBoundaryCount; + _pendingPostCompaction = true; + if (wasAutomatic) + _sessionAutomaticCompactionCount++; + else + _sessionManualCompactionCount++; + + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) + return; + + conv.CompactionCount = _sessionCompactionCount; + conv.AutomaticCompactionCount = _sessionAutomaticCompactionCount; + conv.ManualCompactionCount = _sessionManualCompactionCount; + conv.CompactionSavedTokens = _sessionCompactionSavedTokens; + conv.SessionMemoryCompactionCount = _sessionMemoryCompactionCount; + conv.MicrocompactBoundaryCount = _sessionMicrocompactBoundaryCount; + conv.SnipCompactionCount = _sessionSnipCompactionCount; + conv.LastCompactionAt = _lastCompactionAt; + conv.LastCompactionWasAutomatic = wasAutomatic; + conv.LastCompactionBeforeTokens = beforeTokens; + conv.LastCompactionAfterTokens = afterTokens; + conv.LastCompactionStageSummary = _lastCompactionStageSummary; + conv.PendingPostCompaction = true; + try { _storage.Save(conv); } catch { } + } + + private void ConsumePostCompactionUsageIfNeeded(int promptTokens, int completionTokens) + { + if (!_pendingPostCompaction) + return; + + _pendingPostCompaction = false; + _sessionPostCompactionResponseCount++; + _sessionPostCompactionPromptTokens += Math.Max(0, promptTokens); + _sessionPostCompactionCompletionTokens += Math.Max(0, completionTokens); + + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) + return; + + conv.PendingPostCompaction = false; + conv.PostCompactionResponseCount = _sessionPostCompactionResponseCount; + conv.PostCompactionPromptTokens = _sessionPostCompactionPromptTokens; + conv.PostCompactionCompletionTokens = _sessionPostCompactionCompletionTokens; + try { _storage.Save(conv); } catch { } + } + + private void ScheduleGitBranchRefresh(int delayMs = 400) + { + if (BtnGitBranch == null) + return; + + _gitRefreshTimer.Stop(); + _gitRefreshTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(100, delayMs)); + _gitRefreshTimer.Start(); + } + + private async Task RefreshGitBranchStatusAsync() + { + var folder = GetCurrentWorkFolder(); + if (_activeTab == "Chat" || string.IsNullOrWhiteSpace(folder)) + { + UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed); + return; + } + + var gitRoot = ResolveGitRoot(folder); + var gitPath = FindGitExecutablePath(); + if (string.IsNullOrWhiteSpace(gitRoot) || string.IsNullOrWhiteSpace(gitPath)) + { + UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed); + return; + } + + _gitStatusRefreshCts?.Cancel(); + _gitStatusRefreshCts?.Dispose(); + _gitStatusRefreshCts = new CancellationTokenSource(); + var ct = _gitStatusRefreshCts.Token; + + try + { + var branchTask = RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, ct); + var statusTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct); + var diffTask = RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat", "HEAD" }, ct); + var branchesTask = RunGitAsync(gitPath, gitRoot, new[] { "branch", "--format", "%(refname:short)" }, ct); + var upstreamTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "-sb" }, ct); + await Task.WhenAll(branchTask, statusTask, diffTask, branchesTask, upstreamTask); + + var branchResult = await branchTask; + var statusResult = await statusTask; + var diffResult = await diffTask; + var branchesResult = await branchesTask; + var upstreamResult = await upstreamTask; + + if (branchResult.ExitCode != 0 || statusResult.ExitCode != 0) + { + UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed); + return; + } + + var branchName = branchResult.StdOut.Trim(); + if (string.IsNullOrWhiteSpace(branchName)) + branchName = "detached"; + + var fileCount = statusResult.StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Length; + + var branches = branchesResult.ExitCode == 0 + ? branchesResult.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x.Equals(branchName, StringComparison.OrdinalIgnoreCase) ? "" : x, StringComparer.OrdinalIgnoreCase) + .ToList() + : new List(); + + var diffText = diffResult.ExitCode == 0 ? diffResult.StdOut : ""; + if (string.IsNullOrWhiteSpace(diffText)) + { + var fallbackDiff = await RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat" }, ct); + diffText = fallbackDiff.ExitCode == 0 ? fallbackDiff.StdOut : ""; + } + + var insertions = ParseGitShortStat(diffText, "insertion"); + var deletions = ParseGitShortStat(diffText, "deletion"); + + var filesText = fileCount == 0 && insertions == 0 && deletions == 0 + ? "깨끗함" + : $"{fileCount:N0}개 파일"; + var addedText = insertions > 0 ? $"+{insertions:N0}" : ""; + var deletedText = deletions > 0 ? $"-{deletions:N0}" : ""; + var tooltip = $"브랜치: {branchName}\n작업 폴더: {gitRoot}\n변경 파일: {fileCount:N0}\n추가 라인: +{insertions:N0}\n삭제 라인: -{deletions:N0}"; + _currentGitRoot = gitRoot; + _currentGitChangedFileCount = fileCount; + _currentGitInsertions = insertions; + _currentGitDeletions = deletions; + _currentGitBranches = branches; + _currentGitUpstreamStatus = ExtractGitUpstreamStatus(upstreamResult.StdOut); + + UpdateGitBranchUi(branchName, filesText, addedText, deletedText, tooltip, Visibility.Visible); + } + catch (OperationCanceledException) + { + } + catch + { + UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed); + } + } + + private void UpdateGitBranchUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility) + { + Dispatcher.Invoke(() => + { + _currentGitBranchName = branchName; + _currentGitTooltip = tooltip; + + if (BtnGitBranch != null) + { + BtnGitBranch.Visibility = visibility; + BtnGitBranch.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? "현재 Git 브랜치 상태" : tooltip; + } + + if (GitBranchLabel != null) + GitBranchLabel.Text = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : $"브랜치 {branchName}"; + if (GitBranchFilesText != null) + GitBranchFilesText.Text = filesText; + if (GitBranchAddedText != null) + GitBranchAddedText.Text = addedText; + if (GitBranchDeletedText != null) + GitBranchDeletedText.Text = deletedText; + if (GitBranchSeparator != null) + GitBranchSeparator.Visibility = visibility; + }); + } + + private static int ParseGitShortStat(string text, string unit) + { + if (string.IsNullOrWhiteSpace(text)) + return 0; + + var match = System.Text.RegularExpressions.Regex.Match( + text, + $@"(\d+)\s+{unit}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return match.Success && int.TryParse(match.Groups[1].Value, out var value) ? value : 0; + } + + private static string? ExtractGitUpstreamStatus(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? ""; + var start = firstLine.IndexOf('[', StringComparison.Ordinal); + var end = firstLine.IndexOf(']', StringComparison.Ordinal); + if (start >= 0 && end > start) + return firstLine[(start + 1)..end]; + return null; + } + + private void BuildGitBranchPopup() + { + if (GitBranchItems == null) + return; + + GitBranchItems.Children.Clear(); + + var gitRoot = _currentGitRoot ?? ResolveGitRoot(GetCurrentWorkFolder()); + var branchName = _currentGitBranchName ?? "detached"; + var tooltip = _currentGitTooltip ?? ""; + var fileText = GitBranchFilesText?.Text ?? ""; + var addedText = GitBranchAddedText?.Text ?? ""; + var deletedText = GitBranchDeletedText?.Text ?? ""; + var query = (_gitBranchSearchText ?? "").Trim(); + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + GitBranchItems.Children.Add(CreatePopupSummaryStrip(new[] + { + ("브랜치", string.IsNullOrWhiteSpace(branchName) ? "없음" : branchName, "#F8FAFC", "#E2E8F0", "#475569"), + ("파일", string.IsNullOrWhiteSpace(fileText) ? "0" : fileText, "#EFF6FF", "#BFDBFE", "#1D4ED8"), + ("최근", _recentGitBranches.Count.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"), + })); + GitBranchItems.Children.Add(CreatePopupSectionLabel("현재 브랜치", new Thickness(8, 6, 8, 4))); + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uE943", + branchName, + string.IsNullOrWhiteSpace(fileText) ? "현재 브랜치" : fileText, + true, + accentBrush, + secondaryText, + primaryText, + () => { })); + + if (!string.IsNullOrWhiteSpace(addedText) || !string.IsNullOrWhiteSpace(deletedText)) + { + var stats = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(8, 2, 8, 8), + }; + if (!string.IsNullOrWhiteSpace(addedText)) + stats.Children.Add(CreateMetricPill(addedText, "#16A34A")); + if (!string.IsNullOrWhiteSpace(deletedText)) + stats.Children.Add(CreateMetricPill(deletedText, "#DC2626")); + GitBranchItems.Children.Add(stats); + } + + if (!string.IsNullOrWhiteSpace(gitRoot)) + { + GitBranchItems.Children.Add(CreatePopupSectionLabel("저장소", new Thickness(8, 6, 8, 4))); + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uED25", + System.IO.Path.GetFileName(gitRoot.TrimEnd('\\', '/')), + gitRoot, + false, + accentBrush, + secondaryText, + primaryText, + () => { })); + } + + if (!string.IsNullOrWhiteSpace(_currentGitUpstreamStatus)) + { + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uE8AB", + "업스트림", + _currentGitUpstreamStatus!, + false, + accentBrush, + secondaryText, + primaryText, + () => { })); + } + + GitBranchItems.Children.Add(CreatePopupSectionLabel("빠른 작업", new Thickness(8, 10, 8, 4))); + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uE8C8", + "상태 요약 복사", + "브랜치, 변경 파일, 추가/삭제 라인 복사", + false, + accentBrush, + secondaryText, + primaryText, + () => + { + try { Clipboard.SetText(tooltip); } catch { } + GitBranchPopup.IsOpen = false; + })); + + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uE72B", + "새로고침", + "Git 상태를 다시 조회합니다", + false, + accentBrush, + secondaryText, + primaryText, + async () => + { + await RefreshGitBranchStatusAsync(); + BuildGitBranchPopup(); + })); + + var filteredBranches = _currentGitBranches + .Where(branch => string.IsNullOrWhiteSpace(query) + || branch.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(20) + .ToList(); + + var recentBranches = _recentGitBranches + .Where(branch => _currentGitBranches.Any(current => string.Equals(current, branch, StringComparison.OrdinalIgnoreCase))) + .Where(branch => string.IsNullOrWhiteSpace(query) + || branch.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(5) + .ToList(); + + if (recentBranches.Count > 0) + { + var recentSectionLabel = string.IsNullOrWhiteSpace(query) + ? $"최근 전환 · {recentBranches.Count}" + : $"최근 전환 · {recentBranches.Count}"; + GitBranchItems.Children.Add(CreatePopupSectionLabel(recentSectionLabel, new Thickness(8, 10, 8, 4))); + + foreach (var branch in recentBranches) + { + var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase); + GitBranchItems.Children.Add(CreatePopupMenuRow( + isCurrent ? "\uE73E" : "\uE8FD", + branch, + isCurrent ? "현재 브랜치" : "최근 사용 브랜치", + isCurrent, + accentBrush, + secondaryText, + primaryText, + isCurrent ? null : () => _ = SwitchGitBranchAsync(branch))); + } + } + + if (_currentGitBranches.Count > 0) + { + var branchSectionLabel = string.IsNullOrWhiteSpace(query) + ? $"브랜치 전환 · {_currentGitBranches.Count}" + : $"브랜치 전환 · {filteredBranches.Count}/{_currentGitBranches.Count}"; + GitBranchItems.Children.Add(CreatePopupSectionLabel(branchSectionLabel, new Thickness(8, 10, 8, 4))); + + foreach (var branch in filteredBranches) + { + if (recentBranches.Any(recent => string.Equals(recent, branch, StringComparison.OrdinalIgnoreCase))) + continue; + + var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase); + GitBranchItems.Children.Add(CreatePopupMenuRow( + isCurrent ? "\uE73E" : "\uE943", + branch, + isCurrent ? "현재 브랜치" : "이 브랜치로 전환", + isCurrent, + accentBrush, + secondaryText, + primaryText, + isCurrent ? null : () => _ = SwitchGitBranchAsync(branch))); + } + + if (!string.IsNullOrWhiteSpace(query) && filteredBranches.Count == 0) + { + GitBranchItems.Children.Add(new TextBlock + { + Text = "검색 결과가 없습니다.", + FontSize = 11.5, + Foreground = secondaryText, + Margin = new Thickness(10, 6, 10, 10), + }); + } + } + + GitBranchItems.Children.Add(CreatePopupSectionLabel("브랜치 작업", new Thickness(8, 10, 8, 4))); + GitBranchItems.Children.Add(CreatePopupMenuRow( + "\uE710", + "새 브랜치 생성", + "현재 작업 기준으로 새 브랜치를 만들고 전환합니다", + false, + accentBrush, + secondaryText, + primaryText, + () => _ = CreateGitBranchAsync())); + } + + private TextBlock CreatePopupSectionLabel(string text, Thickness? margin = null) + { + return new TextBlock + { + Text = text, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = margin ?? new Thickness(8, 8, 8, 4), + }; + } + + private UIElement CreatePopupSummaryStrip(IEnumerable<(string Label, string Value, string BgHex, string BorderHex, string FgHex)> items) + { + var wrap = new WrapPanel + { + Margin = new Thickness(8, 6, 8, 6), + }; + + foreach (var item in items) + { + wrap.Children.Add(CreateMetricPill($"{item.Label} {item.Value}", item.FgHex, item.BgHex, item.BorderHex)); + } + + return wrap; + } + + private Border CreateMetricPill(string text, string colorHex) + => CreateMetricPill(text, colorHex, $"{colorHex}18", $"{colorHex}44"); + + private Border CreateMetricPill(string text, string colorHex, string bgHex, string borderHex) + { + return new Border + { + Background = BrushFromHex(bgHex), + BorderBrush = BrushFromHex(borderHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 0), + Child = new TextBlock + { + Text = text, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(colorHex), + } + }; + } + + private Border CreateFlatPopupRow(string icon, string title, string description, string colorHex, bool clickable, Action? onClick) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var border = new Border + { + Background = Brushes.Transparent, + BorderBrush = borderColor, + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(8, 9, 8, 9), + Cursor = clickable ? Cursors.Hand : Cursors.Arrow, + Focusable = clickable, + }; + KeyboardNavigation.SetIsTabStop(border, clickable); + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + grid.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = BrushFromHex(colorHex), + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 1, 10, 0), + }); + + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = title, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + if (!string.IsNullOrWhiteSpace(description)) + { + textStack.Children.Add(new TextBlock + { + Text = description, + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(0, 2, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + Grid.SetColumn(textStack, 1); + grid.Children.Add(textStack); + if (clickable) + { + var chevron = new TextBlock + { + Text = "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }; + Grid.SetColumn(chevron, 2); + grid.Children.Add(chevron); + } + border.Child = grid; + + if (clickable && onClick != null) + { + border.MouseEnter += (_, _) => border.Background = hoverBrush; + border.MouseLeave += (_, _) => border.Background = Brushes.Transparent; + border.MouseLeftButtonUp += (_, _) => onClick(); + border.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + onClick(); + } + }; + } + + return border; + } + + private async Task SwitchGitBranchAsync(string branchName) + { + if (string.IsNullOrWhiteSpace(branchName) || string.IsNullOrWhiteSpace(_currentGitRoot)) + return; + + var gitPath = FindGitExecutablePath(); + if (string.IsNullOrWhiteSpace(gitPath)) + return; + + try + { + var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", branchName }, CancellationToken.None); + if (result.ExitCode != 0) + { + CustomMessageBox.Show($"브랜치 전환에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + TrackRecentGitBranch(branchName); + GitBranchPopup.IsOpen = false; + await RefreshGitBranchStatusAsync(); + SetStatus($"브랜치 전환: {branchName}", spinning: false); + } + catch (Exception ex) + { + CustomMessageBox.Show($"브랜치 전환 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private async Task CreateGitBranchAsync() + { + if (string.IsNullOrWhiteSpace(_currentGitRoot)) + return; + + var dlg = new Views.InputDialog("새 브랜치 생성", "브랜치 이름:", "", "feature/my-change") { Owner = this }; + if (dlg.ShowDialog() != true) + return; + + var branchName = (dlg.ResponseText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(branchName)) + return; + + var gitPath = FindGitExecutablePath(); + if (string.IsNullOrWhiteSpace(gitPath)) + return; + + try + { + var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", "-c", branchName }, CancellationToken.None); + if (result.ExitCode != 0) + { + CustomMessageBox.Show($"브랜치 생성에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + TrackRecentGitBranch(branchName); + GitBranchPopup.IsOpen = false; + await RefreshGitBranchStatusAsync(); + SetStatus($"새 브랜치 생성: {branchName}", spinning: false); + } + catch (Exception ex) + { + CustomMessageBox.Show($"브랜치 생성 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private static string TruncateForStatus(string? text, int max = 40) + { + if (string.IsNullOrEmpty(text)) return ""; + return text.Length <= max ? text : text[..max] + "…"; + } + + private string BuildComposerDraftText() + { + var rawText = InputBox?.Text?.Trim() ?? ""; + return _slashPalette.ActiveCommand != null + ? (_slashPalette.ActiveCommand + " " + rawText).Trim() + : rawText; + } + + private static string InferDraftKind(string text, string? explicitKind = null) + { + var trimmed = text?.Trim() ?? ""; + var requestedKind = explicitKind?.Trim().ToLowerInvariant(); + + if (requestedKind is "followup" or "steering") + return requestedKind; + + if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase)) + return "command"; + + if (requestedKind is "direct" or "message") + return requestedKind; + + if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase)) + return "steering"; + + return "message"; + } + + private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true) + { + if (InputBox == null) + return; + + var text = BuildComposerDraftText(); + if (string.IsNullOrWhiteSpace(text)) + return; + + if (_isStreaming && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase)) + priority = "next"; + + HideSlashChip(restoreText: false); + ClearPromptCardPlaceholder(); + + DraftQueueItem? queuedItem = null; + lock (_convLock) + { + var session = _appState.ChatSession; + if (session != null) + _currentConversation = (queuedItem = session.EnqueueDraft( + _activeTab, + text, + priority, + _storage, + InferDraftKind(text, explicitKind))) != null + ? session.CurrentConversation + : _currentConversation; + } + + InputBox.Clear(); + InputBox.Focus(); + UpdateInputBoxHeight(); + RefreshDraftQueueUi(); + + if (queuedItem == null) + return; + + if (!_isStreaming && startImmediatelyWhenIdle) + { + StartNextQueuedDraftIfAny(queuedItem.Id); + return; + } + + var toast = queuedItem.Kind switch + { + "command" => "명령이 대기열에 추가되었습니다.", + "direct" => "직접 실행 요청이 대기열에 추가되었습니다.", + "steering" => "조정 요청이 대기열에 추가되었습니다.", + "followup" => "후속 작업이 대기열에 추가되었습니다.", + _ => "메시지가 대기열에 추가되었습니다.", + }; + ShowToast(toast); + } + + // ─── 헬퍼 ───────────────────────────────────────────────────────────── + private Popup? _taskSummaryPopup; + private UIElement? _taskSummaryTarget; + private string _taskSummaryTaskFilter = "all"; + + private void BtnDraftEnqueue_Click(object sender, RoutedEventArgs e) + { + var text = InputBox?.Text?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + return; + + QueueComposerDraft( + priority: Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ? "now" : "next", + explicitKind: "steering", + startImmediatelyWhenIdle: true); + } + + private void BtnDraftEdit_Click(object sender, RoutedEventArgs e) + { + if (InputBox == null) + return; + + InputBox.Focus(); + InputBox.CaretIndex = InputBox.Text?.Length ?? 0; + } + + private void BtnDraftClear_Click(object sender, RoutedEventArgs e) + { + if (InputBox == null) + return; + + InputBox.Clear(); + InputBox.Focus(); + UpdateInputBoxHeight(); + RefreshDraftQueueUi(); + } + + private void RefreshDraftQueueUi() + { + if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null) + return; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + _draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage); + } + + var summary = _appState.GetDraftQueueSummary(_activeTab); + var items = _appState.GetDraftQueueItems(_activeTab); + + DraftPreviewCard.Visibility = Visibility.Collapsed; + BtnDraftEnqueue.IsEnabled = false; + DraftPreviewText.Text = string.Empty; + + RebuildDraftQueuePanel(items); + } + + private void RebuildDraftQueuePanel(IReadOnlyList items) + { + if (DraftQueuePanel == null) + return; + + DraftQueuePanel.Children.Clear(); + + var visibleItems = items + .OrderBy(GetDraftStateRank) + .ThenBy(GetDraftPriorityRank) + .ThenBy(x => x.CreatedAt) + .ToList(); + + if (visibleItems.Count == 0) + { + DraftQueuePanel.Visibility = Visibility.Collapsed; + return; + } + + DraftQueuePanel.Visibility = Visibility.Visible; + var summary = _appState.GetDraftQueueSummary(_activeTab); + DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary)); + const int maxPerSection = 3; + var runningItems = visibleItems + .Where(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase)) + .Take(maxPerSection) + .ToList(); + var queuedItems = visibleItems + .Where(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item)) + .Take(maxPerSection) + .ToList(); + var blockedItems = visibleItems + .Where(IsDraftBlocked) + .Take(maxPerSection) + .ToList(); + var completedItems = visibleItems + .Where(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase)) + .Take(maxPerSection) + .ToList(); + var failedItems = visibleItems + .Where(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase)) + .Take(maxPerSection) + .ToList(); + + AddDraftQueueSection("실행 중", runningItems, summary.RunningCount); + AddDraftQueueSection("다음 작업", queuedItems, summary.QueuedCount); + AddDraftQueueSection("보류", blockedItems, summary.BlockedCount); + AddDraftQueueSection("완료", completedItems, summary.CompletedCount); + AddDraftQueueSection("실패", failedItems, summary.FailedCount); + + if (summary.CompletedCount > 0 || summary.FailedCount > 0) + { + var footer = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 2, 0, 0), + }; + + if (summary.CompletedCount > 0) + footer.Children.Add(CreateDraftQueueActionButton($"완료 정리 {summary.CompletedCount}", ClearCompletedDrafts, BrushFromHex("#ECFDF5"))); + + if (summary.FailedCount > 0) + footer.Children.Add(CreateDraftQueueActionButton($"실패 정리 {summary.FailedCount}", ClearFailedDrafts, BrushFromHex("#FEF2F2"))); + + DraftQueuePanel.Children.Add(footer); + } + } + + private void AddDraftQueueSection(string label, IReadOnlyList items, int totalCount) + { + if (DraftQueuePanel == null || totalCount <= 0) + return; + + DraftQueuePanel.Children.Add(CreateDraftQueueSectionLabel($"{label} · {totalCount}")); + foreach (var item in items) + DraftQueuePanel.Children.Add(CreateDraftQueueCard(item)); + + if (totalCount > items.Count) + { + DraftQueuePanel.Children.Add(new TextBlock + { + Text = $"추가 항목 {totalCount - items.Count}개", + Margin = new Thickness(8, -2, 0, 8), + FontSize = 10.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#7A7F87"), + }); + } + } + + private UIElement CreateDraftQueueSummaryStrip(AppStateService.DraftQueueSummaryState summary) + { + var wrap = new WrapPanel + { + Margin = new Thickness(0, 0, 0, 8), + }; + + if (summary.RunningCount > 0) + wrap.Children.Add(CreateQueueSummaryPill("실행 중", summary.RunningCount.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8")); + if (summary.QueuedCount > 0) + wrap.Children.Add(CreateQueueSummaryPill("다음", summary.QueuedCount.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9")); + if (summary.BlockedCount > 0) + wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C")); + if (summary.CompletedCount > 0) + wrap.Children.Add(CreateQueueSummaryPill("완료", summary.CompletedCount.ToString(), "#ECFDF5", "#BBF7D0", "#166534")); + if (summary.FailedCount > 0) + wrap.Children.Add(CreateQueueSummaryPill("실패", summary.FailedCount.ToString(), "#FEF2F2", "#FECACA", "#991B1B")); + + if (wrap.Children.Count == 0) + wrap.Children.Add(CreateQueueSummaryPill("대기열", "0", "#F8FAFC", "#E2E8F0", "#475569")); + + return wrap; + } + + private Border CreateQueueSummaryPill(string label, string value, string bgHex, string borderHex, string fgHex) + { + return new Border + { + Background = BrushFromHex(bgHex), + BorderBrush = BrushFromHex(borderHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 0), + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = label, + FontSize = 10, + Foreground = BrushFromHex(fgHex), + }, + new TextBlock + { + Text = $" {value}", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(fgHex), + } + } + } + }; + } + + private TextBlock CreateDraftQueueSectionLabel(string text) + { + return new TextBlock + { + Text = text, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(8, 0, 8, 6), + Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"), + }; + } + + private Border CreateDraftQueueCard(DraftQueueItem item) + { + var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8"); + var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7"); + var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"); + var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280"); + var neutralSurface = BrushFromHex("#F5F6F8"); + var (kindIcon, kindForeground) = GetDraftKindVisual(item); + var (stateBackground, stateBorder, stateForeground) = GetDraftStateBadgeColors(item); + var (priorityBackground, priorityBorder, priorityForeground) = GetDraftPriorityBadgeColors(item.Priority); + + var container = new Border + { + Background = background, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(0, 0, 0, 8), + }; + + var root = new Grid(); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + container.Child = root; + + var left = new StackPanel(); + Grid.SetColumn(left, 0); + root.Children.Add(left); + + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + }; + header.Children.Add(new TextBlock + { + Text = kindIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = kindForeground, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0), + }); + header.Children.Add(CreateDraftQueueBadge(GetDraftKindLabel(item), BrushFromHex("#F8FAFC"), borderBrush, kindForeground)); + header.Children.Add(CreateDraftQueueBadge(GetDraftStateLabel(item), stateBackground, stateBorder, stateForeground)); + header.Children.Add(CreateDraftQueueBadge(GetDraftPriorityLabel(item.Priority), priorityBackground, priorityBorder, priorityForeground)); + left.Children.Add(header); + + left.Children.Add(new TextBlock + { + Text = item.Text, + FontSize = 12.5, + Foreground = primaryText, + Margin = new Thickness(0, 6, 0, 0), + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 520, + }); + + var meta = $"{item.CreatedAt:HH:mm}"; + if (item.AttemptCount > 0) + meta += $" · 시도 {item.AttemptCount}"; + if (item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now) + meta += $" · 재시도 {item.NextRetryAt.Value:HH:mm:ss}"; + if (!string.IsNullOrWhiteSpace(item.LastError)) + meta += $" · {TruncateForStatus(item.LastError, 36)}"; + + left.Children.Add(new TextBlock + { + Text = meta, + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(0, 6, 0, 0), + }); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Top, + }; + Grid.SetColumn(actions, 1); + root.Children.Add(actions); + + if (!string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase)) + actions.Children.Add(CreateDraftQueueActionButton("실행", () => QueueDraftForImmediateRun(item.Id))); + + if (string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) || + string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase)) + { + actions.Children.Add(CreateDraftQueueActionButton("대기", () => ResetDraftInQueue(item.Id), neutralSurface)); + } + + actions.Children.Add(CreateDraftQueueActionButton("삭제", () => RemoveDraftFromQueue(item.Id), neutralSurface)); + return container; + } + + private Border CreateDraftQueueBadge(string text, Brush background, Brush borderBrush, Brush foreground) + { + return new Border + { + Background = background, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(7, 2, 7, 2), + Margin = new Thickness(0, 0, 6, 0), + Child = new TextBlock + { + Text = text, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = foreground, + } + }; + } + + private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null) + { + var btn = new Button + { + Content = label, + Margin = new Thickness(6, 0, 0, 0), + Padding = new Thickness(10, 5, 10, 5), + MinWidth = 48, + FontSize = 11, + Background = background ?? BrushFromHex("#EEF2FF"), + BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#D4D4D8"), + BorderThickness = new Thickness(1), + Foreground = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"), + Cursor = Cursors.Hand, + }; + btn.Click += (_, _) => onClick(); + return btn; + } + + private void QueueDraftForImmediateRun(string draftId) + { + if (_isStreaming) + { + ShowToast("현재 작업이 끝난 뒤 실행할 수 있습니다.", "\uE783"); + return; + } + + StartNextQueuedDraftIfAny(draftId); + } + + private void ResetDraftInQueue(string draftId) + { + lock (_convLock) + { + var session = ChatSession; + if (session != null && session.ResetDraftToQueued(_activeTab, draftId, _storage)) + _currentConversation = session.CurrentConversation; + } + + RefreshDraftQueueUi(); + } + + private void RemoveDraftFromQueue(string draftId) + { + lock (_convLock) + { + var session = ChatSession; + if (session != null && session.RemoveDraft(_activeTab, draftId, _storage)) + _currentConversation = session.CurrentConversation; + } + + RefreshDraftQueueUi(); + } + + private void ClearCompletedDrafts() + { + int removed; + lock (_convLock) + { + removed = _draftQueueProcessor.ClearCompleted(ChatSession, _activeTab, _storage); + _currentConversation = ChatSession?.CurrentConversation ?? _currentConversation; + } + + RefreshDraftQueueUi(); + if (removed > 0) + ShowToast($"완료된 대기열 {removed}개 정리됨"); + } + + private void ClearFailedDrafts() + { + int removed; + lock (_convLock) + { + removed = _draftQueueProcessor.ClearFailed(ChatSession, _activeTab, _storage); + _currentConversation = ChatSession?.CurrentConversation ?? _currentConversation; + } + + RefreshDraftQueueUi(); + if (removed > 0) + ShowToast($"실패한 대기열 {removed}개 정리됨"); + } + + private void StartNextQueuedDraftIfAny(string? preferredDraftId = null) + { + if (_isStreaming || InputBox == null) + return; + + DraftQueueItem? next = null; + lock (_convLock) + { + var session = ChatSession; + next = _draftQueueProcessor.TryStartNext(session, _activeTab, _storage, preferredDraftId, _appState.TaskRuns); + if (next == null) + return; + + _runningDraftId = next.Id; + _currentConversation = session?.CurrentConversation ?? _currentConversation; + } + + InputBox.Text = next.Text; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + UpdateInputBoxHeight(); + RefreshDraftQueueUi(); + _ = SendMessageAsync(); + } + + private static int GetDraftStateRank(DraftQueueItem item) + => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0 + : IsDraftBlocked(item) ? 1 + : string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2 + : string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3 + : 4; + + private static int GetDraftPriorityRank(DraftQueueItem item) + => item.Priority?.ToLowerInvariant() switch + { + "now" => 0, + "next" => 1, + _ => 2, + }; + + private static string GetDraftPriorityLabel(string? priority) + => priority?.ToLowerInvariant() switch + { + "now" => "지금", + "later" => "나중", + _ => "다음", + }; + + private static string GetDraftKindLabel(DraftQueueItem item) + => item.Kind?.ToLowerInvariant() switch + { + "followup" => "후속 작업", + "steering" => "조정", + "command" => "명령", + "direct" => "직접 실행", + _ => "메시지", + }; + + private (string Icon, Brush Foreground) GetDraftKindVisual(DraftQueueItem item) + => item.Kind?.ToLowerInvariant() switch + { + "followup" => ("\uE8A5", BrushFromHex("#0F766E")), + "steering" => ("\uE7C3", BrushFromHex("#B45309")), + "command" => ("\uE756", BrushFromHex("#7C3AED")), + "direct" => ("\uE8A7", BrushFromHex("#2563EB")), + _ => ("\uE8BD", BrushFromHex("#475569")), + }; + + private static string GetDraftStateLabel(DraftQueueItem item) + => IsDraftBlocked(item) ? "재시도 대기" + : item.State?.ToLowerInvariant() switch + { + "running" => "실행 중", + "failed" => "실패", + "completed" => "완료", + _ => "대기", + }; + + private Brush GetDraftStateBrush(DraftQueueItem item) + => IsDraftBlocked(item) ? BrushFromHex("#B45309") + : item.State?.ToLowerInvariant() switch + { + "running" => BrushFromHex("#2563EB"), + "failed" => BrushFromHex("#DC2626"), + "completed" => BrushFromHex("#059669"), + _ => BrushFromHex("#7C3AED"), + }; + + private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item) + => IsDraftBlocked(item) + ? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")) + : item.State?.ToLowerInvariant() switch + { + "running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")), + "failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")), + "completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), + _ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")), + }; + + private static (Brush Background, Brush Border, Brush Foreground) GetDraftPriorityBadgeColors(string? priority) + => priority?.ToLowerInvariant() switch + { + "now" => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")), + "later" => (BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")), + _ => (BrushFromHex("#FEF3C7"), BrushFromHex("#FDE68A"), BrushFromHex("#92400E")), + }; + + private static bool IsDraftBlocked(DraftQueueItem item) + => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) + && item.NextRetryAt.HasValue + && item.NextRetryAt.Value > DateTime.Now; + + private void OpenCommandSkillBrowser(string seedInput) + { + if (InputBox == null) + return; + + if (!string.IsNullOrEmpty(_slashPalette.ActiveCommand)) + HideSlashChip(); + + InputBox.Text = seedInput; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + } + + private void BtnToggleExecutionLog_Click(object sender, RoutedEventArgs e) + { + bool visible; + lock (_convLock) + { + var session = _appState.ChatSession; + visible = session?.ToggleExecutionHistory(_activeTab, _storage) ?? !(_currentConversation?.ShowExecutionHistory ?? true); + if (session != null) + _currentConversation = session.CurrentConversation; + } + if (ExecutionLogLabel != null) + ExecutionLogLabel.Text = visible ? "실행 로그 켜짐" : "실행 로그 숨김"; + if (ExecutionLogIcon != null) + ExecutionLogIcon.Text = visible ? "\uE946" : "\uE8F8"; + + RenderMessages(); + } + + private void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + _taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge; + ShowTaskSummaryPopup(); + } + + private void ShowTaskSummaryPopup() + { + if (_taskSummaryTarget == null) + return; + + if (_taskSummaryPopup != null) + _taskSummaryPopup.IsOpen = false; + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var panel = new StackPanel { Margin = new Thickness(2) }; + panel.Children.Add(new TextBlock + { + Text = "작업 요약", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + Margin = new Thickness(10, 8, 10, 4), + }); + panel.Children.Add(new TextBlock + { + Text = "현재 실행/권한/작업 흐름", + FontSize = 10, + Foreground = secondaryText, + Margin = new Thickness(10, 0, 10, 6), + }); + var taskFilterRow = new WrapPanel + { + Margin = new Thickness(8, 0, 8, 8), + }; + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("all", "전체")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("permission", "권한")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("queue", "대기")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("tool", "도구")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("subagent", "서브")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("hook", "훅")); + panel.Children.Add(taskFilterRow); + + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + AddTaskSummaryObservabilitySections(panel, currentConversation); + + if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)) + { + var currentRun = new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E2E8F0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(8, 0, 8, 8), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = $"현재 실행 run {ShortRunId(_appState.AgentRun.RunId)}", + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }, + new TextBlock + { + Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · iteration {_appState.AgentRun.LastIteration}", + Margin = new Thickness(0, 3, 0, 0), + Foreground = GetRunStatusBrush(_appState.AgentRun.Status), + }, + new TextBlock + { + Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary, + Margin = new Thickness(0, 4, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = Brushes.DimGray, + } + } + } + }; + panel.Children.Add(currentRun); + } + + var recentAgentRuns = _appState.GetRecentAgentRuns(3); + if (recentAgentRuns.Count > 0) + { + var latestFailedRun = _appState.GetLatestFailedRun(); + if (latestFailedRun != null) + { + panel.Children.Add(new Border + { + Background = BrushFromHex("#FEF2F2"), + BorderBrush = BrushFromHex("#FECACA"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(8, 0, 8, 8), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = $"최근 실패 원인 · run {ShortRunId(latestFailedRun.RunId)}", + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#991B1B"), + }, + new TextBlock + { + Text = $"{latestFailedRun.UpdatedAt:HH:mm:ss} · iteration {latestFailedRun.LastIteration}", + Margin = new Thickness(0, 2, 0, 0), + Foreground = BrushFromHex("#B45309"), + }, + new TextBlock + { + Text = string.IsNullOrWhiteSpace(latestFailedRun.Summary) ? "요약 없음" : latestFailedRun.Summary, + Margin = new Thickness(0, 4, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + } + } + } + }); + } + + panel.Children.Add(new TextBlock + { + Text = "최근 에이전트 실행", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.DimGray, + Margin = new Thickness(10, 0, 10, 4), + }); + + foreach (var run in recentAgentRuns) + { + var runEvents = GetExecutionEventsForRun(run.RunId); + var runFilePaths = GetExecutionEventFilePaths(run.RunId); + var runDisplay = _appState.GetRunDisplay(run); + var runCardStack = new StackPanel + { + Children = + { + new TextBlock + { + Text = runDisplay.HeaderText, + FontWeight = FontWeights.SemiBold, + Foreground = GetRunStatusBrush(run.Status), + }, + new TextBlock + { + Text = runDisplay.MetaText, + Margin = new Thickness(0, 2, 0, 0), + Foreground = secondaryText, + }, + new TextBlock + { + Text = TruncateForStatus(runDisplay.SummaryText, 140), + Margin = new Thickness(0, 3, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + } + } + }; + + if (runEvents.Count > 0 || runFilePaths.Count > 0) + { + var activitySummary = new StackPanel(); + activitySummary.Children.Add(new TextBlock + { + Text = $"실행 로그 {runEvents.Count} · 관련 파일 {runFilePaths.Count}", + FontSize = 10, + Foreground = secondaryText, + }); + + if (!string.IsNullOrWhiteSpace(run.RunId)) + { + var capturedRunId = run.RunId; + var timelineButton = CreateTaskSummaryActionButton( + "타임라인 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ScrollToRunInTimeline(capturedRunId), + trailingMargin: false); + timelineButton.Margin = new Thickness(0, 6, 0, 0); + activitySummary.Children.Add(timelineButton); + } + + runCardStack.Children.Add(new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E2E8F0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 8, 0, 0), + Child = activitySummary + }); + } + + if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase)) + { + var capturedRun = run; + var followUpButton = CreateTaskSummaryActionButton( + "후속 작업 큐에 넣기", + "#ECFDF5", + "#BBF7D0", + "#166534", + (_, _) => EnqueueFollowUpFromRun(capturedRun), + trailingMargin: false); + followUpButton.Margin = new Thickness(0, 8, 0, 0); + runCardStack.Children.Add(followUpButton); + } + + if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) + { + var retryButton = CreateTaskSummaryActionButton( + "이 실행 다시 시도", + "#FEF2F2", + "#FCA5A5", + "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }, + trailingMargin: false); + retryButton.Margin = new Thickness(0, 8, 0, 0); + runCardStack.Children.Add(retryButton); + } + + panel.Children.Add(new Border + { + Background = Brushes.White, + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(9), + Padding = new Thickness(9, 6, 9, 6), + Margin = new Thickness(8, 0, 8, 6), + Child = runCardStack + }); + } + + if (_appState.GetLatestFailedRun() != null + && CanRetryCurrentConversation()) + { + var actionsPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(8, 2, 8, 8), + }; + + var retryButton = CreateTaskSummaryActionButton( + "마지막 요청 다시 시도", + "#EEF2FF", + "#C7D2FE", + "#3730A3", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }); + actionsPanel.Children.Add(retryButton); + + if (_appState.ActiveTasks.Count > 0) + { + var runningFilterButton = CreateTaskSummaryActionButton( + "진행 중 대화만 보기", + "#DBEAFE", + "#93C5FD", + "#1D4ED8", + (_, _) => + { + _runningOnlyFilter = true; + UpdateConversationRunningFilterUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + }, + trailingMargin: false); + runningFilterButton.Margin = new Thickness(2, 0, 0, 0); + actionsPanel.Children.Add(runningFilterButton); + } + panel.Children.Add(actionsPanel); + } + } + + foreach (var task in FilterTaskSummaryItems(_appState.ActiveTasks).Take(6)) + panel.Children.Add(BuildTaskSummaryCard(task, active: true)); + + foreach (var task in FilterTaskSummaryItems(_appState.RecentTasks).Take(6)) + panel.Children.Add(BuildTaskSummaryCard(task, active: false)); + + if (!FilterTaskSummaryItems(_appState.ActiveTasks).Any() && !FilterTaskSummaryItems(_appState.RecentTasks).Any()) + { + panel.Children.Add(new TextBlock + { + Text = "표시할 작업 이력이 없습니다.", + Margin = new Thickness(10, 2, 10, 8), + Foreground = secondaryText, + }); + } + + _taskSummaryPopup = new Popup + { + PlacementTarget = _taskSummaryTarget, + Placement = PlacementMode.Top, + AllowsTransparency = true, + StaysOpen = false, + PopupAnimation = PopupAnimation.Fade, + Child = new Border + { + Background = popupBackground, + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(6), + Child = new ScrollViewer + { + Content = panel, + MaxHeight = 340, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + } + } + }; + + _taskSummaryPopup.IsOpen = true; + } + + private bool CanRetryCurrentConversation() + { + return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation()); + } + + private List GetExecutionEventsForRun(string? runId, int take = 3) + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, eventTake: take).Events; + } + + private static bool IsBranchContextMessage(string? content) + { + return !string.IsNullOrWhiteSpace(content) + && content.StartsWith("이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다.", StringComparison.Ordinal); + } + + private AppStateService.AgentRunState? GetAgentRunStateById(string? runId) + => _appState.GetAgentRunById(runId); + + private AppStateService.AgentRunState? GetLatestBranchContextRun() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + return _appState.GetLatestConversationRun(conv?.AgentRunHistory); + } + + private List GetBranchContextFilePaths(string? runId, int take = 3) + { + return GetExecutionEventFilePaths(runId, take); + } + + private List GetExecutionEventFilePaths(string? runId, int take = 2) + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, fileTake: take).FilePaths; + } + + private AppStateService.RunPlanHistoryState GetRunPlanHistory(string? runId) + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + return _appState.GetRunPlanHistory(conv?.ExecutionEvents, runId); + } + + private Border? BuildRunPlanHistoryCard(AppStateService.RunPlanHistoryState history) + { + if (!history.HasAny) + return null; + + var panel = new StackPanel(); + panel.Children.Add(new TextBlock + { + Text = "계획 히스토리", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#1E3A8A"), + Margin = new Thickness(0, 0, 0, 6), + }); + + panel.Children.Add(BuildPlanHistorySection("원안", history.OriginalSummary, history.OriginalSteps)); + panel.Children.Add(BuildPlanHistorySection("수정안", history.RevisedSummary, history.RevisedSteps)); + panel.Children.Add(BuildPlanHistorySection("최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps)); + + var diffSection = BuildPlanDiffSection(history.OriginalSteps, history.FinalApprovedSteps); + if (diffSection != null) + panel.Children.Add(diffSection); + + return new Border + { + Background = BrushFromHex("#EFF6FF"), + BorderBrush = BrushFromHex("#BFDBFE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 8, 0, 0), + Child = panel, + }; + } + + private static Border BuildPlanHistorySection(string title, string? summary, IReadOnlyList steps) + { + var section = new StackPanel(); + section.Children.Add(new TextBlock + { + Text = title, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#1D4ED8"), + }); + + var hasContent = false; + if (!string.IsNullOrWhiteSpace(summary)) + { + section.Children.Add(new TextBlock + { + Text = summary, + FontSize = 10, + Foreground = Brushes.DimGray, + Margin = new Thickness(0, 2, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + hasContent = true; + } + + if (steps.Count > 0) + { + foreach (var step in steps.Take(4)) + { + section.Children.Add(new TextBlock + { + Text = $"• {step}", + FontSize = 10, + Foreground = BrushFromHex("#334155"), + Margin = new Thickness(0, 1, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + + if (steps.Count > 4) + { + section.Children.Add(new TextBlock + { + Text = $"• ... 외 {steps.Count - 4}개 단계", + FontSize = 10, + Foreground = BrushFromHex("#64748B"), + Margin = new Thickness(0, 1, 0, 0), + }); + } + hasContent = true; + } + + if (!hasContent) + { + section.Children.Add(new TextBlock + { + Text = "기록 없음", + FontSize = 10, + Foreground = BrushFromHex("#64748B"), + Margin = new Thickness(0, 2, 0, 0), + }); + } + + return new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#DBEAFE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(6, 5, 6, 5), + Margin = new Thickness(0, 0, 0, 4), + Child = section, + }; + } + + private Border? BuildPlanDiffSection(IReadOnlyList originalSteps, IReadOnlyList finalSteps) + { + if (originalSteps.Count == 0 || finalSteps.Count == 0) + return null; + + var originalNormalized = originalSteps + .Where(step => !string.IsNullOrWhiteSpace(step)) + .Select(step => step.Trim()) + .ToList(); + var finalNormalized = finalSteps + .Where(step => !string.IsNullOrWhiteSpace(step)) + .Select(step => step.Trim()) + .ToList(); + + var originalTokens = BuildPlanStepTokens(originalNormalized); + var finalTokens = BuildPlanStepTokens(finalNormalized); + var originalTokenMap = originalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal); + var finalTokenMap = finalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal); + + var added = finalTokens + .Where(token => !originalTokenMap.ContainsKey(token.Id)) + .Select(token => token.Text) + .ToList(); + var removed = originalTokens + .Where(token => !finalTokenMap.ContainsKey(token.Id)) + .Select(token => token.Text) + .ToList(); + var moved = GetMovedPlanSteps(originalTokens, finalTokenMap); + if (added.Count == 0 && removed.Count == 0 && moved.Count == 0) + return null; + + var section = new StackPanel(); + var header = new Grid(); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + header.Children.Add(new TextBlock + { + Text = "변경 포인트 (원안 ↔ 최종승인안)", + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#1E3A8A"), + }); + var severity = GetPlanDiffSeverity(added.Count, removed.Count, moved.Count, originalNormalized.Count, finalNormalized.Count); + var severityBadge = new Border + { + Background = BrushFromHex(severity.BgHex), + BorderBrush = BrushFromHex(severity.BorderHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(6, 1, 6, 1), + Child = new TextBlock + { + Text = severity.Label, + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(severity.FgHex), + } + }; + Grid.SetColumn(severityBadge, 1); + header.Children.Add(severityBadge); + section.Children.Add(header); + + foreach (var step in added.Take(4)) + { + section.Children.Add(new TextBlock + { + Text = $"+ {step}", + FontSize = 10, + Foreground = BrushFromHex("#166534"), + Margin = new Thickness(0, 1, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + + foreach (var step in removed.Take(4)) + { + section.Children.Add(new TextBlock + { + Text = $"- {step}", + FontSize = 10, + Foreground = BrushFromHex("#991B1B"), + Margin = new Thickness(0, 1, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + + foreach (var item in moved.Take(4)) + { + section.Children.Add(new TextBlock + { + Text = $"~ {item.Step} ({item.FromIndex + 1} -> {item.ToIndex + 1})", + FontSize = 10, + Foreground = BrushFromHex("#1D4ED8"), + Margin = new Thickness(0, 1, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + + var hiddenCount = Math.Max(0, added.Count - 4) + Math.Max(0, removed.Count - 4) + Math.Max(0, moved.Count - 4); + if (hiddenCount > 0) + { + section.Children.Add(new TextBlock + { + Text = $"... 외 {hiddenCount}개 변경", + FontSize = 10, + Foreground = BrushFromHex("#64748B"), + Margin = new Thickness(0, 1, 0, 0), + }); + } + + return new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#BFDBFE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(6, 5, 6, 5), + Margin = new Thickness(0, 0, 0, 2), + Child = section, + }; + } + + private static List<(string Step, int FromIndex, int ToIndex)> GetMovedPlanSteps( + IReadOnlyList originalTokens, + IReadOnlyDictionary finalTokenMap) + { + var moved = new List<(string Step, int FromIndex, int ToIndex)>(); + foreach (var token in originalTokens) + { + if (!finalTokenMap.TryGetValue(token.Id, out var finalToken)) + continue; + if (token.Index == finalToken.Index) + continue; + + moved.Add((finalToken.Text, token.Index, finalToken.Index)); + } + + return moved + .OrderBy(item => item.ToIndex) + .ToList(); + } + + private static List BuildPlanStepTokens(IReadOnlyList steps) + { + var counters = new Dictionary(StringComparer.Ordinal); + var tokens = new List(steps.Count); + + for (var i = 0; i < steps.Count; i++) + { + var text = steps[i].Trim(); + if (string.IsNullOrWhiteSpace(text)) + continue; + + var normalizedKey = text.ToUpperInvariant(); + counters.TryGetValue(normalizedKey, out var currentCount); + var nextCount = currentCount + 1; + counters[normalizedKey] = nextCount; + + tokens.Add(new PlanStepToken($"{normalizedKey}#{nextCount}", text, i)); + } + + return tokens; + } + + private readonly record struct PlanStepToken(string Id, string Text, int Index); + + private (string Label, string BgHex, string BorderHex, string FgHex) GetPlanDiffSeverity( + int addedCount, + int removedCount, + int movedCount, + int originalCount, + int finalCount) + { + var totalChanges = addedCount + removedCount + movedCount; + var baseSize = Math.Max(1, Math.Max(originalCount, finalCount)); + var ratio = (double)totalChanges / baseSize; + + var llm = _settings.Settings.Llm; + var mediumCount = llm.PlanDiffSeverityMediumCount > 0 ? llm.PlanDiffSeverityMediumCount : 2; + var highCount = llm.PlanDiffSeverityHighCount > 0 ? llm.PlanDiffSeverityHighCount : 5; + if (highCount < mediumCount) + highCount = mediumCount; + + var mediumRatioPercent = llm.PlanDiffSeverityMediumRatioPercent > 0 ? llm.PlanDiffSeverityMediumRatioPercent : 25; + var highRatioPercent = llm.PlanDiffSeverityHighRatioPercent > 0 ? llm.PlanDiffSeverityHighRatioPercent : 60; + mediumRatioPercent = Math.Clamp(mediumRatioPercent, 1, 100); + highRatioPercent = Math.Clamp(highRatioPercent, mediumRatioPercent, 100); + var mediumRatio = mediumRatioPercent / 100.0; + var highRatio = highRatioPercent / 100.0; + + if (totalChanges >= highCount || ratio >= highRatio) + return ("대폭", "#FEF2F2", "#FCA5A5", "#991B1B"); + if (totalChanges >= mediumCount || ratio >= mediumRatio) + return ("중간", "#FFF7ED", "#FDBA74", "#9A3412"); + return ("경미", "#ECFDF5", "#86EFAC", "#166534"); + } + + private void OpenRunFilePath(string path) + { + if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path)) + return; + + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + + if (PreviewWindow.IsOpen) + PreviewWindow.RefreshIfOpen(path); + else + TryShowPreview(path); + } + + private void OpenRunFileExternal(string path) + { + if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path)) + return; + + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }); + } + catch { } + } + + private void RevealRunFileInFolder(string path) + { + if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path)) + return; + + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); } catch { } + } + + private string GetRunPrimaryTool(string runId) + { + return GetExecutionEventsForRun(runId, 20) + .Where(evt => !string.IsNullOrWhiteSpace(evt.ToolName)) + .GroupBy(evt => evt.ToolName!, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault() ?? ""; + } + + private static string GetFollowUpAssetHint(IEnumerable files) + { + var ext = files + .Select(path => System.IO.Path.GetExtension(path)?.ToLowerInvariant()) + .FirstOrDefault(e => !string.IsNullOrWhiteSpace(e)); + + return ext switch + { + ".html" or ".htm" => "브라우저에서 렌더링과 레이아웃까지 함께 점검해줘.", + ".md" => "문서 흐름과 섹션 구성을 함께 다듬어줘.", + ".csv" or ".xlsx" => "데이터 구조와 컬럼 구성이 맞는지도 함께 확인해줘.", + ".docx" => "문서 형식과 본문 구성을 같이 점검해줘.", + ".cs" or ".ts" or ".js" or ".py" => "코드 영향 범위와 후속 수정 포인트를 같이 점검해줘.", + _ => "결과물과 관련 파일을 먼저 검토한 뒤 이어서 진행해줘.", + }; + } + + private string BuildFollowUpLeadForRun(AppStateService.AgentRunState run) + { + var primaryTool = GetRunPrimaryTool(run.RunId); + var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3); + var assetHint = GetFollowUpAssetHint(relatedFiles); + return primaryTool switch + { + "file_write" or "file_edit" or "html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create" + => $"방금 만든 결과물을 검토하고, 필요한 후속 수정이나 마무리 작업을 이어서 진행해줘. {assetHint}", + "code_review" or "review" + => "방금 검토한 내용을 바탕으로 우선순위 높은 이슈부터 후속 조치를 이어서 진행해줘. 수정이 필요하면 바로 반영 방향까지 포함해줘.", + "grep" or "glob" or "folder_map" + => "방금 찾은 내용과 구조를 바탕으로 다음 분석 단계를 이어서 진행해줘. 필요하면 관련 파일을 직접 열어 근거를 더 확인해줘.", + "process" or "build_run" + => "방금 실행한 명령 결과를 바탕으로 오류나 남은 작업을 정리하고, 필요한 다음 조치를 이어서 진행해줘.", + _ => "방금 완료한 작업을 이어서 진행해줘.", + }; + } + + private string BuildBranchHintFromRun(AppStateService.AgentRunState run) + { + var primaryTool = GetRunPrimaryTool(run.RunId); + var baseHint = primaryTool switch + { + "file_write" or "file_edit" => "수정안", + "html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create" => "결과 확장", + "code_review" or "review" => "리뷰 후속", + "grep" or "glob" or "folder_map" => "탐색 후속", + _ => TruncateForStatus(run.Summary, 16), + }; + return string.IsNullOrWhiteSpace(baseHint) ? "후속안" : baseHint; + } + + private string BuildFollowUpPromptFromRun(AppStateService.AgentRunState run) + { + var summary = string.IsNullOrWhiteSpace(run.Summary) + ? "방금 완료한 작업" + : run.Summary.Trim(); + var recentEvents = GetExecutionEventsForRun(run.RunId, 3); + var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3); + var planHistory = GetRunPlanHistory(run.RunId); + var lead = BuildFollowUpLeadForRun(run); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(lead); + sb.AppendLine(); + sb.AppendLine("[이전 결과 요약]"); + sb.AppendLine(summary); + + if (relatedFiles.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("[관련 파일]"); + foreach (var path in relatedFiles) + sb.AppendLine($"- {path}"); + } + + if (recentEvents.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("[최근 실행 로그]"); + foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp)) + sb.AppendLine($"- {_appState.FormatExecutionEventLine(evt)}"); + } + + if (planHistory.HasAny) + { + sb.AppendLine(); + sb.AppendLine("[계획 히스토리]"); + AppendPlanHistoryLines(sb, planHistory, bulletPrefix: "- "); + } + + sb.AppendLine(); + sb.AppendLine(GetFollowUpAssetHint(relatedFiles)); + return sb.ToString().Trim(); + } + + private string BuildBranchContextMessageFromRun(AppStateService.AgentRunState run) + { + var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3); + var recentEvents = GetExecutionEventsForRun(run.RunId, 3); + var planHistory = GetRunPlanHistory(run.RunId); + var primaryTool = GetRunPrimaryTool(run.RunId); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다. ({BuildBranchHintFromRun(run)})"); + sb.AppendLine(); + sb.AppendLine($"- 실행 요약: {(string.IsNullOrWhiteSpace(run.Summary) ? "요약 없음" : run.Summary.Trim())}"); + if (!string.IsNullOrWhiteSpace(primaryTool)) + sb.AppendLine($"- 주요 도구: {primaryTool}"); + + if (relatedFiles.Count > 0) + { + sb.AppendLine("- 관련 파일:"); + foreach (var path in relatedFiles) + sb.AppendLine($" - {path}"); + } + + if (recentEvents.Count > 0) + { + sb.AppendLine("- 최근 실행 로그:"); + foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp)) + sb.AppendLine($" - {_appState.FormatExecutionEventLine(evt)}"); + } + + if (planHistory.HasAny) + { + sb.AppendLine("- 계획 히스토리:"); + AppendPlanHistoryLines(sb, planHistory, bulletPrefix: " - "); + } + + sb.AppendLine(); + sb.AppendLine("이 문맥을 바탕으로 여기서 별도의 후속 작업을 이어가면 됩니다."); + return sb.ToString().Trim(); + } + + private static void AppendPlanHistoryLines(System.Text.StringBuilder sb, AppStateService.RunPlanHistoryState history, string bulletPrefix) + { + AppendPlanHistoryLine(sb, bulletPrefix, "원안", history.OriginalSummary, history.OriginalSteps); + AppendPlanHistoryLine(sb, bulletPrefix, "수정안", history.RevisedSummary, history.RevisedSteps); + AppendPlanHistoryLine(sb, bulletPrefix, "최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps); + } + + private static void AppendPlanHistoryLine( + System.Text.StringBuilder sb, + string bulletPrefix, + string label, + string? summary, + IReadOnlyList steps) + { + var stepPreview = steps + .Where(step => !string.IsNullOrWhiteSpace(step)) + .Take(3) + .ToList(); + + if (string.IsNullOrWhiteSpace(summary) && stepPreview.Count == 0) + { + sb.AppendLine($"{bulletPrefix}{label}: 기록 없음"); + return; + } + + var summaryPart = string.IsNullOrWhiteSpace(summary) ? "" : summary.Trim(); + var stepPart = stepPreview.Count == 0 ? "" : string.Join(" | ", stepPreview); + if (!string.IsNullOrWhiteSpace(summaryPart) && !string.IsNullOrWhiteSpace(stepPart)) + sb.AppendLine($"{bulletPrefix}{label}: {summaryPart} ({stepPart})"); + else if (!string.IsNullOrWhiteSpace(summaryPart)) + sb.AppendLine($"{bulletPrefix}{label}: {summaryPart}"); + else + sb.AppendLine($"{bulletPrefix}{label}: {stepPart}"); + } + + private void EnqueueFollowUpFromRun(AppStateService.AgentRunState run) + { + var prompt = BuildFollowUpPromptFromRun(run); + + lock (_convLock) + { + var session = _appState.ChatSession; + if (session != null) + _currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null + ? session.CurrentConversation + : _currentConversation; + } + + RefreshDraftQueueUi(); + ShowToast("후속 작업이 대기열에 추가되었습니다."); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + } + + private void ScrollToRunInTimeline(string runId) + { + if (string.IsNullOrWhiteSpace(runId)) + return; + + if (_runBannerAnchors.TryGetValue(runId, out var target)) + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + Dispatcher.BeginInvoke(() => + { + target.BringIntoView(); + target.Focusable = true; + target.Focus(); + }, DispatcherPriority.Background); + return; + } + + ShowToast("현재 화면에서 해당 실행 로그를 찾지 못했습니다.", "\uE783"); + } + + private void BranchConversationFromRun(AppStateService.AgentRunState run) + { + ChatConversation? source; + lock (_convLock) source = _currentConversation; + if (source == null || source.Messages.Count == 0) + return; + + var atIndex = source.Messages.FindLastIndex(m => m.Role == "assistant"); + if (atIndex < 0) + atIndex = source.Messages.Count - 1; + + ForkConversation( + source, + atIndex, + BuildBranchHintFromRun(run), + BuildBranchContextMessageFromRun(run), + run.RunId); + + var prompt = BuildFollowUpPromptFromRun(run); + lock (_convLock) + { + var session = _appState.ChatSession; + if (session != null) + _currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null + ? session.CurrentConversation + : _currentConversation; + } + + RefreshDraftQueueUi(); + ShowToast("새 분기 대화가 생성되고 후속 작업이 대기열에 추가되었습니다."); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + } + + private string? GetLastUserMessageFromConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + return conv?.Messages.LastOrDefault(m => m.Role == "user")?.Content; + } + + private void RetryLastUserMessageFromConversation() + { + var lastUserMessage = GetLastUserMessageFromConversation(); + if (string.IsNullOrWhiteSpace(lastUserMessage) || InputBox == null) + return; + + InputBox.Text = lastUserMessage; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true); + } + + private void FocusCurrentConversation() + { + if (InputBox == null) + return; + + InputBox.Focus(); + InputBox.CaretIndex = InputBox.Text?.Length ?? 0; + } + + private void ShowRunningConversationsOnly() + { + _runningOnlyFilter = true; + UpdateConversationRunningFilterUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + } + + private void ShowSubAgentTasksOnly() + { + _taskSummaryTaskFilter = "subagent"; + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + } + + private static string? TryGetDraftIdFromQueueTask(TaskRunStore.TaskRun task) + { + if (!string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(task.Id)) + return null; + + var parts = task.Id.Split(':'); + return parts.Length >= 3 ? parts[^1] : null; + } + + private static string? TryGetToolNameFromPermissionTask(TaskRunStore.TaskRun task) + { + if (!string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(task.Id)) + return null; + + var parts = task.Id.Split(':'); + return parts.Length >= 2 ? parts[^1] : null; + } + + private void RetryQueueTask(TaskRunStore.TaskRun task) + { + var draftId = TryGetDraftIdFromQueueTask(task); + if (string.IsNullOrWhiteSpace(draftId)) + return; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.ResetDraftToQueued(_activeTab, draftId, _storage); + _currentConversation = session.CurrentConversation ?? _currentConversation; + } + } + + RefreshDraftQueueUi(); + StartNextQueuedDraftIfAny(draftId); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + } + + private void RemoveQueueTask(TaskRunStore.TaskRun task) + { + var draftId = TryGetDraftIdFromQueueTask(task); + if (string.IsNullOrWhiteSpace(draftId)) + return; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.RemoveDraft(_activeTab, draftId, _storage); + _currentConversation = session.CurrentConversation ?? _currentConversation; + } + } + + RefreshDraftQueueUi(); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + } + + private void ApplyPermissionOverrideAndRefreshTaskPopup(TaskRunStore.TaskRun task, string? mode) + { + var toolName = TryGetToolNameFromPermissionTask(task); + if (string.IsNullOrWhiteSpace(toolName)) + return; + + SetToolPermissionOverride(toolName, mode); + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + } + + private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind); + var taskStack = new StackPanel(); + var headerRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + }; + headerRow.Children.Add(new TextBlock + { + Text = kindIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = kindColor, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + headerRow.Children.Add(new TextBlock + { + Text = active + ? $"진행 중 · {task.Title}" + : $"{GetTaskStatusLabel(task.Status)} · {task.Title}", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = active ? primaryText : secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + taskStack.Children.Add(headerRow); + + if (!string.IsNullOrWhiteSpace(task.Summary)) + { + taskStack.Children.Add(new TextBlock + { + Text = task.Summary, + FontSize = 10.5, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + } + + var reviewChipRow = BuildReviewSignalChipRow( + kind: task.Kind, + toolName: task.Title, + title: task.Title, + summary: task.Summary); + if (reviewChipRow != null) + taskStack.Children.Add(reviewChipRow); + + var actionRow = BuildTaskSummaryActionRow(task, active); + if (actionRow != null) + taskStack.Children.Add(actionRow); + + return new Border + { + Background = active ? BrushFromHex("#F8FAFC") : Brushes.White, + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(8, 0, 8, 6), + Child = taskStack + }; + } + + private Button CreateTaskSummaryActionButton( + string label, + string bg, + string border, + string fg, + RoutedEventHandler onClick, + bool trailingMargin = true) + { + var button = new Button + { + Content = label, + FontSize = 10.5, + MinHeight = 28, + Padding = new Thickness(9, 4, 9, 4), + Margin = trailingMargin ? new Thickness(0, 0, 6, 0) : new Thickness(0), + Background = BrushFromHex(bg), + BorderBrush = BrushFromHex(border), + BorderThickness = new Thickness(1), + Foreground = BrushFromHex(fg), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Left, + }; + button.Click += onClick; + return button; + } + + private WrapPanel? BuildTaskSummaryActionRow(TaskRunStore.TaskRun task, bool active) + { + if (string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase)) + { + var actions = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + var primaryButton = CreateTaskSummaryActionButton( + active ? "지금 실행" : "다시 실행", + "#EEF2FF", + "#C7D2FE", + "#3730A3", + (_, _) => RetryQueueTask(task)); + actions.Children.Add(primaryButton); + + var secondaryButton = CreateTaskSummaryActionButton( + active ? "큐에서 제거" : "정리", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => RemoveQueueTask(task), + trailingMargin: false); + actions.Children.Add(secondaryButton); + return actions; + } + + if (string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase)) + { + var actions = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + Button BuildPermissionButton(string label, string bg, string border, string fg, string? mode, bool margin = true) + { + return CreateTaskSummaryActionButton( + label, bg, border, fg, + (_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode), + trailingMargin: margin); + } + + actions.Children.Add(BuildPermissionButton("권한 요청", "#EFF6FF", "#BFDBFE", "#1D4ED8", PermissionModeCatalog.Default)); + actions.Children.Add(BuildPermissionButton("편집 자동 승인", "#ECFDF5", "#BBF7D0", "#166534", PermissionModeCatalog.AcceptEdits)); + actions.Children.Add(BuildPermissionButton("계획 모드", "#EEF2FF", "#C7D2FE", "#3730A3", PermissionModeCatalog.Plan)); + actions.Children.Add(BuildPermissionButton("권한 건너뛰기", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.BypassPermissions)); + actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false)); + return actions; + } + + return null; + } + + private Border BuildHookSummaryCard(AppStateService.HookEventState hook) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var hookCardStack = new StackPanel(); + hookCardStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uE756", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = "훅 이벤트", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"), + } + } + }); + hookCardStack.Children.Add(new TextBlock + { + Text = _appState.FormatHookEventLine(hook), + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"), + }); + + var hookActionRow = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + var hookFilterButton = CreateTaskSummaryActionButton( + "훅만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => + { + _taskSummaryTaskFilter = "hook"; + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + }); + hookActionRow.Children.Add(hookFilterButton); + + if (!string.IsNullOrWhiteSpace(hook.RunId)) + { + var capturedRunId = hook.RunId; + var timelineButton = CreateTaskSummaryActionButton( + "관련 로그로 이동", + hook.Success ? "#EEF2FF" : "#FEF2F2", + hook.Success ? "#C7D2FE" : "#FCA5A5", + hook.Success ? "#3730A3" : "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + ScrollToRunInTimeline(capturedRunId); + }, + trailingMargin: false); + hookActionRow.Children.Add(timelineButton); + } + + hookCardStack.Children.Add(hookActionRow); + + return new Border + { + Background = hook.Success ? BrushFromHex("#F8FAFC") : BrushFromHex("#FEF2F2"), + BorderBrush = hook.Success ? BrushFromHex("#E2E8F0") : BrushFromHex("#FECACA"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(8, 0, 8, 6), + Child = hookCardStack + }; + } + + private Border BuildActiveBackgroundSummaryCard(IReadOnlyList activeBackgroundJobs, int activeBackgroundCount) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var activeBackgroundStack = new StackPanel(); + activeBackgroundStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE9F9", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = BrushFromHex("#1D4ED8"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"실행 중인 백그라운드 작업 {activeBackgroundCount}개", + FontSize = 11, + Foreground = BrushFromHex("#1D4ED8"), + FontWeight = FontWeights.SemiBold, + } + } + }); + + foreach (var job in activeBackgroundJobs) + { + activeBackgroundStack.Children.Add(new TextBlock + { + Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 52)}", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.5, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + } + + var activeActionRow = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + var runningFilterButton = CreateTaskSummaryActionButton( + "진행 중 대화만 보기", + "#DBEAFE", + "#93C5FD", + "#1D4ED8", + (_, _) => ShowRunningConversationsOnly()); + activeActionRow.Children.Add(runningFilterButton); + + var subTaskButton = CreateTaskSummaryActionButton( + "서브 작업만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ShowSubAgentTasksOnly(), + trailingMargin: false); + activeActionRow.Children.Add(subTaskButton); + activeBackgroundStack.Children.Add(activeActionRow); + + return new Border + { + Background = BrushFromHex("#EFF6FF"), + BorderBrush = BrushFromHex("#BFDBFE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(8, 0, 8, 8), + Child = activeBackgroundStack + }; + } + + private Border BuildRecentBackgroundJobCard(BackgroundJobService.BackgroundJobState job) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var jobCardStack = new StackPanel(); + var isFailed = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase); + jobCardStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uE823", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"), + } + } + }); + jobCardStack.Children.Add(new TextBlock + { + Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 72)}", + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = isFailed + ? BrushFromHex("#991B1B") + : secondaryText, + }); + + var jobActionRow = new WrapPanel + { + Margin = new Thickness(0, 8, 0, 0), + }; + + var focusButton = CreateTaskSummaryActionButton( + "이 대화로 이동", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + FocusCurrentConversation(); + }); + jobActionRow.Children.Add(focusButton); + + var subagentOnlyButton = CreateTaskSummaryActionButton( + "서브 작업만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ShowSubAgentTasksOnly()); + jobActionRow.Children.Add(subagentOnlyButton); + + if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) + { + var retryBackgroundButton = CreateTaskSummaryActionButton( + "이 작업 다시 시도", + "#FEF2F2", + "#FCA5A5", + "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }, + trailingMargin: false); + jobActionRow.Children.Add(retryBackgroundButton); + } + + jobCardStack.Children.Add(jobActionRow); + + return new Border + { + Background = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) + ? BrushFromHex("#FEF2F2") + : BrushFromHex("#F8FAFC"), + BorderBrush = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) + ? BrushFromHex("#FECACA") + : BrushFromHex("#E2E8F0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(8, 0, 8, 6), + Child = jobCardStack + }; + } + + private static (Brush Background, Brush Border, Brush Foreground) GetPermissionModePalette(string? mode) + { + var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode); + return normalized switch + { + var x when string.Equals(x, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#ECFDF5"), BrushFromHex("#86EFAC"), BrushFromHex("#166534")), + var x when string.Equals(x, PermissionModeCatalog.AcceptEdits, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), + var x when string.Equals(x, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")), + var x when string.Equals(x, PermissionModeCatalog.BypassPermissions, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")), + _ => (BrushFromHex("#F8FAFC"), BrushFromHex("#CBD5E1"), BrushFromHex("#334155")), + }; + } + + private static (string Icon, Brush Color) GetTaskKindVisual(string? kind) + { + var normalized = kind?.Trim().ToLowerInvariant() ?? ""; + return normalized switch + { + "permission" => ("\uE72E", BrushFromHex("#1D4ED8")), + "queue" => ("\uE14C", BrushFromHex("#7C3AED")), + "tool" => ("\uE90F", BrushFromHex("#A16207")), + "hook" => ("\uE756", BrushFromHex("#0F766E")), + "subagent" => ("\uE902", BrushFromHex("#2563EB")), + _ => ("\uE946", BrushFromHex("#475569")), + }; + } + + private static (string Label, Brush Background, Brush Border, Brush Foreground) GetPermissionEventStatusDisplay(string? status) + { + var normalized = status?.Trim().ToLowerInvariant() ?? ""; + return normalized switch + { + "denied" => ("차단", BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")), + "granted" => ("허용", BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), + "approved" => ("승인", BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")), + "auto" => ("자동 허용", BrushFromHex("#FFF7ED"), BrushFromHex("#FED7AA"), BrushFromHex("#9A3412")), + _ => ("확인 대기", BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")), + }; + } + + private void AddTaskSummaryPermissionSection(StackPanel panel, ChatConversation? currentConversation) + { + var permissionSummary = _appState.GetPermissionSummary(currentConversation); + var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(permissionSummary.EffectiveMode); + var (bg, border, fg) = GetPermissionModePalette(normalizedMode); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + + var content = new StackPanel(); + content.Children.Add(new TextBlock + { + Text = $"현재 권한 · {PermissionModeCatalog.ToDisplayLabel(normalizedMode)}", + FontWeight = FontWeights.SemiBold, + Foreground = fg, + }); + content.Children.Add(new TextBlock + { + Text = permissionSummary.Description, + Margin = new Thickness(0, 3, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + }); + content.Children.Add(new TextBlock + { + Text = $"기본 {PermissionModeCatalog.ToDisplayLabel(permissionSummary.DefaultMode)} · 예외 {permissionSummary.OverrideCount}개", + Margin = new Thickness(0, 2, 0, 0), + Foreground = primaryText, + }); + + panel.Children.Add(new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(8, 0, 8, 8), + Child = content, + }); + } + + private void AddTaskSummaryPermissionHistorySection(StackPanel panel) + { + var recentPermissions = _appState.GetRecentPermissionEvents(4); + if (recentPermissions.Count == 0) + return; + + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + panel.Children.Add(new TextBlock + { + Text = "최근 권한 이력", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(10, 0, 10, 4), + }); + + foreach (var permission in recentPermissions) + { + var (statusLabel, statusBg, statusBorder, statusFg) = GetPermissionEventStatusDisplay(permission.Status); + var card = new StackPanel(); + card.Children.Add(new TextBlock + { + Text = $"{permission.Timestamp:HH:mm:ss} · {permission.ToolName}", + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + FontSize = 10.5, + }); + card.Children.Add(new TextBlock + { + Text = statusLabel, + Margin = new Thickness(0, 3, 0, 0), + Foreground = statusFg, + FontWeight = FontWeights.SemiBold, + FontSize = 10.5, + }); + if (!string.IsNullOrWhiteSpace(permission.Summary)) + { + card.Children.Add(new TextBlock + { + Text = TruncateForStatus(permission.Summary, 80), + Margin = new Thickness(0, 2, 0, 0), + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + FontSize = 10, + }); + } + + panel.Children.Add(new Border + { + Background = statusBg, + BorderBrush = statusBorder, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(8, 0, 8, 6), + Child = card, + }); + } + } + + private void AddTaskSummaryHookSection(StackPanel panel) + { + var recentHooks = _appState.GetRecentHookEvents(5); + if (recentHooks.Count == 0) + return; + + panel.Children.Add(new TextBlock + { + Text = "최근 훅 이력", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.DimGray, + Margin = new Thickness(10, 0, 10, 4), + }); + + foreach (var hook in recentHooks) + panel.Children.Add(BuildHookSummaryCard(hook)); + } + + private void AddTaskSummaryBackgroundSection(StackPanel panel) + { + var activeBackgroundJobs = _appState.GetActiveBackgroundJobs(3); + var recentBackgroundJobs = _appState.GetRecentBackgroundJobs(4); + var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount; + + if (activeBackgroundCount > 0) + panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount)); + + if (recentBackgroundJobs.Count == 0) + return; + + panel.Children.Add(new TextBlock + { + Text = "최근 백그라운드 작업", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.DimGray, + Margin = new Thickness(10, 0, 10, 4), + }); + + foreach (var job in recentBackgroundJobs) + panel.Children.Add(BuildRecentBackgroundJobCard(job)); + } + + private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation) + { + AddTaskSummaryPermissionSection(panel, currentConversation); + AddTaskSummaryPermissionHistorySection(panel); + AddTaskSummaryHookSection(panel); + AddTaskSummaryBackgroundSection(panel); + } + + private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) + { + var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!; + return new System.Windows.Media.SolidColorBrush(c); + } +} + + diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 290ff83..11996c5 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -49,6 +49,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -520,7 +731,7 @@ - - @@ -845,18 +1104,18 @@ - - - @@ -913,10 +1172,11 @@ VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Background="{DynamicResource LauncherBackground}" - Padding="22,18,22,0"> + Padding="24,20,24,10"> + MaxWidth="920" + HorizontalAlignment="Center"> @@ -925,9 +1185,9 @@ @@ -938,7 +1198,7 @@ + Margin="0,22,0,4"> @@ -981,12 +1241,13 @@ Margin="0,10,0,0"/> - + Padding="0,2,0,0"> @@ -1311,11 +1572,13 @@ - - + VerticalAlignment="Bottom"> + - @@ -1401,7 +1664,7 @@ @@ -1411,7 +1674,7 @@ - + @@ -1438,7 +1701,7 @@ VerticalAlignment="Center" Margin="0,0,5,0"/> @@ -1621,6 +1884,7 @@ Margin="0,0,0,8"/> @@ -1636,11 +1900,13 @@ Click="BtnInlineReasoning_Click"/> @@ -1867,10 +2135,10 @@ @@ -2152,78 +2420,123 @@ BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,0,1,0"> - - - + + + + + + + + + - - + - + + Tag="cowork"/> + + + + - - - - - - - - - + Padding="14,14,20,20"> + + + + + + - + + + + + + + 현재 대화에서 사용할 AI 서비스 연결을 선택합니다. 사내 모드에서는 내부 연결만 유지하는 데도 함께 쓰입니다. + + + + - + + + + + + + 선택한 서비스 안에서 실제 응답을 생성할 모델입니다. 모델에 따라 속도, 문장 길이, 코드 품질이 달라질 수 있습니다. + + + + @@ -2340,11 +2689,30 @@ - + + + + + + + Ollama, vLLM 같은 연결형 서비스의 기본 주소입니다. 사내 서버나 프록시를 쓰는 경우 이 값을 바꿔 연결 대상을 맞춥니다. + + + + - + + + + + + + Gemini, Claude처럼 인증이 필요한 서비스만 입력합니다. 빈 값이면 인증 없이 접속 가능한 연결만 사용합니다. + + + + - + + + + + + + 호출 간격을 조정해 제한이 있는 환경에서 더 안정적으로 동작하게 합니다. + + + + - + + @@ -2660,28 +3207,73 @@ - -