Compare commits
19 Commits
be7328184a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c1266d3e | |||
| 3924bac9f9 | |||
| 9f5a9d315c | |||
| 4c1513a5da | |||
| ccaa24745e | |||
| 1ce6ccb030 | |||
| 2b21e8cdfb | |||
| 35ec073eb9 | |||
| 68524c1c94 | |||
| b4a506de96 | |||
| 82b42b3ba3 | |||
| 90bd77f945 | |||
| 95e40df354 | |||
| f9d18fba08 | |||
| f0af86cc1e | |||
| 13f0e23ed5 | |||
| 7cb27b70f8 | |||
| 61f82bdd10 | |||
| fa349c2057 |
68
README.md
68
README.md
@@ -7,6 +7,50 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
||||
개발 참고: Claw Code 동등성 작업 추적 문서
|
||||
`docs/claw-code-parity-plan.md`
|
||||
|
||||
- 업데이트: 2026-04-06 09:27 (KST)
|
||||
- `claw-code`와 AX Agent를 다시 대조해 남은 품질 향상 작업을 3트랙으로 재정리했습니다. 앞으로 계획은 `사용자 체감 UI/UX`, `LLM·작업 처리`, `유지보수·추가기능 구조`로 분리해 관리합니다.
|
||||
- `docs/claw-code-parity-plan.md`에 각 트랙별 참조 파일, AX 적용 위치, 완료 조건, 품질 판정 기준, 권장 실행 순서를 고정했습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 09:14 (KST)
|
||||
- AX Agent 워크트리 선택 팝업과 공통 선택 row 렌더를 `ChatWindow.SelectionPopupPresentation.cs`로 분리했습니다. 작업 위치/워크트리 전환 메뉴와 선택 상태 row 조립이 메인 창 코드 밖으로 이동해 footer 선택 UX를 별도 파일에서 정리할 수 있게 됐습니다.
|
||||
- `ChatWindow.xaml.cs`는 대화 상태와 세션 orchestration 쪽에 더 집중하도록 정리했고, 향후 브랜치/워크트리/선택형 팝업 UX를 `claw-code` 기준으로 계속 다듬기 쉬운 구조를 만들었습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 09:03 (KST)
|
||||
- AX Agent 공통 선택 팝업 조립 로직을 `ChatWindow.PopupPresentation.cs`로 분리했습니다. 테마 팝업 컨테이너, 공통 메뉴 아이템, 구분선, 최근 폴더 우클릭 컨텍스트 메뉴가 메인 창 코드 밖으로 이동해 footer/file-browser 쪽 팝업 품질 작업을 이어가기 쉬운 구조로 정리했습니다.
|
||||
- `ChatWindow.xaml.cs`는 대화 상태와 런타임 orchestration 쪽에 더 집중하도록 정리했고, 공통 팝업 시각 언어를 한 곳에서 다듬을 수 있는 기반을 만들었습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:55 (KST)
|
||||
- AX Agent 파일 브라우저 렌더를 `ChatWindow.FileBrowserPresentation.cs`로 분리했습니다. 파일 탐색기 열기/닫기, 폴더 트리 구성, 파일 헤더/아이콘/크기 표시, 우클릭 메뉴, 디바운스 새로고침 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||
- `ChatWindow.xaml.cs`는 transcript·runtime orchestration 중심으로 더 정리됐고, claw-code 기준 사이드 surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:47 (KST)
|
||||
- AX Agent 우측 프리뷰 패널 렌더를 `ChatWindow.PreviewPresentation.cs`로 분리했습니다. 프리뷰 탭 목록, 헤더, 파일 로드, CSV/텍스트/마크다운/HTML 표시, 숨김/열기, 우클릭 메뉴, 별도 창 미리보기 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||
- `ChatWindow.xaml.cs`는 transcript 및 런타임 orchestration 중심으로 더 정리됐고, claw-code 기준 preview surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:39 (KST)
|
||||
- AX Agent 하단 상태바 이벤트 처리와 회전 애니메이션을 `ChatWindow.StatusPresentation.cs`로 옮겼습니다. `UpdateStatusBar`, `StartStatusAnimation`, `StopStatusAnimation`이 상태 표현 파일로 이동해 메인 창 코드의 runtime/status 분기가 더 줄었습니다.
|
||||
- `ChatWindow.xaml.cs`는 대화 실행 orchestration 중심으로 더 정리됐고, claw-code 기준 status line 정교화와 footer presentation 개선을 계속 이어가기 쉬운 구조로 맞췄습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:27 (KST)
|
||||
- AX Agent 메시지 액션/메타/편집 렌더를 `ChatWindow.MessageInteractions.cs`로 분리했습니다. 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||
- `ChatWindow.xaml.cs`는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리했고, claw-code 기준 메시지 타입 분리와 renderer 구조화를 계속 진행하기 쉬운 기반을 만들었습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:28 (KST)
|
||||
- AX Agent 하단 composer와 대기열 UI 렌더를 `ChatWindow.ComposerQueuePresentation.cs`로 분리했습니다. 입력창 높이 계산, draft kind 해석, 후속 요청 큐 카드/요약 pill/배지/액션 버튼 생성 책임을 메인 창 코드에서 떼어냈습니다.
|
||||
- `ChatWindow.xaml.cs`는 대기열 실행 orchestration과 세션 변경 흐름만 더 선명하게 남겨, claw-code 기준 입력부/queued command UX 개선을 계속하기 쉬운 구조로 정리했습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 08:12 (KST)
|
||||
- AX Agent 하단 작업 바 관련 presentation 메서드를 메인 창 코드에서 더 분리했습니다. `ChatWindow.FooterPresentation.cs`를 추가해 폴더 바, 선택된 프리셋 안내, Git 브랜치 버튼/팝업 렌더, 요약 pill 생성 책임을 별도 partial로 옮겼습니다.
|
||||
- `ChatWindow.xaml.cs`는 대화 흐름과 런타임 orchestration 중심으로 더 정리했고, claw-code 기준으로 footer/preset/Git popup 품질 작업을 계속 이어가기 쉬운 구조를 만들었습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 01:37 (KST)
|
||||
- AX Agent의 계획 승인 흐름을 더 transcript 우선 구조로 정리했습니다. 인라인 승인 카드가 기본 경로를 맡고, `계획` 버튼은 저장된 계획 요약/단계를 여는 상세 보기 역할만 하도록 분리했습니다.
|
||||
- 상태선과 quick strip 계산도 presentation state로 더 모았습니다. runtime badge, compact strip, quick strip의 텍스트/강조색/노출 여부를 한 번에 계산해 창 코드의 직접 분기를 줄였습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 07:31 (KST)
|
||||
- `ChatWindow.xaml.cs`에 몰려 있던 권한 팝업 렌더와 컨텍스트 사용량 카드 렌더를 별도 partial 파일로 분리했습니다. `ChatWindow.PermissionPresentation.cs`, `ChatWindow.ContextUsagePresentation.cs`를 추가해 권한 선택/권한 상태 배너/컨텍스트 사용량 hover 카드 책임을 메인 창 orchestration 코드에서 떼어냈습니다.
|
||||
- 다음 단계에서 `permission / tool-result / footer` presentation catalog를 더 세밀하게 확장하기 쉽게 구조를 정리했고, 동작은 그대로 유지한 채 transcript/푸터 품질 개선 발판을 마련했습니다.
|
||||
|
||||
- 업데이트: 2026-04-06 00:50 (KST)
|
||||
- 채팅/코워크 프리셋 카드의 hover 설명 레이어를 카드 내부 오버레이 방식에서 안정적인 tooltip형 설명으로 바꿨습니다. 카드 배경/테두리만 반응하게 정리해 hover 시 반복 깜빡임을 줄였습니다.
|
||||
|
||||
@@ -1104,3 +1148,27 @@ MIT License
|
||||
- 업데이트: 2026-04-05 22:26 (KST)
|
||||
- 코드 탭에서는 폴더 문서/파일을 기본 작업 전제로 삼도록 `폴더 내 데이터 활용`을 항상 `적극 활용(active)`으로 강제했다. 하단 채팅창의 데이터 활용 버튼은 코드 탭에서 숨기고, 내부 설정 오버레이의 같은 옵션도 코드 탭에서는 노출하지 않게 정리했다.
|
||||
- 코워크/코드 탭의 사용자 메시지도 assistant 메시지와 같은 파일 경로 강조 렌더러를 쓰도록 바꿔, 폴더 하위 파일명이나 경로를 입력하면 채팅 본문에서 파란색으로 인식되게 맞췄다.
|
||||
- 업데이트: 2026-04-05 22:29 (KST)
|
||||
- AX Agent 채팅/코워크 프리셋을 선택할 때, 메시지도 입력도 없는 fresh conversation인데도 `새 대화`가 반복 생성되던 흐름을 보정했다. 이제 현재 대화가 이미 있으면 그 빈 대화에 프리셋만 적용하고, 실제 대화가 아예 없는 경우에만 새 대화를 만든다.
|
||||
- 업데이트: 2026-04-05 22:32 (KST)
|
||||
- AX Agent 내부 설정 개발자 탭의 `워크플로우 시각화`, `전체 호출·토큰 합계 표시`, `감사 로그` 토글이 누르자마자 꺼지는 문제를 수정했다. 각 토글의 변경 이벤트를 연결해 즉시 저장되도록 보정했다.
|
||||
- 업데이트: 2026-04-05 22:36 (KST)
|
||||
- AX Agent 내부 설정 `도구 훅 실행 타임아웃`과 `등록된 훅` 영역에서 잘림이 보이던 레이아웃을 보정했다. 슬라이더/값 배지 컬럼 폭과 `훅 추가` 버튼 최소 폭을 넉넉히 늘려 텍스트와 컨트롤이 서로 밀리지 않게 정리했다.
|
||||
- 업데이트: 2026-04-05 22:40 (KST)
|
||||
- AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다.
|
||||
- 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `system / light / dark` 모드 조합으로 같은 방식으로 적용되도록 정리했다.
|
||||
- 업데이트: 2026-04-06 00:58 (KST)
|
||||
- AX Agent transcript 품질 향상을 위해 렌더 책임을 실제로 분리했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)를 추가해 `의견 요청`, `계획 승인`, `작업 요약` UI 로직을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에서 분리했다.
|
||||
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)를 추가해 권한 요청과 도구 결과를 `명령/네트워크/파일`, `성공/실패/거부/취소` 기준으로 나눠 transcript badge에 재사용하도록 정리했다.
|
||||
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status/runtime summary 계산을 전용 요약 모델로 한 번 더 계층화했다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 상태선 갱신은 이제 이 presentation summary를 소비한다.
|
||||
- 업데이트: 2026-04-06 01:12 (KST)
|
||||
- AX Agent 코워크/코드의 `폴더 내 문서 활용`을 사용자 옵션에서 제거했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 에서 하단 버튼, 내부 설정 행, 구형 설정창 항목을 걷어냈다.
|
||||
- 런타임은 옵션이 아닌 자동 정책으로 유지한다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 채팅은 `none`, 코워크는 `passive`, 코드는 `active`를 자동 적용하고, 더 이상 오버레이 저장 시 `FolderDataUsage`를 사용자 선택값으로 저장하지 않는다.
|
||||
- 업데이트: 2026-04-06 01:24 (KST)
|
||||
- `claw-code` 기준 transcript 품질 향상을 위해 권한 요청/도구 결과/도구 이름 display catalog를 다시 정리했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 파일/문서/빌드/Git/웹/스킬/질문 카테고리를 더 명확한 한국어 display name과 badge label로 분류하고, [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 presentation으로 나누도록 보강했다.
|
||||
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success / error / reject / cancel`을 도구 종류에 따라 `파일 작업 완료`, `빌드/테스트 실패`, `웹 요청 거부`처럼 더 읽기 쉬운 결과 라벨로 바꾸도록 확장했다.
|
||||
- transcript renderer 분리 2차로 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)를 추가해 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 옮겼다. 이제 메인 파일은 대화 흐름과 상태 처리에 더 집중하고, 이벤트 배너 렌더는 별도 partial에서 관리한다.
|
||||
- 업데이트: 2026-04-06 09:36 (KST)
|
||||
- [OperationalStatusPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/OperationalStatusPresentationCatalog.cs)를 추가해 compact strip/quick strip의 색상, 노출 조건, 빠른 상태 배지 문구 계산을 전용 카탈로그로 분리했다. [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)의 `GetOperationalStatusPresentation(...)`은 이제 상태 집계 후 카탈로그 결과만 반환한다.
|
||||
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)에 `Kind`, `Description` 메타를 추가했다. [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)는 이제 이벤트 요약이 비어 있을 때 이 설명을 transcript fallback으로 사용한다.
|
||||
- [PermissionModePresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs) 에서 제거된 계획 모드 잔재를 걷어내고, [ChatWindow.PermissionPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs)의 권한 선택 UI와 상단 배너도 `권한 요청 / 편집 자동 승인 / 권한 건너뛰기 / 읽기 전용`만 다루도록 정리했다.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# AX Copilot - 媛쒕컻 臾몄꽌
|
||||
|
||||
- Document update: 2026-04-06 09:27 (KST) - Re-framed the remaining `claw-code` parity work into three explicit tracks: user-facing UI/UX quality, LLM/task-handling quality, and maintainability/extensibility structure. This separates visible polish from runtime truth and from architectural cleanup.
|
||||
- Document update: 2026-04-06 09:27 (KST) - Captured the new track plan in `docs/claw-code-parity-plan.md` with reference files, AX apply targets, completion criteria, quality criteria, and recommended execution order so future work can be prioritized more deliberately.
|
||||
|
||||
- Document update: 2026-04-06 09:14 (KST) - Split worktree-selection popup rendering into `ChatWindow.SelectionPopupPresentation.cs`. The current workspace/worktree chooser and the shared selected-row popup card renderer now live outside `ChatWindow.xaml.cs`, reducing footer selection UI density in the main window file.
|
||||
- Document update: 2026-04-06 09:14 (KST) - This keeps branch/worktree/footer chooser UX on the same presentation side of the codebase and leaves the main chat window more focused on runtime orchestration and conversation flow.
|
||||
|
||||
- Document update: 2026-04-06 09:03 (KST) - Split common themed popup construction out of `ChatWindow.xaml.cs` into `ChatWindow.PopupPresentation.cs`. Shared popup container creation, generic popup menu items/separators, and the recent-folder context menu now live in a dedicated partial instead of the main window orchestration file.
|
||||
- Document update: 2026-04-06 09:03 (KST) - This keeps footer/file-browser popup styling on a single visual path and reduces direct popup composition inside the main chat window flow, making further `claw-code` style popup UX work easier to maintain.
|
||||
|
||||
- Document update: 2026-04-06 07:31 (KST) - Split permission presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.PermissionPresentation.cs`. Permission popup row construction, popup refresh, section expansion persistence, and permission banner/status styling now live in a dedicated partial instead of the main window orchestration file.
|
||||
- Document update: 2026-04-06 07:31 (KST) - Split context usage card/popup rendering into `ChatWindow.ContextUsagePresentation.cs`. The Cowork/Code context usage ring, tooltip popup copy, hover close behavior, and screen-coordinate hit testing are now isolated from the rest of the chat window flow.
|
||||
- Document update: 2026-04-06 01:37 (KST) - Reworked AX Agent plan approval toward a more transcript-native flow. The inline decision card remains the primary approval path, while the `계획` affordance now opens the stored plan as a detail-only surface instead of acting like a required popup step.
|
||||
- Document update: 2026-04-06 01:37 (KST) - Expanded `OperationalStatusPresentationState` so runtime badge, compact strip, and quick-strip labels/colors/visibility are calculated together. `ChatWindow` now consumes a richer presentation model instead of branching on strip kinds and quick-strip counters independently.
|
||||
|
||||
- Document update: 2026-04-05 19:04 (KST) - Added transcript-facing tool/skill display normalization in `ChatWindow.xaml.cs`. Tool and skill events now use role-first badges (`도구`, `도구 결과`, `스킬`) with human-readable item labels such as `파일 읽기`, `빌드/실행`, `Git`, or `/skill-name`, instead of exposing raw snake_case names as the primary visual label.
|
||||
- Document update: 2026-04-05 19:04 (KST) - Reduced task-summary observability noise by limiting permission/background observability sections to debug-level sessions. Default AX Agent runtime UX now stays closer to `claw-code`, where transcript reading flow remains primary and diagnostics stay secondary.
|
||||
|
||||
@@ -4862,3 +4876,42 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
||||
- 업데이트: 2026-04-05 22:26 (KST)
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `IsFolderDataAlwaysEnabledTab()` helper를 추가하고, 코드 탭에서는 `_folderDataUsage`를 항상 `active`로 로드/저장하도록 고정했다. 이에 맞춰 `BtnDataUsage_Click`, `UpdateDataUsageUI()`, 오버레이의 `OverlayFolderDataUsageRow`, `BtnOverlayFolderDataUsage_Click`, `CmbOverlayFolderDataUsage_SelectionChanged`도 코드 탭에서는 숨김 또는 강제 active만 유지하게 정리했다.
|
||||
- 같은 파일의 사용자 메시지 bubble 렌더에서 코워크/코드 탭은 plain `TextBlock` 대신 [MarkdownRenderer.Render](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/MarkdownRenderer.cs) 경로를 타도록 변경했다. 이로써 코워크/코드 사용자 입력 안의 파일명/파일 경로가 assistant 응답과 동일한 파란 강조 규칙을 쓰게 됐다.
|
||||
- 업데이트: 2026-04-05 22:29 (KST)
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SelectTopic(...)`에서 fresh conversation이 이미 있는 경우에는 `StartNewConversation()`를 다시 호출하지 않도록 보정했다. 기존에는 메시지도 입력도 없는 빈 대화에서 프리셋만 눌러도 새 conversation이 계속 생성되어 좌측 목록에 `새 대화`가 누적될 수 있었다.
|
||||
- 현재는 `_currentConversation` 존재 여부를 기준으로 빈 대화 재사용과 실제 신규 생성 경로를 분리해, 프리셋 클릭은 같은 새 대화 안에서 메타데이터만 갱신하도록 맞췄다.
|
||||
- 업데이트: 2026-04-05 22:32 (KST)
|
||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `ChkOverlayWorkflowVisualizer`, `ChkOverlayShowTotalCallStats`, `ChkOverlayEnableAuditLog`에 `Checked/Unchecked` 이벤트를 연결했다.
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 각 토글 전용 저장 handler를 추가해, 개발자 탭 옵션이 눌린 직후 오버레이 재동기화로 기본값으로 되돌아가던 문제를 해결했다.
|
||||
- 업데이트: 2026-04-05 22:36 (KST)
|
||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 훅 설정 섹션에서 `도구 훅 스크립트 제한 시간` row 오른쪽 컬럼을 `120 -> 180`, 값 배지 컬럼을 `44 -> 56`으로 늘렸다.
|
||||
- 같은 파일의 `등록된 훅` 헤더 우측 `훅 추가` 버튼은 좌측 여백과 최소 폭을 키워, 작은 창에서도 텍스트 잘림 없이 보이도록 정리했다.
|
||||
- 업데이트: 2026-04-05 22:40 (KST)
|
||||
- AX Agent 테마 리소스 구조를 다시 점검한 뒤 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 프리셋을 추가했다. 새 리소스 파일은 [AgentNordLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordLight.xaml), [AgentNordDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordDark.xaml), [AgentNordSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordSystem.xaml), [AgentEmberLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberLight.xaml), [AgentEmberDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberDark.xaml), [AgentEmberSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberSystem.xaml) 이다.
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildAgentThemeDictionaryUri()`, `RefreshOverlayThemeCards()`에 새 프리셋 분기를 추가했고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `테마 스타일` 선택 카드에도 `Nord`, `Ember`를 노출했다.
|
||||
- 업데이트: 2026-04-06 00:58 (KST)
|
||||
- transcript renderer 분리 1차를 반영했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs)에 `ShowInlineUserAskAsync`, `CreatePlanDecisionCallback`, `ShowPlanButton`, `EnsurePlanViewerWindow` 계열을 옮기고, [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)에 `ShowTaskSummaryPopup`, `BuildTaskSummaryCard`를 옮겨 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 transcript 책임을 줄였다.
|
||||
- 권한/도구 결과 presentation catalog를 추가했다. [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령/네트워크/파일/일반` 권한 요청·허용 메타를, [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success/error/reject/cancel` 기준의 도구 결과 badge 메타를 제공한다.
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)`는 이제 이 catalog를 사용해 권한 요청과 도구 결과 badge를 결정한다.
|
||||
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status line/runtime summary 계산을 presentation layer로 분리했다. `UpdateTaskSummaryIndicators()`는 presentation summary만 소비하도록 바뀌었다.
|
||||
- 업데이트: 2026-04-06 01:12 (KST)
|
||||
- 코워크/코드의 `폴더 내 문서 활용`은 사용자 제어 옵션에서 제거하고 탭별 자동 정책으로 고정했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 하단 데이터 활용 버튼과 AX Agent 내부 설정의 관련 row를 제거했고, [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 의 대응 UI도 정리했다.
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 는 이제 `GetAutomaticFolderDataUsage()`만 사용해 채팅=`none`, 코워크=`passive`, 코드=`active`를 적용한다. 오버레이 저장에서도 `llm.FolderDataUsage`를 더 이상 사용자 입력으로 덮어쓰지 않으며, UI 클릭/선택 변경 핸들러는 자동 정책 유지용 no-op 수준으로 축소했다.
|
||||
- 업데이트: 2026-04-06 01:24 (KST)
|
||||
- transcript display catalog를 `claw-code` 기준으로 정교화했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 도구/스킬 이름과 badge label을 `파일 / 문서 / 빌드 / Git / 웹 / 질문 / 제안 / 에이전트` 축으로 재정의했고, summary fallback 문구도 더 자연스러운 한국어로 정리했다.
|
||||
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 색상/라벨로 분기하게 바꿨다. [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 도구 결과를 `파일 작업`, `빌드/테스트`, `Git`, `문서`, `스킬`, `웹 요청`, `명령 실행` 기준으로 성공/실패 라벨을 더 세밀하게 반환한다.
|
||||
- 이벤트 배너 renderer를 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 로 분리했다. 기존 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 있던 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 별도 partial로 옮겨, 이후 `permission/tool-result/plan` 타입별 renderer 확장을 더 쉽게 할 수 있는 구조를 마련했다.
|
||||
- Document update: 2026-04-06 08:12 (KST) - Split footer/preset/Git popup presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.FooterPresentation.cs`. The folder bar refresh, selected preset guide, Git branch surface update, Git popup assembly, and popup summary-pill/row helpers now live in a dedicated partial instead of the main window orchestration file.
|
||||
- Document update: 2026-04-06 08:12 (KST) - This pass keeps the AX Agent transcript/runtime flow closer to the `claw-code` separation model by reducing UI assembly inside the main chat window file and isolating footer/prompt-adjacent presentation code for future parity work.
|
||||
- Document update: 2026-04-06 08:28 (KST) - Split composer/draft queue presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.ComposerQueuePresentation.cs`. Input-box height calculation, draft-kind inference, queue summary pills, compact queue cards, expanded queue sections, queue badges, and queue action buttons now live in a dedicated partial.
|
||||
- Document update: 2026-04-06 08:28 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused while preserving the same runtime behavior, and it aligns AX Agent more closely with the `claw-code` model of separating prompt/footer presentation from session execution logic.
|
||||
- Document update: 2026-04-06 08:27 (KST) - Split message interaction presentation out of `ChatWindow.xaml.cs` into `ChatWindow.MessageInteractions.cs`. Feedback button creation, assistant response meta text, transcript entry animation, and inline user-message edit/regenerate flow are now grouped in a dedicated partial.
|
||||
- Document update: 2026-04-06 08:27 (KST) - This pass further reduces renderer responsibility in the main chat window file and moves AX Agent closer to the `claw-code` structure where transcript orchestration and message interaction presentation are separated.
|
||||
- Document update: 2026-04-06 08:39 (KST) - Moved runtime status bar event handling and spinner animation out of `ChatWindow.xaml.cs` into `ChatWindow.StatusPresentation.cs`. `UpdateStatusBar`, `StartStatusAnimation`, and `StopStatusAnimation` now live alongside the existing operational status presentation helpers.
|
||||
- Document update: 2026-04-06 08:39 (KST) - This pass reduces direct status-line branching in the main chat window file and keeps AX Agent closer to the `claw-code` model where runtime/footer presentation is separated from session orchestration.
|
||||
- Document update: 2026-04-06 08:47 (KST) - Split preview surface rendering out of `ChatWindow.xaml.cs` into `ChatWindow.PreviewPresentation.cs`. Preview tab tracking, panel open/close, tab bar rebuilding, preview header state, CSV/text/markdown/HTML loaders, context menu actions, and external popup preview are now grouped in a dedicated partial.
|
||||
- Document update: 2026-04-06 08:47 (KST) - This keeps `ChatWindow.xaml.cs` focused on transcript/runtime orchestration and aligns AX Agent more closely with the `claw-code` model where preview/session surfaces are treated as separate presentation layers.
|
||||
- Document update: 2026-04-06 08:55 (KST) - Split file browser presentation out of `ChatWindow.xaml.cs` into `ChatWindow.FileBrowserPresentation.cs`. File browser open/close handlers, folder tree population, file item header/icon/size formatting, context menu actions, and debounced refresh logic now live in a dedicated partial.
|
||||
- Document update: 2026-04-06 08:55 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused and aligns AX Agent more closely with the `claw-code` model where sidebar/file surfaces are separated from transcript and runtime flow.
|
||||
- Document update: 2026-04-06 09:36 (KST) - Added `OperationalStatusPresentationCatalog.cs` and moved compact-strip / quick-strip styling decisions out of `AppStateService.GetOperationalStatusPresentation(...)`. The service now delegates runtime/status presentation shaping to a dedicated catalog instead of mixing state aggregation with UI color logic.
|
||||
- Document update: 2026-04-06 09:36 (KST) - Expanded `PermissionRequestPresentationCatalog.cs` and `ToolResultPresentationCatalog.cs` with `Kind` and `Description` metadata so permission/tool-result transcript entries carry typed explanatory text rather than badge-only labels. `ChatWindow.AgentEventRendering.cs` now uses that description as a fallback summary when the event summary is empty.
|
||||
- Document update: 2026-04-06 09:36 (KST) - Removed the stale `Plan` option from `PermissionModePresentationCatalog.cs` and simplified `ChatWindow.PermissionPresentation.cs` so the permission popup and top-banner presentation only expose the live modes (`권한 요청`, `편집 자동 승인`, `권한 건너뛰기`, `읽기 전용`).
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
- Updated: 2026-04-05 16:55 (KST)
|
||||
- Current estimated parity vs `claw-code`: core execution engine `82%`, main chat UI `68%`, Cowork/Code status UX `63%`, internal settings linkage `88%`, overall AX Agent `74%`.
|
||||
- Engine-affecting settings should be handled conservatively during parity work. If a setting changes the main execution route, approval flow, or recovery behavior without representing a stable real-world user choice, it should be moved to developer-only UI or removed from user-facing surfaces.
|
||||
- Updated: 2026-04-06 09:36 (KST)
|
||||
- Progressed the maintainability track by moving runtime strip styling into `OperationalStatusPresentationCatalog.cs`, expanding permission/tool-result transcript catalogs with typed descriptions, and removing the stale plan-mode presentation branch from permission UI surfaces. The next structural focus remains footer/status/composer presentation slimming and regression ritual enforcement.
|
||||
|
||||
## Preserved History (Summary)
|
||||
- Core loop guards and post-tool verification gates are already partially implemented.
|
||||
@@ -218,6 +220,97 @@
|
||||
- Remaining quality target:
|
||||
- move more tool-result and permission-result presentation into smaller message-type-specific helpers, closer to `claw-code` component separation
|
||||
|
||||
## Focused Quality Tracks
|
||||
- Updated: 2026-04-06 09:27 (KST)
|
||||
- The remaining improvement work should now be managed in three parallel tracks so UX polish, runtime quality, and maintainability do not get mixed together.
|
||||
|
||||
### Track 1. User-Facing UI/UX Quality
|
||||
- Reference:
|
||||
- `src/components/Messages.tsx`
|
||||
- `src/components/MessageRow.tsx`
|
||||
- `src/components/StatusLine.tsx`
|
||||
- `src/components/PromptInput/PromptInput.tsx`
|
||||
- `src/components/PromptInput/PromptInputFooter.tsx`
|
||||
- `src/components/SessionPreview.tsx`
|
||||
- AX apply target:
|
||||
- `src/AxCopilot/Views/ChatWindow.xaml`
|
||||
- `src/AxCopilot/Views/ChatWindow.MessageInteractions.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.StatusPresentation.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.FooterPresentation.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs`
|
||||
- Focus:
|
||||
- keep transcript text dominant and metadata secondary
|
||||
- make footer controls read as a task bar, not a settings strip
|
||||
- unify preview surfaces, chooser popups, and approval cards under one visual language
|
||||
- reduce visual noise from queue/status/diagnostic surfaces unless the state is actionable
|
||||
- Completion criteria:
|
||||
- message rows, footer, preview, and inline approval/question cards feel visually coherent
|
||||
- chooser popups share the same spacing, hover behavior, and summary-row structure
|
||||
- footer/status elements appear only when they convey useful state
|
||||
- Quality criteria:
|
||||
- a user can understand “what is happening now” from the transcript and footer without opening extra panels
|
||||
- the interface remains readable under narrow widths without text clipping or layout jitter
|
||||
|
||||
### Track 2. LLM / Task Handling Quality
|
||||
- Reference:
|
||||
- `src/bootstrap/state.ts`
|
||||
- `src/bridge/initReplBridge.ts`
|
||||
- `src/bridge/sessionRunner.ts`
|
||||
- `src/components/messages/AssistantToolUseMessage.tsx`
|
||||
- `src/components/messages/PlanApprovalMessage.tsx`
|
||||
- `src/components/permissions/*`
|
||||
- AX apply target:
|
||||
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`
|
||||
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`
|
||||
- `src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs`
|
||||
- `src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs`
|
||||
- `src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.InlineInteractions.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs`
|
||||
- Focus:
|
||||
- keep all entry routes (`send`, `retry`, `regenerate`, `queue`, `slash`) on the same prepared execution path
|
||||
- distinguish `success / error / reject / cancel / needs-approval` tool results more clearly
|
||||
- keep plan approval and user-question flows transcript-native by default
|
||||
- minimize mismatches between execution state and what the user sees in the timeline
|
||||
- Completion criteria:
|
||||
- plan/permission/tool-result/question events all have consistent transcript-native lifecycles
|
||||
- reopen/retry/queue/compact flows preserve the same visible runtime state
|
||||
- tool failures and permission rejections are clearly distinguishable in transcript rendering
|
||||
- Quality criteria:
|
||||
- the same prompt under the same tab/settings uses the same execution route
|
||||
- users can tell whether the agent succeeded, failed, was blocked, or is waiting for approval without reading raw diagnostics
|
||||
|
||||
### Track 3. Maintainability / Extensibility Structure
|
||||
- Reference:
|
||||
- `src/components/*` split by role in `claw-code`
|
||||
- `src/components/messages/*`
|
||||
- `src/components/permissions/*`
|
||||
- `src/components/PromptInput/*`
|
||||
- AX apply target:
|
||||
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
|
||||
- `src/AxCopilot/Views/ChatWindow.*.cs`
|
||||
- `src/AxCopilot/Services/AppStateService.cs`
|
||||
- `src/AxCopilot/Services/Agent/*.cs`
|
||||
- Focus:
|
||||
- continue shrinking `ChatWindow.xaml.cs` toward orchestration-only responsibility
|
||||
- keep renderer, popup, footer, message-interaction, status, and preview logic in dedicated partials
|
||||
- centralize presentation rules in catalogs/models instead of scattered UI string/visibility branches
|
||||
- prepare the codebase for new permission types, new tool classes, and new transcript card types without re-bloating the main window file
|
||||
- Completion criteria:
|
||||
- `ChatWindow.xaml.cs` owns orchestration and runtime coordination more than direct UI element construction
|
||||
- new message/permission/tool card types can be added via presentation catalogs or dedicated partials
|
||||
- runtime summary and footer/status visibility derive from presentation models rather than ad-hoc branching
|
||||
- Quality criteria:
|
||||
- adding a new tool-result or approval type should mostly affect one catalog/renderer area
|
||||
- future UI polish work should land in dedicated presentation files rather than expanding the main window file again
|
||||
|
||||
## Recommended Execution Order
|
||||
- Updated: 2026-04-06 09:27 (KST)
|
||||
1. Finish Track 2 consistency first whenever a UX issue is caused by runtime truth mismatch.
|
||||
2. Apply Track 1 visual cleanup only after the state/message lifecycle is stable for that surface.
|
||||
3. Fold each stable surface into Track 3 structure immediately so later changes do not reintroduce `ChatWindow.xaml.cs` sprawl.
|
||||
4. Keep validating against `docs/AX_AGENT_REGRESSION_PROMPTS.md` after each change set, especially for `plan / permission / queue / compact / reopen`.
|
||||
|
||||
## Current Snapshot
|
||||
- Updated: 2026-04-05 19:42 (KST)
|
||||
- Estimated parity:
|
||||
@@ -407,3 +500,15 @@
|
||||
- `BuildTopicButtons()` rebuild frequency
|
||||
- `OnAgentEvent` timeline churn during long Cowork/Code runs
|
||||
- compact queue summary still needs one more pass to fully match `claw-code` footer minimalism
|
||||
|
||||
## Progress Notes
|
||||
- 업데이트: 2026-04-06 00:58 (KST)
|
||||
- transcript renderer 분리 1차 완료
|
||||
- AX 적용: [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)
|
||||
- 완료 조건: `plan / ask / task-summary` 렌더 helper가 메인 `ChatWindow.xaml.cs` 밖으로 이동
|
||||
- permission / tool-result presentation catalog 도입
|
||||
- AX 적용: [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)
|
||||
- 완료 조건: `AddAgentEventBanner(...)`가 권한/도구 결과 badge 메타를 inline switch가 아니라 catalog에서 해석
|
||||
- runtime summary 전용 계층 1차 반영
|
||||
- AX 적용: [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)
|
||||
- 완료 조건: 상태선 UI가 `OperationalStatusPresentationState`를 소비해 strip/runtime badge visibility를 계산
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class AgentTranscriptDisplayCatalog
|
||||
@@ -5,30 +7,44 @@ internal static class AgentTranscriptDisplayCatalog
|
||||
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawName))
|
||||
return slashPrefix ? "/스킬" : "도구";
|
||||
return slashPrefix ? "/skill" : "도구";
|
||||
|
||||
var normalized = rawName.Trim();
|
||||
var mapped = normalized.ToLowerInvariant() switch
|
||||
var lowered = normalized.ToLowerInvariant();
|
||||
var mapped = lowered switch
|
||||
{
|
||||
"file_read" => "파일 읽기",
|
||||
"file_write" => "파일 쓰기",
|
||||
"file_edit" => "파일 편집",
|
||||
"file_watch" => "파일 변경 감시",
|
||||
"file_info" => "파일 정보",
|
||||
"file_manage" => "파일 관리",
|
||||
"glob" => "파일 찾기",
|
||||
"grep" => "내용 검색",
|
||||
"folder_map" => "폴더 구조",
|
||||
|
||||
"document_reader" => "문서 읽기",
|
||||
"document_planner" => "문서 계획",
|
||||
"document_assembler" => "문서 조합",
|
||||
"document_review" => "문서 검토",
|
||||
"format_convert" => "형식 변환",
|
||||
"code_search" => "코드 검색",
|
||||
"code_review" => "코드 리뷰",
|
||||
"template_render" => "템플릿 렌더",
|
||||
|
||||
"build_run" => "빌드/실행",
|
||||
"test_loop" => "테스트 루프",
|
||||
"dev_env_detect" => "개발 환경 점검",
|
||||
"git_tool" => "Git",
|
||||
"process" => "프로세스",
|
||||
"glob" => "파일 찾기",
|
||||
"grep" => "내용 검색",
|
||||
"folder_map" => "폴더 맵",
|
||||
"memory" => "메모리",
|
||||
"diff_tool" => "Diff",
|
||||
"diff_preview" => "Diff 미리보기",
|
||||
|
||||
"process" => "명령 실행",
|
||||
"bash" => "Bash",
|
||||
"powershell" => "PowerShell",
|
||||
"web_fetch" => "웹 요청",
|
||||
"http" => "HTTP 요청",
|
||||
"user_ask" => "의견 요청",
|
||||
"suggest_actions" => "다음 작업 제안",
|
||||
|
||||
"task_create" => "작업 생성",
|
||||
"task_update" => "작업 업데이트",
|
||||
"task_list" => "작업 목록",
|
||||
@@ -43,7 +59,10 @@ internal static class AgentTranscriptDisplayCatalog
|
||||
if (!slashPrefix)
|
||||
return mapped;
|
||||
|
||||
return normalized.StartsWith('/') ? normalized : "/" + normalized.Replace(' ', '-');
|
||||
if (normalized.StartsWith('/'))
|
||||
return normalized;
|
||||
|
||||
return "/" + lowered.Replace('_', '-').Replace(' ', '-');
|
||||
}
|
||||
|
||||
public static string GetEventBadgeLabel(AgentEvent evt)
|
||||
@@ -59,22 +78,23 @@ internal static class AgentTranscriptDisplayCatalog
|
||||
|
||||
public static string GetTaskCategoryLabel(string? kind, string? title)
|
||||
{
|
||||
if (string.Equals(kind, "permission", System.StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(kind, "permission", StringComparison.OrdinalIgnoreCase))
|
||||
return "권한";
|
||||
if (string.Equals(kind, "queue", System.StringComparison.OrdinalIgnoreCase))
|
||||
return "큐";
|
||||
if (string.Equals(kind, "hook", System.StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(kind, "queue", StringComparison.OrdinalIgnoreCase))
|
||||
return "대기열";
|
||||
if (string.Equals(kind, "hook", StringComparison.OrdinalIgnoreCase))
|
||||
return "훅";
|
||||
if (string.Equals(kind, "subagent", System.StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(kind, "subagent", StringComparison.OrdinalIgnoreCase))
|
||||
return "에이전트";
|
||||
if (string.Equals(kind, "tool", System.StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase))
|
||||
return GetToolCategoryLabel(title);
|
||||
|
||||
return "작업";
|
||||
}
|
||||
|
||||
public static string BuildEventSummary(AgentEvent evt, string displayName)
|
||||
{
|
||||
var summary = (evt.Summary ?? "").Trim();
|
||||
var summary = (evt.Summary ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
return summary;
|
||||
|
||||
@@ -83,9 +103,11 @@ internal static class AgentTranscriptDisplayCatalog
|
||||
AgentEventType.ToolCall => $"{displayName} 실행 준비",
|
||||
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
||||
AgentEventType.SkillCall => $"{displayName} 실행",
|
||||
AgentEventType.PermissionRequest => $"{displayName} 실행 전 사용자 확인 필요",
|
||||
AgentEventType.PermissionGranted => $"{displayName} 실행이 허용됨",
|
||||
AgentEventType.PermissionDenied => $"{displayName} 실행이 거부됨",
|
||||
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
|
||||
AgentEventType.PermissionGranted => $"{displayName} 실행 권한이 승인되었습니다.",
|
||||
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
|
||||
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
|
||||
AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.",
|
||||
_ => summary,
|
||||
};
|
||||
}
|
||||
@@ -97,14 +119,24 @@ internal static class AgentTranscriptDisplayCatalog
|
||||
|
||||
return rawName.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage" => "파일",
|
||||
"build_run" or "test_loop" or "dev_env_detect" => "빌드",
|
||||
"git_tool" or "diff_tool" or "diff_preview" => "Git",
|
||||
"document_reader" or "document_planner" or "document_assembler" or "document_review" or "format_convert" or "template_render" => "문서",
|
||||
"user_ask" => "질문",
|
||||
"suggest_actions" => "제안",
|
||||
"process" => "실행",
|
||||
"spawn_agent" or "wait_agents" => "에이전트",
|
||||
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage"
|
||||
=> "파일",
|
||||
"build_run" or "test_loop" or "dev_env_detect"
|
||||
=> "빌드",
|
||||
"git_tool" or "diff_tool" or "diff_preview"
|
||||
=> "Git",
|
||||
"document_reader" or "document_planner" or "document_assembler" or "document_review" or "format_convert" or "template_render"
|
||||
=> "문서",
|
||||
"user_ask"
|
||||
=> "질문",
|
||||
"suggest_actions"
|
||||
=> "제안",
|
||||
"process" or "bash" or "powershell"
|
||||
=> "명령",
|
||||
"spawn_agent" or "wait_agents"
|
||||
=> "에이전트",
|
||||
"web_fetch" or "http"
|
||||
=> "웹",
|
||||
_ => "도구",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class OperationalStatusPresentationCatalog
|
||||
{
|
||||
public static AppStateService.OperationalStatusPresentationState Resolve(
|
||||
AppStateService.OperationalStatusState status,
|
||||
string tab,
|
||||
bool hasLiveRuntimeActivity,
|
||||
int runningConversationCount,
|
||||
int spotlightConversationCount,
|
||||
bool runningOnlyFilter,
|
||||
bool sortConversationsByRecent)
|
||||
{
|
||||
var showCompactStrip = !string.Equals(tab, "Chat", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var (stripBackgroundHex, stripBorderHex, stripForegroundHex) = ResolveStripColors(status.StripKind, showCompactStrip);
|
||||
|
||||
var allowQuickStrip = !string.Equals(tab, "Chat", StringComparison.OrdinalIgnoreCase);
|
||||
var quickRunningActive = runningOnlyFilter && runningConversationCount > 0;
|
||||
var quickHotActive = !sortConversationsByRecent && spotlightConversationCount > 0;
|
||||
var showQuickStrip = allowQuickStrip && (quickRunningActive || quickHotActive);
|
||||
|
||||
return new AppStateService.OperationalStatusPresentationState
|
||||
{
|
||||
ShowRuntimeBadge = status.ShowRuntimeBadge && hasLiveRuntimeActivity,
|
||||
RuntimeLabel = status.RuntimeLabel,
|
||||
ShowLastCompleted = status.ShowLastCompleted,
|
||||
LastCompletedText = status.LastCompletedText,
|
||||
ShowCompactStrip = showCompactStrip,
|
||||
StripKind = showCompactStrip ? status.StripKind : "none",
|
||||
StripText = showCompactStrip ? status.StripText : "",
|
||||
StripBackgroundHex = stripBackgroundHex,
|
||||
StripBorderHex = stripBorderHex,
|
||||
StripForegroundHex = stripForegroundHex,
|
||||
ShowQuickStrip = showQuickStrip,
|
||||
QuickRunningText = runningConversationCount > 0 ? $"진행 {runningConversationCount}" : "진행",
|
||||
QuickHotText = spotlightConversationCount > 0 ? $"활동 {spotlightConversationCount}" : "활동",
|
||||
QuickRunningActive = quickRunningActive,
|
||||
QuickHotActive = quickHotActive,
|
||||
QuickRunningBackgroundHex = quickRunningActive ? "#DBEAFE" : "#F8FAFC",
|
||||
QuickRunningBorderHex = quickRunningActive ? "#93C5FD" : "#E5E7EB",
|
||||
QuickRunningForegroundHex = quickRunningActive ? "#1D4ED8" : "#6B7280",
|
||||
QuickHotBackgroundHex = quickHotActive ? "#F5F3FF" : "#F8FAFC",
|
||||
QuickHotBorderHex = quickHotActive ? "#C4B5FD" : "#E5E7EB",
|
||||
QuickHotForegroundHex = quickHotActive ? "#6D28D9" : "#6B7280",
|
||||
};
|
||||
}
|
||||
|
||||
private static (string backgroundHex, string borderHex, string foregroundHex) ResolveStripColors(string stripKind, bool visible)
|
||||
{
|
||||
if (!visible)
|
||||
return ("", "", "");
|
||||
|
||||
if (string.Equals(stripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase))
|
||||
return ("#FFF7ED", "#FDBA74", "#C2410C");
|
||||
|
||||
if (string.Equals(stripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stripKind, "permission_denied", StringComparison.OrdinalIgnoreCase))
|
||||
return ("#FEF2F2", "#FECACA", "#991B1B");
|
||||
|
||||
return ("", "", "");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal sealed record PermissionModePresentation(
|
||||
string Mode,
|
||||
@@ -23,12 +23,6 @@ internal static class PermissionModePresentationCatalog
|
||||
"편집 자동 승인",
|
||||
"모든 파일 편집을 자동 승인합니다.",
|
||||
"#107C10"),
|
||||
new PermissionModePresentation(
|
||||
PermissionModeCatalog.Plan,
|
||||
"\uE7C3",
|
||||
"계획 모드",
|
||||
"변경하기 전에 계획을 먼저 만듭니다.",
|
||||
"#4338CA"),
|
||||
new PermissionModePresentation(
|
||||
PermissionModeCatalog.BypassPermissions,
|
||||
"\uE814",
|
||||
@@ -41,7 +35,7 @@ internal static class PermissionModePresentationCatalog
|
||||
{
|
||||
var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode);
|
||||
return Ordered.FirstOrDefault(item =>
|
||||
string.Equals(item.Mode, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
?? Ordered[0];
|
||||
string.Equals(item.Mode, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
?? Ordered[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal sealed record PermissionRequestPresentation(
|
||||
string Kind,
|
||||
string Icon,
|
||||
string Label,
|
||||
string Description,
|
||||
string BackgroundHex,
|
||||
string ForegroundHex);
|
||||
|
||||
internal static class PermissionRequestPresentationCatalog
|
||||
{
|
||||
public static PermissionRequestPresentation Resolve(string? toolName, bool pending)
|
||||
{
|
||||
var tool = (toolName ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
if (tool.Contains("bash") || tool.Contains("powershell") || tool.Contains("process"))
|
||||
return Build(
|
||||
"command",
|
||||
pending,
|
||||
"\uE756",
|
||||
"명령 실행 권한 요청",
|
||||
"명령 실행 권한 확인",
|
||||
"터미널 명령을 실행하기 전에 확인이 필요합니다.",
|
||||
"명령 실행이 승인되어 계속 진행합니다.",
|
||||
"#FEF2F2",
|
||||
"#DC2626");
|
||||
|
||||
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||||
return Build(
|
||||
"web",
|
||||
pending,
|
||||
"\uE774",
|
||||
"웹 요청 권한 요청",
|
||||
"웹 요청 권한 확인",
|
||||
"외부 웹 요청 전 사용자 확인이 필요합니다.",
|
||||
"웹 요청이 승인되어 계속 진행합니다.",
|
||||
"#FFF7ED",
|
||||
"#C2410C");
|
||||
|
||||
if (tool.Contains("skill"))
|
||||
return Build(
|
||||
"skill",
|
||||
pending,
|
||||
"\uE8A5",
|
||||
"스킬 실행 권한 요청",
|
||||
"스킬 실행 권한 확인",
|
||||
"연결된 스킬 실행 전 확인이 필요합니다.",
|
||||
"스킬 실행이 승인되어 계속 진행합니다.",
|
||||
"#F5F3FF",
|
||||
"#7C3AED");
|
||||
|
||||
if (tool.Contains("ask"))
|
||||
return Build(
|
||||
"question",
|
||||
pending,
|
||||
"\uE897",
|
||||
"의견 요청 확인",
|
||||
"의견 요청 완료",
|
||||
"사용자에게 선택이나 답변을 요청합니다.",
|
||||
"사용자 의견을 받아 다음 단계로 진행합니다.",
|
||||
"#EFF6FF",
|
||||
"#2563EB");
|
||||
|
||||
if (tool.Contains("file_edit") || tool.Contains("file_write") || tool.Contains("edit"))
|
||||
return Build(
|
||||
"file_edit",
|
||||
pending,
|
||||
"\uE70F",
|
||||
"파일 수정 권한 요청",
|
||||
"파일 수정 권한 확인",
|
||||
"파일을 만들거나 수정하기 전에 확인이 필요합니다.",
|
||||
"파일 수정이 승인되어 계속 진행합니다.",
|
||||
"#FFF7ED",
|
||||
"#C2410C");
|
||||
|
||||
if (tool.Contains("file") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder"))
|
||||
return Build(
|
||||
"file_access",
|
||||
pending,
|
||||
"\uE8A5",
|
||||
"파일 접근 권한 요청",
|
||||
"파일 접근 권한 확인",
|
||||
"폴더와 파일 내용을 읽기 전에 확인이 필요합니다.",
|
||||
"파일 접근이 승인되어 계속 진행합니다.",
|
||||
"#FFF7ED",
|
||||
"#C2410C");
|
||||
|
||||
return Build(
|
||||
"generic",
|
||||
pending,
|
||||
"\uE897",
|
||||
"권한 요청",
|
||||
"권한 확인",
|
||||
"계속 진행하기 전에 사용자 확인이 필요합니다.",
|
||||
"요청이 승인되어 계속 진행합니다.",
|
||||
"#FFF7ED",
|
||||
"#C2410C");
|
||||
}
|
||||
|
||||
private static PermissionRequestPresentation Build(
|
||||
string kind,
|
||||
bool pending,
|
||||
string icon,
|
||||
string pendingLabel,
|
||||
string resolvedLabel,
|
||||
string pendingDescription,
|
||||
string resolvedDescription,
|
||||
string backgroundHex,
|
||||
string foregroundHex)
|
||||
{
|
||||
return new PermissionRequestPresentation(
|
||||
kind,
|
||||
pending ? icon : "\uE73E",
|
||||
pending ? pendingLabel : resolvedLabel,
|
||||
pending ? pendingDescription : resolvedDescription,
|
||||
pending ? backgroundHex : "#ECFDF5",
|
||||
pending ? foregroundHex : "#059669");
|
||||
}
|
||||
}
|
||||
149
src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs
Normal file
149
src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal sealed record ToolResultPresentation(
|
||||
string Kind,
|
||||
string Icon,
|
||||
string Label,
|
||||
string Description,
|
||||
string BackgroundHex,
|
||||
string ForegroundHex,
|
||||
string StatusKind);
|
||||
|
||||
internal static class ToolResultPresentationCatalog
|
||||
{
|
||||
public static ToolResultPresentation Resolve(AgentEvent evt, string fallbackLabel)
|
||||
{
|
||||
var summary = (evt.Summary ?? string.Empty).Trim();
|
||||
var tool = (evt.ToolName ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var baseLabel = string.IsNullOrWhiteSpace(fallbackLabel) ? "도구 결과" : fallbackLabel;
|
||||
var kind = ResolveKind(tool);
|
||||
|
||||
if (summary.Contains("취소", StringComparison.OrdinalIgnoreCase) ||
|
||||
summary.Contains("중단", StringComparison.OrdinalIgnoreCase) ||
|
||||
evt.Type == AgentEventType.StopRequested)
|
||||
{
|
||||
return new ToolResultPresentation(
|
||||
"cancel",
|
||||
"\uE711",
|
||||
$"{baseLabel} 취소",
|
||||
"요청이 중단되어 결과가 취소되었습니다.",
|
||||
"#F8FAFC",
|
||||
"#475569",
|
||||
"cancel");
|
||||
}
|
||||
|
||||
if (summary.Contains("거부", StringComparison.OrdinalIgnoreCase) ||
|
||||
summary.Contains("반려", StringComparison.OrdinalIgnoreCase) ||
|
||||
summary.Contains("권한 거부", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ToolResultPresentation(
|
||||
"reject",
|
||||
"\uE783",
|
||||
$"{baseLabel} 거부",
|
||||
"권한이 거부되어 작업이 중단되었습니다.",
|
||||
"#FEF2F2",
|
||||
"#DC2626",
|
||||
"reject");
|
||||
}
|
||||
|
||||
if (!evt.Success || evt.Type == AgentEventType.Error)
|
||||
{
|
||||
return new ToolResultPresentation(
|
||||
kind,
|
||||
"\uE783",
|
||||
BuildFailureLabel(kind, baseLabel),
|
||||
BuildFailureDescription(kind),
|
||||
"#FEF2F2",
|
||||
"#DC2626",
|
||||
"error");
|
||||
}
|
||||
|
||||
return new ToolResultPresentation(
|
||||
kind,
|
||||
"\uE73E",
|
||||
BuildSuccessLabel(kind, baseLabel),
|
||||
BuildSuccessDescription(kind),
|
||||
"#ECFDF5",
|
||||
"#16A34A",
|
||||
"success");
|
||||
}
|
||||
|
||||
private static string ResolveKind(string tool)
|
||||
{
|
||||
if (tool.Contains("file"))
|
||||
return "file";
|
||||
if (tool.Contains("build") || tool.Contains("test"))
|
||||
return "build";
|
||||
if (tool.Contains("git") || tool.Contains("diff"))
|
||||
return "git";
|
||||
if (tool.Contains("document") || tool.Contains("format") || tool.Contains("template"))
|
||||
return "document";
|
||||
if (tool.Contains("skill"))
|
||||
return "skill";
|
||||
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||||
return "web";
|
||||
if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell"))
|
||||
return "command";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
private static string BuildSuccessLabel(string kind, string baseLabel)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
"file" => "파일 작업 완료",
|
||||
"build" => "빌드/테스트 완료",
|
||||
"git" => "Git 작업 완료",
|
||||
"document" => "문서 작업 완료",
|
||||
"skill" => "스킬 실행 완료",
|
||||
"web" => "웹 요청 완료",
|
||||
"command" => "명령 실행 완료",
|
||||
_ => baseLabel,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFailureLabel(string kind, string baseLabel)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
"file" => "파일 작업 실패",
|
||||
"build" => "빌드/테스트 실패",
|
||||
"git" => "Git 작업 실패",
|
||||
"document" => "문서 작업 실패",
|
||||
"skill" => "스킬 실행 실패",
|
||||
"web" => "웹 요청 실패",
|
||||
"command" => "명령 실행 실패",
|
||||
_ => $"{baseLabel} 실패",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSuccessDescription(string kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
"file" => "파일 처리 결과가 정상적으로 반영되었습니다.",
|
||||
"build" => "빌드나 테스트 단계가 성공적으로 끝났습니다.",
|
||||
"git" => "Git 관련 작업이 정상적으로 완료되었습니다.",
|
||||
"document" => "문서 생성 또는 변환 작업이 완료되었습니다.",
|
||||
"skill" => "선택된 스킬이 정상적으로 실행되었습니다.",
|
||||
"web" => "웹 요청이 정상적으로 완료되었습니다.",
|
||||
"command" => "명령 실행이 정상적으로 끝났습니다.",
|
||||
_ => "요청한 작업이 정상적으로 완료되었습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFailureDescription(string kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
"file" => "파일 처리 중 문제가 발생했습니다.",
|
||||
"build" => "빌드나 테스트 단계에서 실패가 발생했습니다.",
|
||||
"git" => "Git 관련 작업이 실패했습니다.",
|
||||
"document" => "문서 생성 또는 변환 작업이 실패했습니다.",
|
||||
"skill" => "스킬 실행 중 문제가 발생했습니다.",
|
||||
"web" => "웹 요청 처리에 실패했습니다.",
|
||||
"command" => "명령 실행 중 오류가 발생했습니다.",
|
||||
_ => "작업 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,31 @@ public sealed class AppStateService
|
||||
public string StripText { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed class OperationalStatusPresentationState
|
||||
{
|
||||
public bool ShowRuntimeBadge { get; init; }
|
||||
public string RuntimeLabel { get; init; } = "";
|
||||
public bool ShowLastCompleted { get; init; }
|
||||
public string LastCompletedText { get; init; } = "";
|
||||
public bool ShowCompactStrip { get; init; }
|
||||
public string StripKind { get; init; } = "none";
|
||||
public string StripText { get; init; } = "";
|
||||
public string StripBackgroundHex { get; init; } = "";
|
||||
public string StripBorderHex { get; init; } = "";
|
||||
public string StripForegroundHex { get; init; } = "";
|
||||
public bool ShowQuickStrip { get; init; }
|
||||
public string QuickRunningText { get; init; } = "";
|
||||
public string QuickHotText { get; init; } = "";
|
||||
public bool QuickRunningActive { get; init; }
|
||||
public bool QuickHotActive { get; init; }
|
||||
public string QuickRunningBackgroundHex { get; init; } = "#F8FAFC";
|
||||
public string QuickRunningBorderHex { get; init; } = "#E5E7EB";
|
||||
public string QuickRunningForegroundHex { get; init; } = "#6B7280";
|
||||
public string QuickHotBackgroundHex { get; init; } = "#F8FAFC";
|
||||
public string QuickHotBorderHex { get; init; } = "#E5E7EB";
|
||||
public string QuickHotForegroundHex { get; init; } = "#6B7280";
|
||||
}
|
||||
|
||||
public ChatSessionStateService? ChatSession { get; private set; }
|
||||
public SkillCatalogState Skills { get; } = new();
|
||||
public McpCatalogState Mcp { get; } = new();
|
||||
@@ -620,6 +645,25 @@ public sealed class AppStateService
|
||||
};
|
||||
}
|
||||
|
||||
public OperationalStatusPresentationState GetOperationalStatusPresentation(
|
||||
string tab,
|
||||
bool hasLiveRuntimeActivity,
|
||||
int runningConversationCount,
|
||||
int spotlightConversationCount,
|
||||
bool runningOnlyFilter,
|
||||
bool sortConversationsByRecent)
|
||||
{
|
||||
var status = GetOperationalStatus(tab);
|
||||
return OperationalStatusPresentationCatalog.Resolve(
|
||||
status,
|
||||
tab,
|
||||
hasLiveRuntimeActivity,
|
||||
runningConversationCount,
|
||||
spotlightConversationCount,
|
||||
runningOnlyFilter,
|
||||
sortConversationsByRecent);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
|
||||
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();
|
||||
|
||||
|
||||
17
src/AxCopilot/Themes/AgentEmberDark.xaml
Normal file
17
src/AxCopilot/Themes/AgentEmberDark.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#1B1510"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#241C15"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#32261D"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#3A2C21"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#F6EEE6"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#D0B9A6"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#A68F7E"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#F29A54"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#4A382B"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#2B2119"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#F6C08A"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#4C392B"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#755B48"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||
</ResourceDictionary>
|
||||
17
src/AxCopilot/Themes/AgentEmberLight.xaml
Normal file
17
src/AxCopilot/Themes/AgentEmberLight.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#FAF6EF"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFDFC"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#7A6758"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#A08D7D"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#C96B2C"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#E7D8C9"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#F7EBDD"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#9A531E"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#E4D4C4"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||
</ResourceDictionary>
|
||||
17
src/AxCopilot/Themes/AgentEmberSystem.xaml
Normal file
17
src/AxCopilot/Themes/AgentEmberSystem.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#FAF6EF"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFDFC"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#7A6758"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#A08D7D"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#C96B2C"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#E7D8C9"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#F7EBDD"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#9A531E"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#E4D4C4"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
||||
</ResourceDictionary>
|
||||
17
src/AxCopilot/Themes/AgentNordDark.xaml
Normal file
17
src/AxCopilot/Themes/AgentNordDark.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#111827"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#182231"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#223247"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#26384F"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#E8EEF5"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#AEBBCB"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#8393A7"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#66A3FF"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#334155"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#1C2A3B"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#9CC3FF"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#334155"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#52647A"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||
</ResourceDictionary>
|
||||
17
src/AxCopilot/Themes/AgentNordLight.xaml
Normal file
17
src/AxCopilot/Themes/AgentNordLight.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F4F7FB"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E6EDF7"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF3F9"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#1F2A37"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#64748B"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#94A3B8"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#2F6FBE"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#D8E1EB"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#EAF2FB"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#215C9E"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#D7E0EA"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B6C3D2"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||
</ResourceDictionary>
|
||||
17
src/AxCopilot/Themes/AgentNordSystem.xaml
Normal file
17
src/AxCopilot/Themes/AgentNordSystem.xaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F4F7FB"/>
|
||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E6EDF7"/>
|
||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF3F9"/>
|
||||
<SolidColorBrush x:Key="PrimaryText" Color="#1F2A37"/>
|
||||
<SolidColorBrush x:Key="SecondaryText" Color="#64748B"/>
|
||||
<SolidColorBrush x:Key="PlaceholderText" Color="#94A3B8"/>
|
||||
<SolidColorBrush x:Key="AccentColor" Color="#2F6FBE"/>
|
||||
<SolidColorBrush x:Key="SeparatorColor" Color="#D8E1EB"/>
|
||||
<SolidColorBrush x:Key="HintBackground" Color="#EAF2FB"/>
|
||||
<SolidColorBrush x:Key="HintText" Color="#215C9E"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#D7E0EA"/>
|
||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B6C3D2"/>
|
||||
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
||||
</ResourceDictionary>
|
||||
@@ -391,7 +391,7 @@
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ToggleSwitch}"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid Margin="0,8,0,0" Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -533,32 +533,6 @@
|
||||
Style="{StaticResource OutlineHoverBtn}"
|
||||
Click="BtnReasoningMode_Click"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="폴더 데이터 활용"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
|
||||
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Border.ToolTip>
|
||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18" MaxWidth="320">
|
||||
현재 작업 폴더 파일과 문맥을 AI가 얼마나 적극적으로 참고할지 정합니다.
|
||||
</TextBlock>
|
||||
</ToolTip>
|
||||
</Border.ToolTip>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Button x:Name="BtnFolderDataUsage"
|
||||
Grid.Column="1"
|
||||
MinWidth="120"
|
||||
Style="{StaticResource OutlineHoverBtn}"
|
||||
Click="BtnFolderDataUsage_Click"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
|
||||
@@ -17,7 +17,6 @@ public partial class AgentSettingsWindow : Window
|
||||
private readonly LlmSettings _llm;
|
||||
private string _permissionMode = PermissionModeCatalog.Deny;
|
||||
private string _reasoningMode = "detailed";
|
||||
private string _folderDataUsage = "active";
|
||||
private string _operationMode = OperationModePolicy.InternalMode;
|
||||
private string _displayMode = "rich";
|
||||
private string _defaultOutputFormat = "auto";
|
||||
@@ -48,7 +47,6 @@ public partial class AgentSettingsWindow : Window
|
||||
ModelInput.Text = _selectedModel;
|
||||
_permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_llm.FilePermission);
|
||||
_reasoningMode = string.IsNullOrWhiteSpace(_llm.AgentDecisionLevel) ? "detailed" : _llm.AgentDecisionLevel;
|
||||
_folderDataUsage = string.IsNullOrWhiteSpace(_llm.FolderDataUsage) ? "active" : _llm.FolderDataUsage;
|
||||
_operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||||
_displayMode = "rich";
|
||||
_defaultOutputFormat = string.IsNullOrWhiteSpace(_llm.DefaultOutputFormat) ? "auto" : _llm.DefaultOutputFormat;
|
||||
@@ -114,7 +112,6 @@ public partial class AgentSettingsWindow : Window
|
||||
BtnOperationMode.Content = BuildOperationModeLabel(_operationMode);
|
||||
BtnPermissionMode.Content = PermissionModeCatalog.ToDisplayLabel(_permissionMode);
|
||||
BtnReasoningMode.Content = BuildReasoningModeLabel(_reasoningMode);
|
||||
BtnFolderDataUsage.Content = BuildFolderDataUsageLabel(_folderDataUsage);
|
||||
}
|
||||
|
||||
private void RefreshDisplayModeCards()
|
||||
@@ -173,16 +170,6 @@ public partial class AgentSettingsWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFolderDataUsageLabel(string mode)
|
||||
{
|
||||
return (mode ?? "none").ToLowerInvariant() switch
|
||||
{
|
||||
"active" => "적극 활용",
|
||||
"passive" => "소극 활용",
|
||||
_ => "활용하지 않음",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildOutputFormatLabel(string format)
|
||||
{
|
||||
return (format ?? "auto").ToLowerInvariant() switch
|
||||
@@ -469,17 +456,6 @@ public partial class AgentSettingsWindow : Window
|
||||
RefreshModeLabels();
|
||||
}
|
||||
|
||||
private void BtnFolderDataUsage_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_folderDataUsage = _folderDataUsage switch
|
||||
{
|
||||
"none" => "passive",
|
||||
"passive" => "active",
|
||||
_ => "none",
|
||||
};
|
||||
RefreshModeLabels();
|
||||
}
|
||||
|
||||
private void BtnDefaultOutputFormat_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_defaultOutputFormat = (_defaultOutputFormat ?? "auto").ToLowerInvariant() switch
|
||||
@@ -514,7 +490,6 @@ public partial class AgentSettingsWindow : Window
|
||||
_llm.FilePermission = _permissionMode;
|
||||
_llm.DefaultAgentPermission = _permissionMode;
|
||||
_llm.AgentDecisionLevel = _reasoningMode;
|
||||
_llm.FolderDataUsage = _folderDataUsage;
|
||||
_llm.AgentUiExpressionLevel = "rich";
|
||||
_llm.DefaultOutputFormat = _defaultOutputFormat;
|
||||
_llm.DefaultMood = _defaultMood;
|
||||
|
||||
430
src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs
Normal file
430
src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs
Normal file
@@ -0,0 +1,430 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private Border CreateCompactEventPill(string summary, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
BorderBrush = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(6, 2, 6, 2),
|
||||
Margin = new Thickness(8, 2, 220, 2),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Child = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "\uE9CE",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 8,
|
||||
Foreground = accentBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = summary,
|
||||
FontSize = 8.75,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(4, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void AddAgentEventBanner(AgentEvent evt)
|
||||
{
|
||||
var logLevel = _settings.Settings.Llm.AgentLogLevel;
|
||||
|
||||
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
|
||||
{
|
||||
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 summary = !string.IsNullOrWhiteSpace(evt.Summary)
|
||||
? evt.Summary!
|
||||
: $"계획 {evt.Steps.Count}단계";
|
||||
var pill = CreateCompactEventPill(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase)
|
||||
&& evt.Type is AgentEventType.Paused or AgentEventType.Resumed)
|
||||
return;
|
||||
|
||||
if (!string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase)
|
||||
&& evt.Type == AgentEventType.ToolCall)
|
||||
return;
|
||||
|
||||
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats";
|
||||
var transcriptBadgeLabel = GetTranscriptBadgeLabel(evt);
|
||||
var permissionPresentation = evt.Type switch
|
||||
{
|
||||
AgentEventType.PermissionRequest => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: true),
|
||||
AgentEventType.PermissionGranted => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: false),
|
||||
_ => null
|
||||
};
|
||||
var toolResultPresentation = evt.Type == AgentEventType.ToolResult
|
||||
? ToolResultPresentationCatalog.Resolve(evt, transcriptBadgeLabel)
|
||||
: null;
|
||||
|
||||
var (icon, label, bgHex, fgHex) = isTotalStats
|
||||
? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED")
|
||||
: evt.Type switch
|
||||
{
|
||||
AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"),
|
||||
AgentEventType.PermissionRequest => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex),
|
||||
AgentEventType.PermissionGranted => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex),
|
||||
AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"),
|
||||
AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary),
|
||||
AgentEventType.ToolCall => ("\uE8A7", transcriptBadgeLabel, "#EEF6FF", "#3B82F6"),
|
||||
AgentEventType.ToolResult => (toolResultPresentation!.Icon, toolResultPresentation.Label, toolResultPresentation.BackgroundHex, toolResultPresentation.ForegroundHex),
|
||||
AgentEventType.SkillCall => ("\uE8A5", transcriptBadgeLabel, "#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 itemDisplayName = evt.Type == AgentEventType.SkillCall
|
||||
? GetAgentItemDisplayName(evt.ToolName, slashPrefix: true)
|
||||
: GetAgentItemDisplayName(evt.ToolName);
|
||||
var eventSummaryText = BuildAgentEventSummaryText(evt, itemDisplayName);
|
||||
if (string.IsNullOrWhiteSpace(eventSummaryText))
|
||||
{
|
||||
eventSummaryText = evt.Type switch
|
||||
{
|
||||
AgentEventType.PermissionRequest or AgentEventType.PermissionGranted => permissionPresentation?.Description ?? "",
|
||||
AgentEventType.ToolResult => toolResultPresentation?.Description ?? "",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
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(12, 0, 12, 1),
|
||||
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 = 8.25,
|
||||
Foreground = accentBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 3, 0),
|
||||
});
|
||||
headerLeft.Children.Add(new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 8.25,
|
||||
FontWeight = FontWeights.Medium,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
if (IsTranscriptToolLikeEvent(evt) && !string.IsNullOrWhiteSpace(evt.ToolName))
|
||||
{
|
||||
headerLeft.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $" · {itemDisplayName}",
|
||||
FontSize = 8.25,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
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 = 7.5,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(3, 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(3.5, 1, 3.5, 1),
|
||||
Margin = new Thickness(3, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = tokenText,
|
||||
FontSize = 7.25,
|
||||
Foreground = secondaryText,
|
||||
FontFamily = new FontFamily("Consolas"),
|
||||
},
|
||||
});
|
||||
}
|
||||
Grid.SetColumn(headerRight, 1);
|
||||
|
||||
headerGrid.Children.Add(headerLeft);
|
||||
headerGrid.Children.Add(headerRight);
|
||||
sp.Children.Add(headerGrid);
|
||||
|
||||
if (logLevel == "simple")
|
||||
{
|
||||
if (!string.IsNullOrEmpty(eventSummaryText))
|
||||
{
|
||||
var shortSummary = eventSummaryText.Length > 100
|
||||
? eventSummaryText[..100] + "…"
|
||||
: eventSummaryText;
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = shortSummary,
|
||||
FontSize = 8.4,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Margin = new Thickness(11, 1, 0, 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(eventSummaryText))
|
||||
{
|
||||
var summaryText = eventSummaryText.Length > 92 ? eventSummaryText[..92] + "…" : eventSummaryText;
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = summaryText,
|
||||
FontSize = 8.4,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(11, 1, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
var reviewChipRow = BuildReviewSignalChipRow(
|
||||
kind: null,
|
||||
toolName: evt.ToolName,
|
||||
title: label,
|
||||
summary: evt.Summary);
|
||||
if (reviewChipRow != null && string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reviewChipRow.Margin = new Thickness(12, 2, 0, 0);
|
||||
sp.Children.Add(reviewChipRow);
|
||||
}
|
||||
|
||||
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(5, 3, 5, 3),
|
||||
Margin = new Thickness(12, 2, 0, 0),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = evt.ToolInput.Length > 240 ? evt.ToolInput[..240] + "…" : evt.ToolInput,
|
||||
FontSize = 8.5,
|
||||
Foreground = secondaryText,
|
||||
FontFamily = new FontFamily("Consolas"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(evt.FilePath))
|
||||
{
|
||||
var fileName = System.IO.Path.GetFileName(evt.FilePath);
|
||||
var dirName = System.IO.Path.GetDirectoryName(evt.FilePath) ?? "";
|
||||
|
||||
if (!string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var compactPathRow = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(12, 1.5, 0, 0),
|
||||
ToolTip = evt.FilePath,
|
||||
};
|
||||
compactPathRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE8B7",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 8,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 3, 0),
|
||||
});
|
||||
compactPathRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(fileName) ? evt.FilePath : fileName,
|
||||
FontSize = 8.5,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
});
|
||||
sp.Children.Add(compactPathRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var pathBorder = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderColor,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(7, 4, 7, 4),
|
||||
Margin = new Thickness(12, 2, 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 topRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
topRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE8B7",
|
||||
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,
|
||||
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,
|
||||
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;
|
||||
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
624
src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs
Normal file
624
src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs
Normal file
@@ -0,0 +1,624 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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,
|
||||
};
|
||||
const double baseHeight = 42;
|
||||
const double lineStep = 22;
|
||||
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
|
||||
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
|
||||
|
||||
InputBox.MinLines = 1;
|
||||
InputBox.MaxLines = maxLines;
|
||||
InputBox.Height = targetHeight;
|
||||
InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines
|
||||
? ScrollBarVisibility.Auto
|
||||
: ScrollBarVisibility.Disabled;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
var queuedItem = EnqueueDraftRequest(text, priority, explicitKind);
|
||||
|
||||
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 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 items = _appState.GetDraftQueueItems(_activeTab);
|
||||
|
||||
DraftPreviewCard.Visibility = Visibility.Collapsed;
|
||||
BtnDraftEnqueue.IsEnabled = false;
|
||||
DraftPreviewText.Text = string.Empty;
|
||||
|
||||
RebuildDraftQueuePanel(items);
|
||||
}
|
||||
|
||||
private bool IsDraftQueueExpanded()
|
||||
=> _expandedDraftQueueTabs.Contains(_activeTab);
|
||||
|
||||
private void ToggleDraftQueueExpanded()
|
||||
{
|
||||
if (!_expandedDraftQueueTabs.Add(_activeTab))
|
||||
_expandedDraftQueueTabs.Remove(_activeTab);
|
||||
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
|
||||
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> 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;
|
||||
}
|
||||
|
||||
var summary = _appState.GetDraftQueueSummary(_activeTab);
|
||||
var shouldShowQueue =
|
||||
IsDraftQueueExpanded()
|
||||
|| summary.RunningCount > 0
|
||||
|| summary.QueuedCount > 0
|
||||
|| summary.FailedCount > 0;
|
||||
|
||||
if (!shouldShowQueue)
|
||||
{
|
||||
DraftQueuePanel.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
DraftQueuePanel.Visibility = Visibility.Visible;
|
||||
DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary, IsDraftQueueExpanded()));
|
||||
if (!IsDraftQueueExpanded())
|
||||
{
|
||||
DraftQueuePanel.Children.Add(CreateCompactDraftQueuePanel(visibleItems, summary));
|
||||
return;
|
||||
}
|
||||
|
||||
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 UIElement CreateCompactDraftQueuePanel(IReadOnlyList<DraftQueueItem> items, AppStateService.DraftQueueSummaryState summary)
|
||||
{
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
|
||||
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
|
||||
var focusItem = items.FirstOrDefault(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||||
?? items.FirstOrDefault(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
|
||||
?? items.FirstOrDefault(IsDraftBlocked)
|
||||
?? items.FirstOrDefault(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
|
||||
?? items.FirstOrDefault(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
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, 4),
|
||||
};
|
||||
|
||||
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();
|
||||
left.Children.Add(new TextBlock
|
||||
{
|
||||
Text = focusItem == null
|
||||
? "대기열 항목이 준비되면 여기에서 요약됩니다."
|
||||
: $"{GetDraftStateLabel(focusItem)} · {GetDraftKindLabel(focusItem)}",
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
});
|
||||
left.Children.Add(new TextBlock
|
||||
{
|
||||
Text = focusItem?.Text ?? BuildDraftQueueCompactSummaryText(summary),
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
MaxWidth = 520,
|
||||
});
|
||||
Grid.SetColumn(left, 0);
|
||||
root.Children.Add(left);
|
||||
|
||||
var action = CreateDraftQueueActionButton("상세", ToggleDraftQueueExpanded);
|
||||
action.Margin = new Thickness(12, 0, 0, 0);
|
||||
Grid.SetColumn(action, 1);
|
||||
root.Children.Add(action);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private static string BuildDraftQueueCompactSummaryText(AppStateService.DraftQueueSummaryState summary)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (summary.RunningCount > 0) parts.Add($"실행 {summary.RunningCount}");
|
||||
if (summary.QueuedCount > 0) parts.Add($"다음 {summary.QueuedCount}");
|
||||
if (summary.FailedCount > 0) parts.Add($"실패 {summary.FailedCount}");
|
||||
return parts.Count == 0 ? "대기열 0" : string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private void AddDraftQueueSection(string label, IReadOnlyList<DraftQueueItem> 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, bool isExpanded)
|
||||
{
|
||||
var root = new Grid
|
||||
{
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
};
|
||||
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
|
||||
var wrap = new WrapPanel();
|
||||
|
||||
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 (isExpanded && summary.BlockedCount > 0)
|
||||
wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C"));
|
||||
if (isExpanded && 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"));
|
||||
|
||||
Grid.SetColumn(wrap, 0);
|
||||
root.Children.Add(wrap);
|
||||
|
||||
var toggle = CreateDraftQueueActionButton(isExpanded ? "간단히" : "상세 보기", ToggleDraftQueueExpanded);
|
||||
toggle.Margin = new Thickness(10, 0, 0, 0);
|
||||
Grid.SetColumn(toggle, 1);
|
||||
root.Children.Add(toggle);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
144
src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
Normal file
144
src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (TokenUsagePopup != null)
|
||||
TokenUsagePopup.IsOpen = false;
|
||||
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}%";
|
||||
TokenUsagePercentText.Text = percentText;
|
||||
TokenUsageSummaryText.Text = $"컨텍스트 {percentText}";
|
||||
TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}";
|
||||
CompactNowLabel.Text = compactLabel;
|
||||
|
||||
if (TokenUsagePopupTitle != null)
|
||||
TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}";
|
||||
if (TokenUsagePopupUsage != null)
|
||||
TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}";
|
||||
if (TokenUsagePopupDetail != null)
|
||||
TokenUsagePopupDetail.Text = _pendingPostCompaction ? "compact 후 첫 응답 대기 중" : $"자동 압축 시작 {triggerPercent}%";
|
||||
if (TokenUsagePopupCompact != null)
|
||||
TokenUsagePopupCompact.Text = "AX Agent가 컨텍스트를 자동으로 관리합니다";
|
||||
|
||||
TokenUsageCard.ToolTip = null;
|
||||
|
||||
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 14, 14, 11);
|
||||
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 14, 14, 11, 2.5);
|
||||
}
|
||||
|
||||
private void TokenUsageCard_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
_tokenUsagePopupCloseTimer.Stop();
|
||||
if (TokenUsagePopup != null && TokenUsageCard?.Visibility == Visibility.Visible)
|
||||
TokenUsagePopup.IsOpen = true;
|
||||
}
|
||||
|
||||
private void TokenUsageCard_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_tokenUsagePopupCloseTimer.Stop();
|
||||
_tokenUsagePopupCloseTimer.Start();
|
||||
}
|
||||
|
||||
private void TokenUsagePopup_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
_tokenUsagePopupCloseTimer.Stop();
|
||||
}
|
||||
|
||||
private void TokenUsagePopup_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_tokenUsagePopupCloseTimer.Stop();
|
||||
_tokenUsagePopupCloseTimer.Start();
|
||||
}
|
||||
|
||||
private void CloseTokenUsagePopupIfIdle()
|
||||
{
|
||||
if (TokenUsagePopup == null)
|
||||
return;
|
||||
|
||||
var cardHovered = IsMouseInsideElement(TokenUsageCard);
|
||||
var popupHovered = TokenUsagePopup.Child is FrameworkElement popupChild && IsMouseInsideElement(popupChild);
|
||||
if (!cardHovered && !popupHovered)
|
||||
TokenUsagePopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private static bool IsMouseInsideElement(FrameworkElement? element)
|
||||
{
|
||||
if (element == null || !element.IsVisible || element.ActualWidth <= 0 || element.ActualHeight <= 0)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var mouse = System.Windows.Forms.Control.MousePosition;
|
||||
var point = element.PointFromScreen(new Point(mouse.X, mouse.Y));
|
||||
return point.X >= 0 && point.Y >= 0 && point.X <= element.ActualWidth && point.Y <= element.ActualHeight;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return element.IsMouseOver;
|
||||
}
|
||||
}
|
||||
}
|
||||
407
src/AxCopilot/Views/ChatWindow.FileBrowserPresentation.cs
Normal file
407
src/AxCopilot/Views/ChatWindow.FileBrowserPresentation.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private static readonly HashSet<string> _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) || !Directory.Exists(folder))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new 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) || !Directory.Exists(folder))
|
||||
{
|
||||
FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
|
||||
return;
|
||||
}
|
||||
|
||||
FileBrowserTitle.Text = $"파일 탐색기 — {Path.GetFileName(folder)}";
|
||||
var count = 0;
|
||||
PopulateDirectory(new DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
|
||||
}
|
||||
|
||||
private void PopulateDirectory(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();
|
||||
var 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 += (_, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
TryShowPreview(capturedPath);
|
||||
};
|
||||
|
||||
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 = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
if (_previewableExtensions.Contains(ext))
|
||||
AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
|
||||
|
||||
AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = filePath, UseShellExecute = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
AddItem("\uED25", "폴더에서 보기", () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start("explorer.exe", $"/select,\"{filePath}\"");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
AddItem("\uE8C8", "경로 복사", () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(filePath);
|
||||
ShowToast("경로 복사됨");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
AddSep();
|
||||
|
||||
AddItem("\uE8AC", "이름 변경", () =>
|
||||
{
|
||||
var dir = Path.GetDirectoryName(filePath) ?? "";
|
||||
var oldName = Path.GetFileName(filePath);
|
||||
var dlg = new InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
|
||||
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
|
||||
{
|
||||
var newPath = Path.Combine(dir, dlg.ResponseText.Trim());
|
||||
try
|
||||
{
|
||||
File.Move(filePath, newPath);
|
||||
BuildFileTree();
|
||||
ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowToast($"이름 변경 실패: {ex.Message}", "\uE783");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
AddItem("\uE74D", "삭제", () =>
|
||||
{
|
||||
var result = CustomMessageBox.Show(
|
||||
$"파일을 삭제하시겠습니까?\n{Path.GetFileName(filePath)}",
|
||||
"파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (result == MessageBoxResult.Yes)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
BuildFileTree();
|
||||
ShowToast("파일 삭제됨");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowToast($"삭제 실패: {ex.Message}", "\uE783");
|
||||
}
|
||||
}
|
||||
}, dangerBrush, dangerBrush);
|
||||
|
||||
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
private void RefreshFileTreeIfVisible()
|
||||
{
|
||||
if (FileBrowserPanel.Visibility != Visibility.Visible)
|
||||
return;
|
||||
|
||||
_fileBrowserRefreshTimer?.Stop();
|
||||
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
_fileBrowserRefreshTimer.Tick += (_, _) =>
|
||||
{
|
||||
_fileBrowserRefreshTimer.Stop();
|
||||
BuildFileTree();
|
||||
};
|
||||
_fileBrowserRefreshTimer.Start();
|
||||
}
|
||||
}
|
||||
441
src/AxCopilot/Views/ChatWindow.FooterPresentation.cs
Normal file
441
src/AxCopilot/Views/ChatWindow.FooterPresentation.cs
Normal file
@@ -0,0 +1,441 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
private void UpdateDataUsageUI()
|
||||
{
|
||||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||||
}
|
||||
|
||||
private void UpdateSelectedPresetGuide(ChatConversation? conversation = null)
|
||||
{
|
||||
if (SelectedPresetGuide == null || SelectedPresetGuideTitle == null || SelectedPresetGuideDesc == null)
|
||||
return;
|
||||
|
||||
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||
SelectedPresetGuideTitle.Text = "";
|
||||
SelectedPresetGuideDesc.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
conversation ??= _currentConversation;
|
||||
var category = conversation?.Category?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||
SelectedPresetGuideTitle.Text = "";
|
||||
SelectedPresetGuideDesc.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var preset = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets)
|
||||
.FirstOrDefault(p => string.Equals(p.Category?.Trim(), category, StringComparison.OrdinalIgnoreCase));
|
||||
if (preset == null)
|
||||
{
|
||||
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||
SelectedPresetGuideTitle.Text = "";
|
||||
SelectedPresetGuideDesc.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedPresetGuideTitle.Text = string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||||
? $"선택된 작업 유형 · {preset.Label}"
|
||||
: $"선택된 대화 주제 · {preset.Label}";
|
||||
SelectedPresetGuideDesc.Text = string.IsNullOrWhiteSpace(preset.Description)
|
||||
? (preset.Placeholder ?? "")
|
||||
: preset.Description;
|
||||
SelectedPresetGuide.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
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 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)
|
||||
{
|
||||
GitBranchItems.Children.Add(CreatePopupSectionLabel($"최근 전환 · {recentBranches.Count}", 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;
|
||||
}
|
||||
}
|
||||
424
src/AxCopilot/Views/ChatWindow.InlineInteractions.cs
Normal file
424
src/AxCopilot/Views/ChatWindow.InlineInteractions.cs
Normal file
@@ -0,0 +1,424 @@
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private async Task<string?> ShowInlineUserAskAsync(string question, List<string> options, string defaultValue)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
AddUserAskCard(question, options, defaultValue, tcs);
|
||||
});
|
||||
|
||||
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
|
||||
if (completed != tcs.Task)
|
||||
{
|
||||
await Dispatcher.InvokeAsync(RemoveUserAskCard);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private void RemoveUserAskCard()
|
||||
{
|
||||
if (_userAskCard == null)
|
||||
return;
|
||||
|
||||
MessagePanel.Children.Remove(_userAskCard);
|
||||
_userAskCard = null;
|
||||
}
|
||||
|
||||
private void AddUserAskCard(string question, List<string> options, string defaultValue, TaskCompletionSource<string?> tcs)
|
||||
{
|
||||
RemoveUserAskCard();
|
||||
|
||||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||
var accentColor = ((SolidColorBrush)accentBrush).Color;
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x24, accentColor.R, accentColor.G, accentColor.B));
|
||||
var itemBg = TryFindResource("ItemBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B));
|
||||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x16, 0xFF, 0xFF, 0xFF));
|
||||
var okBrush = BrushFromHex("#10B981");
|
||||
var dangerBrush = BrushFromHex("#EF4444");
|
||||
|
||||
var container = new Border
|
||||
{
|
||||
Margin = new Thickness(40, 4, 90, 8),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MaxWidth = Math.Max(420, GetMessageMaxWidth() - 36),
|
||||
Background = itemBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(14, 12, 14, 12),
|
||||
};
|
||||
|
||||
var outer = new StackPanel();
|
||||
outer.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "의견 요청",
|
||||
FontSize = 12.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
});
|
||||
outer.Children.Add(new TextBlock
|
||||
{
|
||||
Text = question,
|
||||
Margin = new Thickness(0, 4, 0, 10),
|
||||
FontSize = 12.5,
|
||||
Foreground = primaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 20,
|
||||
});
|
||||
|
||||
Border? selectedOption = null;
|
||||
string selectedResponse = defaultValue;
|
||||
|
||||
if (options.Count > 0)
|
||||
{
|
||||
var optionPanel = new WrapPanel
|
||||
{
|
||||
Margin = new Thickness(0, 0, 0, 10),
|
||||
ItemWidth = double.NaN,
|
||||
};
|
||||
|
||||
foreach (var option in options.Where(static option => !string.IsNullOrWhiteSpace(option)))
|
||||
{
|
||||
var optionLabel = option.Trim();
|
||||
var optBorder = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
Margin = new Thickness(0, 0, 8, 8),
|
||||
Cursor = Cursors.Hand,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = optionLabel,
|
||||
FontSize = 12,
|
||||
Foreground = primaryText,
|
||||
},
|
||||
};
|
||||
|
||||
optBorder.MouseEnter += (s, _) =>
|
||||
{
|
||||
if (!ReferenceEquals(selectedOption, s))
|
||||
((Border)s).Background = hoverBg;
|
||||
};
|
||||
optBorder.MouseLeave += (s, _) =>
|
||||
{
|
||||
if (!ReferenceEquals(selectedOption, s))
|
||||
((Border)s).Background = Brushes.Transparent;
|
||||
};
|
||||
optBorder.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
if (selectedOption != null)
|
||||
{
|
||||
selectedOption.Background = Brushes.Transparent;
|
||||
selectedOption.BorderBrush = borderBrush;
|
||||
}
|
||||
|
||||
selectedOption = optBorder;
|
||||
selectedOption.Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
|
||||
selectedOption.BorderBrush = accentBrush;
|
||||
selectedResponse = optionLabel;
|
||||
};
|
||||
|
||||
optionPanel.Children.Add(optBorder);
|
||||
}
|
||||
|
||||
outer.Children.Add(optionPanel);
|
||||
}
|
||||
|
||||
outer.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "직접 입력",
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
});
|
||||
|
||||
var inputBox = new TextBox
|
||||
{
|
||||
Text = defaultValue,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MinHeight = 42,
|
||||
MaxHeight = 100,
|
||||
FontSize = 12.5,
|
||||
Padding = new Thickness(10, 8, 10, 8),
|
||||
Background = Brushes.Transparent,
|
||||
Foreground = primaryText,
|
||||
CaretBrush = primaryText,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
};
|
||||
inputBox.TextChanged += (_, _) =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(inputBox.Text))
|
||||
{
|
||||
selectedResponse = inputBox.Text.Trim();
|
||||
if (selectedOption != null)
|
||||
{
|
||||
selectedOption.Background = Brushes.Transparent;
|
||||
selectedOption.BorderBrush = borderBrush;
|
||||
selectedOption = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
outer.Children.Add(inputBox);
|
||||
|
||||
var buttonRow = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Margin = new Thickness(0, 12, 0, 0),
|
||||
};
|
||||
|
||||
Border BuildActionButton(string label, Brush bg, Brush fg)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = bg,
|
||||
BorderBrush = bg,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(12, 7, 12, 7),
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = fg,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
var cancelBtn = BuildActionButton("취소", Brushes.Transparent, dangerBrush);
|
||||
cancelBtn.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x44, 0x44));
|
||||
cancelBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
RemoveUserAskCard();
|
||||
tcs.TrySetResult(null);
|
||||
};
|
||||
buttonRow.Children.Add(cancelBtn);
|
||||
|
||||
var submitBtn = BuildActionButton("전달", okBrush, Brushes.White);
|
||||
submitBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
var finalResponse = !string.IsNullOrWhiteSpace(inputBox.Text)
|
||||
? inputBox.Text.Trim()
|
||||
: selectedResponse?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(finalResponse))
|
||||
finalResponse = defaultValue?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(finalResponse))
|
||||
return;
|
||||
|
||||
RemoveUserAskCard();
|
||||
tcs.TrySetResult(finalResponse);
|
||||
};
|
||||
buttonRow.Children.Add(submitBtn);
|
||||
|
||||
outer.Children.Add(buttonRow);
|
||||
container.Child = outer;
|
||||
_userAskCard = container;
|
||||
|
||||
container.Opacity = 0;
|
||||
container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
|
||||
MessagePanel.Children.Add(container);
|
||||
ForceScrollToEnd();
|
||||
inputBox.Focus();
|
||||
inputBox.CaretIndex = inputBox.Text.Length;
|
||||
}
|
||||
|
||||
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
|
||||
{
|
||||
return async (planSummary, options) =>
|
||||
{
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
var steps = TaskDecomposer.ExtractSteps(planSummary);
|
||||
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_pendingPlanSummary = planSummary;
|
||||
_pendingPlanSteps = steps.ToList();
|
||||
EnsurePlanViewerWindow();
|
||||
_planViewerWindow?.LoadPlan(planSummary, steps, tcs);
|
||||
ShowPlanButton(true);
|
||||
AddDecisionButtons(tcs, options);
|
||||
});
|
||||
|
||||
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
|
||||
if (completed != tcs.Task)
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.Hide();
|
||||
ResetPendingPlanPresentation();
|
||||
});
|
||||
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)
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.SwitchToExecutionMode();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.Hide();
|
||||
ResetPendingPlanPresentation();
|
||||
});
|
||||
}
|
||||
|
||||
return agentDecision;
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsurePlanViewerWindow()
|
||||
{
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
return;
|
||||
|
||||
_planViewerWindow = new PlanViewerWindow(this);
|
||||
_planViewerWindow.Closing += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
_planViewerWindow.Hide();
|
||||
};
|
||||
}
|
||||
|
||||
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 (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0)
|
||||
return;
|
||||
|
||||
EnsurePlanViewerWindow();
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|
||||
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|
||||
|| !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps))
|
||||
{
|
||||
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
|
||||
}
|
||||
_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);
|
||||
}
|
||||
|
||||
private void CompletePlanViewer()
|
||||
{
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
_planViewerWindow.MarkComplete();
|
||||
ResetPendingPlanPresentation();
|
||||
}
|
||||
|
||||
private void ResetPendingPlanPresentation()
|
||||
{
|
||||
_pendingPlanSummary = null;
|
||||
_pendingPlanSteps.Clear();
|
||||
ShowPlanButton(false);
|
||||
}
|
||||
|
||||
private static bool IsWindowAlive(Window? w)
|
||||
{
|
||||
if (w == null)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
var _ = w.IsVisible;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
394
src/AxCopilot/Views/ChatWindow.MessageInteractions.cs
Normal file
394
src/AxCopilot/Views/ChatWindow.MessageInteractions.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
|
||||
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
|
||||
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
|
||||
Action? resetSibling = null, Action<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;
|
||||
}
|
||||
|
||||
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
|
||||
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
|
||||
{
|
||||
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 TextBlock? CreateAssistantMessageMetaText(ChatMessage? message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
if (message.ResponseElapsedMs is > 0)
|
||||
{
|
||||
var elapsedMs = message.ResponseElapsedMs.Value;
|
||||
parts.Add(elapsedMs < 1000
|
||||
? $"{elapsedMs}ms"
|
||||
: $"{(elapsedMs / 1000.0):0.0}s");
|
||||
}
|
||||
|
||||
if (message.PromptTokens > 0 || message.CompletionTokens > 0)
|
||||
{
|
||||
var totalTokens = message.PromptTokens + message.CompletionTokens;
|
||||
parts.Add($"{FormatTokenCount(totalTokens)} tokens");
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TextBlock
|
||||
{
|
||||
Text = string.Join(" · ", parts),
|
||||
FontSize = 9.75,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Margin = new Thickness(4, 2, 0, 0),
|
||||
Opacity = 0.72,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
var idx = MessagePanel.Children.IndexOf(wrapper);
|
||||
if (idx < 0)
|
||||
{
|
||||
_isEditing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
|
||||
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
|
||||
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
|
||||
"</Border></ControlTemplate>");
|
||||
sendBtn.Click += (_, _) =>
|
||||
{
|
||||
var newText = editBox.Text.Trim();
|
||||
if (!string.IsNullOrEmpty(newText))
|
||||
{
|
||||
_ = SubmitEditAsync(idx, newText);
|
||||
}
|
||||
};
|
||||
btnBar.Children.Add(sendBtn);
|
||||
|
||||
editPanel.Children.Add(btnBar);
|
||||
MessagePanel.Children[idx] = editPanel;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var userMsgIdx = -1;
|
||||
var uiIdx = 0;
|
||||
lock (_convLock)
|
||||
{
|
||||
for (var 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderMessages(preserveViewport: true);
|
||||
AutoScrollIfNeeded();
|
||||
|
||||
await SendRegenerateAsync(conv);
|
||||
try
|
||||
{
|
||||
if (ChatSession == null)
|
||||
{
|
||||
_storage.Save(conv);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Debug($"대화 저장 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
RefreshConversationList();
|
||||
}
|
||||
}
|
||||
307
src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs
Normal file
307
src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private void BtnPermission_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PermissionPopup == null) return;
|
||||
PermissionItems.Children.Clear();
|
||||
|
||||
ChatConversation? currentConversation;
|
||||
lock (_convLock) currentConversation = _currentConversation;
|
||||
var coreLevels = PermissionModePresentationCatalog.Ordered.ToList();
|
||||
var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||||
|
||||
void AddPermissionRows(Panel container, IEnumerable<PermissionModePresentation> 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 = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(10, 10, 10, 10),
|
||||
Margin = new Thickness(0, 0, 0, 4),
|
||||
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 = 15,
|
||||
Foreground = BrushFromHex(item.ColorHex),
|
||||
Margin = new Thickness(0, 0, 10, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
|
||||
var textStack = new StackPanel();
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = item.Title,
|
||||
FontSize = 13.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||
});
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = item.Description,
|
||||
FontSize = 11.5,
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 16,
|
||||
MaxWidth = 220,
|
||||
});
|
||||
Grid.SetColumn(textStack, 1);
|
||||
row.Children.Add(textStack);
|
||||
|
||||
var check = new TextBlock
|
||||
{
|
||||
Text = isActive ? "\uE73E" : "",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = BrushFromHex("#2563EB"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(12, 0, 0, 0),
|
||||
};
|
||||
Grid.SetColumn(check, 2);
|
||||
row.Children.Add(check);
|
||||
|
||||
rowBorder.Child = row;
|
||||
rowBorder.MouseEnter += (_, _) => rowBorder.Background = BrushFromHex("#F8FAFC");
|
||||
rowBorder.MouseLeave += (_, _) => rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
AddPermissionRows(PermissionItems, coreLevels);
|
||||
|
||||
PermissionPopup.IsOpen = true;
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
TryFocusFirstPermissionElement(PermissionItems);
|
||||
}, System.Windows.Threading.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<string, string>(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 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<string, bool>(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",
|
||||
"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 = new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB));
|
||||
PermissionLabel.Foreground = defaultFg;
|
||||
PermissionIcon.Foreground = iconFg;
|
||||
if (BtnPermission != null)
|
||||
BtnPermission.BorderBrush = BrushFromHex("#BFDBFE");
|
||||
if (PermissionTopBanner != null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/AxCopilot/Views/ChatWindow.PopupPresentation.cs
Normal file
159
src/AxCopilot/Views/ChatWindow.PopupPresentation.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
786
src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs
Normal file
786
src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs
Normal file
@@ -0,0 +1,786 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
|
||||
};
|
||||
|
||||
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
|
||||
private readonly List<string> _previewTabs = new();
|
||||
private string? _activePreviewTab;
|
||||
private bool _webViewInitialized;
|
||||
private Popup? _previewTabPopup;
|
||||
|
||||
private static readonly string WebView2DataFolder =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "WebView2");
|
||||
|
||||
private void TryShowPreview(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 = 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,
|
||||
});
|
||||
|
||||
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,
|
||||
Tag = "close",
|
||||
};
|
||||
|
||||
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.MouseLeftButtonUp += (_, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
ClosePreviewTab(closePath);
|
||||
};
|
||||
|
||||
tabContent.Children.Add(closeBtn);
|
||||
tabBorder.Child = tabContent;
|
||||
|
||||
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 (!string.Equals(tabPath, _previewTabs[^1], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
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 = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
SetPreviewHeader(filePath);
|
||||
|
||||
PreviewWebView.Visibility = Visibility.Collapsed;
|
||||
PreviewTextScroll.Visibility = Visibility.Collapsed;
|
||||
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||||
PreviewEmpty.Visibility = Visibility.Collapsed;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
SetPreviewHeaderState("파일을 찾을 수 없습니다");
|
||||
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 = 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 = File.ReadAllText(filePath);
|
||||
if (text.Length > 50000)
|
||||
text = text[..50000] + "\n\n... (이후 생략)";
|
||||
PreviewTextBlock.Text = text;
|
||||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||
break;
|
||||
|
||||
default:
|
||||
SetPreviewHeaderState("지원되지 않는 형식");
|
||||
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
|
||||
PreviewEmpty.Visibility = Visibility.Visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetPreviewHeaderState("미리보기 오류");
|
||||
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
|
||||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPreviewHeader(string filePath)
|
||||
{
|
||||
if (PreviewHeaderTitle == null || PreviewHeaderSubtitle == null || PreviewHeaderMeta == null)
|
||||
return;
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var extension = Path.GetExtension(filePath).TrimStart('.').ToUpperInvariant();
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var sizeText = fileInfo.Exists
|
||||
? fileInfo.Length >= 1024 * 1024
|
||||
? $"{fileInfo.Length / 1024d / 1024d:F1} MB"
|
||||
: $"{Math.Max(1, fileInfo.Length / 1024d):F0} KB"
|
||||
: "파일 없음";
|
||||
|
||||
PreviewHeaderTitle.Text = string.IsNullOrWhiteSpace(fileName) ? "미리보기" : fileName;
|
||||
PreviewHeaderSubtitle.Text = filePath;
|
||||
PreviewHeaderMeta.Text = string.IsNullOrWhiteSpace(extension)
|
||||
? sizeText
|
||||
: $"{extension} · {sizeText}";
|
||||
}
|
||||
|
||||
private void SetPreviewHeaderState(string state)
|
||||
{
|
||||
if (PreviewHeaderMeta != null && !string.IsNullOrWhiteSpace(state))
|
||||
PreviewHeaderMeta.Text = state;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadCsvPreview(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
if (lines.Length == 0)
|
||||
return;
|
||||
|
||||
var dt = new DataTable();
|
||||
var headers = ParseCsvLine(lines[0]);
|
||||
foreach (var h in headers)
|
||||
dt.Columns.Add(h);
|
||||
|
||||
var maxRows = Math.Min(lines.Length, 501);
|
||||
for (var i = 1; i < maxRows; i++)
|
||||
{
|
||||
var vals = ParseCsvLine(lines[i]);
|
||||
var row = dt.NewRow();
|
||||
for (var 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 List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var 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;
|
||||
if (PreviewHeaderTitle != null) PreviewHeaderTitle.Text = "미리보기";
|
||||
if (PreviewHeaderSubtitle != null) PreviewHeaderSubtitle.Text = "선택한 파일이 여기에 표시됩니다";
|
||||
if (PreviewHeaderMeta != null) PreviewHeaderMeta.Text = "파일 메타";
|
||||
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("<html></html>");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void PreviewTabBar_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
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) || !File.Exists(_activePreviewTab))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = _activePreviewTab,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
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 (!File.Exists(filePath))
|
||||
return;
|
||||
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var fileName = 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 = 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 DataGrid
|
||||
{
|
||||
AutoGenerateColumns = true,
|
||||
IsReadOnly = true,
|
||||
Background = Brushes.Transparent,
|
||||
Foreground = Brushes.White,
|
||||
BorderThickness = new Thickness(0),
|
||||
FontSize = 12,
|
||||
};
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
if (lines.Length > 0)
|
||||
{
|
||||
var dt = new DataTable();
|
||||
var headers = ParseCsvLine(lines[0]);
|
||||
foreach (var h in headers)
|
||||
dt.Columns.Add(h);
|
||||
for (var i = 1; i < Math.Min(lines.Length, 1001); i++)
|
||||
{
|
||||
var vals = ParseCsvLine(lines[i]);
|
||||
var row = dt.NewRow();
|
||||
for (var 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 = 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();
|
||||
}
|
||||
}
|
||||
184
src/AxCopilot/Views/ChatWindow.SelectionPopupPresentation.cs
Normal file
184
src/AxCopilot/Views/ChatWindow.SelectionPopupPresentation.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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 hintBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||
var row = new Border
|
||||
{
|
||||
Background = selected ? hintBackground : Brushes.Transparent,
|
||||
BorderBrush = selected ? BrushFromHex("#D6E4FF") : Brushes.Transparent,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Cursor = Cursors.Hand,
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
};
|
||||
|
||||
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 = 14,
|
||||
Foreground = selected ? accentBrush : secondaryText,
|
||||
Margin = new Thickness(0, 1, 10, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
grid.Children.Add(iconBlock);
|
||||
|
||||
var textStack = new StackPanel();
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 13.5,
|
||||
FontWeight = selected ? FontWeights.SemiBold : FontWeights.Medium,
|
||||
Foreground = primaryText,
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = description,
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
}
|
||||
|
||||
Grid.SetColumn(textStack, 1);
|
||||
grid.Children.Add(textStack);
|
||||
|
||||
if (selected)
|
||||
{
|
||||
var check = CreateSimpleCheck(accentBrush, 14);
|
||||
Grid.SetColumn(check, 2);
|
||||
check.Margin = new Thickness(10, 0, 0, 0);
|
||||
if (check is FrameworkElement element)
|
||||
element.VerticalAlignment = VerticalAlignment.Center;
|
||||
grid.Children.Add(check);
|
||||
}
|
||||
|
||||
row.Child = grid;
|
||||
row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBackground;
|
||||
row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent;
|
||||
row.MouseLeftButtonUp += (_, _) => onClick?.Invoke();
|
||||
return row;
|
||||
}
|
||||
}
|
||||
284
src/AxCopilot/Views/ChatWindow.StatusPresentation.cs
Normal file
284
src/AxCopilot/Views/ChatWindow.StatusPresentation.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private Storyboard? _statusSpinStoryboard;
|
||||
|
||||
private AppStateService.OperationalStatusPresentationState BuildOperationalStatusPresentation()
|
||||
{
|
||||
var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|
||||
&& (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0);
|
||||
return _appState.GetOperationalStatusPresentation(
|
||||
_activeTab,
|
||||
hasLiveRuntimeActivity,
|
||||
_runningConversationCount,
|
||||
_spotlightConversationCount,
|
||||
_runningOnlyFilter,
|
||||
_sortConversationsByRecent);
|
||||
}
|
||||
|
||||
private void UpdateTaskSummaryIndicators()
|
||||
{
|
||||
var status = BuildOperationalStatusPresentation();
|
||||
|
||||
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)
|
||||
{
|
||||
ConversationStatusStrip.Visibility = status.ShowCompactStrip
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
ConversationStatusStripLabel.Text = status.ShowCompactStrip ? status.StripText : "";
|
||||
|
||||
if (status.ShowCompactStrip)
|
||||
{
|
||||
ConversationStatusStrip.Background = BrushFromHex(status.StripBackgroundHex);
|
||||
ConversationStatusStrip.BorderBrush = BrushFromHex(status.StripBorderHex);
|
||||
ConversationStatusStripLabel.Foreground = BrushFromHex(status.StripForegroundHex);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateConversationQuickStripUi(status);
|
||||
}
|
||||
|
||||
private void UpdateConversationQuickStripUi()
|
||||
{
|
||||
UpdateConversationQuickStripUi(BuildOperationalStatusPresentation());
|
||||
}
|
||||
|
||||
private void UpdateConversationQuickStripUi(AppStateService.OperationalStatusPresentationState status)
|
||||
{
|
||||
if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null
|
||||
|| BtnQuickRunningFilter == null || BtnQuickHotSort == null)
|
||||
return;
|
||||
|
||||
ConversationQuickStrip.Visibility = status.ShowQuickStrip
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
|
||||
QuickRunningLabel.Text = status.QuickRunningText;
|
||||
QuickHotLabel.Text = status.QuickHotText;
|
||||
|
||||
BtnQuickRunningFilter.Background = BrushFromHex(status.QuickRunningBackgroundHex);
|
||||
BtnQuickRunningFilter.BorderBrush = BrushFromHex(status.QuickRunningBorderHex);
|
||||
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
|
||||
QuickRunningLabel.Foreground = BrushFromHex(status.QuickRunningForegroundHex);
|
||||
|
||||
BtnQuickHotSort.Background = BrushFromHex(status.QuickHotBackgroundHex);
|
||||
BtnQuickHotSort.BorderBrush = BrushFromHex(status.QuickHotBorderHex);
|
||||
BtnQuickHotSort.BorderThickness = new Thickness(1);
|
||||
QuickHotLabel.Foreground = BrushFromHex(status.QuickHotForegroundHex);
|
||||
}
|
||||
|
||||
private void SetStatus(string text, bool spinning)
|
||||
{
|
||||
if (StatusLabel != null)
|
||||
StatusLabel.Text = text;
|
||||
|
||||
if (spinning)
|
||||
StartStatusAnimation();
|
||||
else
|
||||
StopStatusAnimation();
|
||||
}
|
||||
|
||||
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 SetStatusIdle()
|
||||
{
|
||||
StopStatusAnimation();
|
||||
if (StatusLabel != null)
|
||||
StatusLabel.Text = "대기 중";
|
||||
if (StatusElapsed != null)
|
||||
{
|
||||
StatusElapsed.Text = "";
|
||||
StatusElapsed.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
if (StatusTokens != null)
|
||||
{
|
||||
StatusTokens.Text = "";
|
||||
StatusTokens.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
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}";
|
||||
StatusTokens.Visibility = Visibility.Visible;
|
||||
RefreshContextUsageVisual();
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
var isDebugLogLevel = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
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:
|
||||
if (!isDebugLogLevel)
|
||||
break;
|
||||
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:
|
||||
if (!isDebugLogLevel)
|
||||
break;
|
||||
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:
|
||||
if (!isDebugLogLevel)
|
||||
break;
|
||||
SetStatus("⏸ 일시정지", spinning: false);
|
||||
break;
|
||||
case AgentEventType.Resumed:
|
||||
if (!isDebugLogLevel)
|
||||
break;
|
||||
SetStatus("▶ 재개됨", spinning: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartStatusAnimation()
|
||||
{
|
||||
if (_statusSpinStoryboard != null)
|
||||
return;
|
||||
|
||||
var anim = new DoubleAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 360,
|
||||
Duration = TimeSpan.FromSeconds(2),
|
||||
RepeatBehavior = RepeatBehavior.Forever,
|
||||
};
|
||||
|
||||
_statusSpinStoryboard = new Storyboard();
|
||||
Storyboard.SetTarget(anim, StatusDiamond);
|
||||
Storyboard.SetTargetProperty(anim,
|
||||
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
|
||||
_statusSpinStoryboard.Children.Add(anim);
|
||||
_statusSpinStoryboard.Begin();
|
||||
}
|
||||
|
||||
private void StopStatusAnimation()
|
||||
{
|
||||
_statusSpinStoryboard?.Stop();
|
||||
_statusSpinStoryboard = null;
|
||||
}
|
||||
}
|
||||
358
src/AxCopilot/Views/ChatWindow.TaskSummary.cs
Normal file
358
src/AxCopilot/Views/ChatWindow.TaskSummary.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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 = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
Margin = new Thickness(8, 5, 8, 2),
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "현재 상태 요약",
|
||||
FontSize = 8.5,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(8, 0, 8, 5),
|
||||
});
|
||||
|
||||
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(8),
|
||||
Padding = new Thickness(8, 6, 8, 6),
|
||||
Margin = new Thickness(6, 0, 6, 6),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"실행 run {ShortRunId(_appState.AgentRun.RunId)}",
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
FontSize = 9.75,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · step {_appState.AgentRun.LastIteration}",
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
Foreground = GetRunStatusBrush(_appState.AgentRun.Status),
|
||||
FontSize = 9,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = Brushes.DimGray,
|
||||
FontSize = 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
panel.Children.Add(currentRun);
|
||||
}
|
||||
|
||||
var recentAgentRuns = _appState.GetRecentAgentRuns(1);
|
||||
if (recentAgentRuns.Count > 0)
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "마지막 실행",
|
||||
FontSize = 9,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = Brushes.DimGray,
|
||||
Margin = new Thickness(8, 0, 8, 2),
|
||||
});
|
||||
|
||||
foreach (var run in recentAgentRuns)
|
||||
{
|
||||
var runEvents = GetExecutionEventsForRun(run.RunId, 1);
|
||||
var runFilePaths = GetExecutionEventFilePaths(run.RunId, 1);
|
||||
var runDisplay = _appState.GetRunDisplay(run);
|
||||
var runCardStack = new StackPanel
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = runDisplay.HeaderText,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = GetRunStatusBrush(run.Status),
|
||||
FontSize = 9.5,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = runDisplay.MetaText,
|
||||
Margin = new Thickness(0, 1, 0, 0),
|
||||
Foreground = secondaryText,
|
||||
FontSize = 8.25,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = TruncateForStatus(runDisplay.SummaryText, 92),
|
||||
Margin = new Thickness(0, 1.5, 0, 0),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = secondaryText,
|
||||
FontSize = 8.5,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (runEvents.Count > 0 || runFilePaths.Count > 0)
|
||||
{
|
||||
var activitySummary = new StackPanel();
|
||||
activitySummary.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"로그 {runEvents.Count} · 파일 {runFilePaths.Count}",
|
||||
FontSize = 8,
|
||||
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, 5, 0, 0);
|
||||
activitySummary.Children.Add(timelineButton);
|
||||
}
|
||||
|
||||
runCardStack.Children.Add(new Border
|
||||
{
|
||||
Background = BrushFromHex("#F8FAFC"),
|
||||
BorderBrush = BrushFromHex("#E2E8F0"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(7),
|
||||
Padding = new Thickness(6, 4, 6, 4),
|
||||
Margin = new Thickness(0, 5, 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, 6, 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, 6, 0, 0);
|
||||
runCardStack.Children.Add(retryButton);
|
||||
}
|
||||
|
||||
panel.Children.Add(new Border
|
||||
{
|
||||
Background = popupBackground,
|
||||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(7),
|
||||
Padding = new Thickness(7, 5, 7, 5),
|
||||
Margin = new Thickness(6, 0, 6, 4),
|
||||
Child = runCardStack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var activeTasks = FilterTaskSummaryItems(_appState.ActiveTasks).Take(3).ToList();
|
||||
var recentTasks = FilterTaskSummaryItems(_appState.RecentTasks).Take(2).ToList();
|
||||
|
||||
foreach (var task in activeTasks)
|
||||
panel.Children.Add(BuildTaskSummaryCard(task, active: true));
|
||||
|
||||
if (ShouldIncludeRecentTaskSummary(activeTasks))
|
||||
{
|
||||
foreach (var task in recentTasks)
|
||||
panel.Children.Add(BuildTaskSummaryCard(task, active: false));
|
||||
}
|
||||
|
||||
if (activeTasks.Count == 0 && recentTasks.Count == 0)
|
||||
{
|
||||
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 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 categoryLabel = GetTranscriptTaskCategory(task);
|
||||
var displayTitle = string.Equals(task.Kind, "tool", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(task.Kind, "hook", StringComparison.OrdinalIgnoreCase)
|
||||
? GetAgentItemDisplayName(task.Title)
|
||||
: task.Title;
|
||||
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 = 9.5,
|
||||
Foreground = kindColor,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
headerRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = active
|
||||
? $"진행 중 · {displayTitle}"
|
||||
: $"{GetTaskStatusLabel(task.Status)} · {displayTitle}",
|
||||
FontSize = 9.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = active ? primaryText : secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
taskStack.Children.Add(headerRow);
|
||||
|
||||
taskStack.Children.Add(new Border
|
||||
{
|
||||
Background = BrushFromHex("#F8FAFC"),
|
||||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(6, 1, 6, 1),
|
||||
Margin = new Thickness(0, 0, 0, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = categoryLabel,
|
||||
FontSize = 8,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = secondaryText,
|
||||
},
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Summary))
|
||||
{
|
||||
taskStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = TruncateForStatus(task.Summary, 96),
|
||||
FontSize = 8.75,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
}
|
||||
|
||||
var reviewChipRow = BuildReviewSignalChipRow(
|
||||
kind: task.Kind,
|
||||
toolName: task.Title,
|
||||
title: displayTitle,
|
||||
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") : (TryFindResource("LauncherBackground") as Brush ?? Brushes.White),
|
||||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(7),
|
||||
Padding = new Thickness(8, 5, 8, 5),
|
||||
Margin = new Thickness(8, 0, 8, 4),
|
||||
Child = taskStack
|
||||
};
|
||||
}
|
||||
}
|
||||
259
src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs
Normal file
259
src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
/// <summary>마우스 오버 시 살짝 확대하는 호버 애니메이션. 독립적 공간이 있는 버튼에만 적용합니다.</summary>
|
||||
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
|
||||
{
|
||||
void EnsureTransform()
|
||||
{
|
||||
element.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.</summary>
|
||||
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
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>심플한 V 체크 아이콘을 생성합니다.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
|
||||
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,
|
||||
Foreground = foreground,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var btn = new Button
|
||||
{
|
||||
Content = icon,
|
||||
Background = Brushes.Transparent,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
Cursor = Cursors.Hand,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Padding = new Thickness(0),
|
||||
Margin = new Thickness(0, 0, 2, 0),
|
||||
ToolTip = tooltip
|
||||
};
|
||||
|
||||
btn.Template = BuildMinimalIconButtonTemplate();
|
||||
btn.MouseEnter += (_, _) =>
|
||||
{
|
||||
icon.Foreground = hoverBrush;
|
||||
btn.Background = hoverBg;
|
||||
};
|
||||
btn.MouseLeave += (_, _) =>
|
||||
{
|
||||
icon.Foreground = foreground;
|
||||
btn.Background = Brushes.Transparent;
|
||||
};
|
||||
btn.Click += (_, _) => onClick();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1360,22 +1360,6 @@
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<!-- ── 데이터 활용 수준 팝업 ── -->
|
||||
<Popup x:Name="DataUsagePopup" Grid.Row="4"
|
||||
PlacementTarget="{Binding ElementName=BtnDataUsage}"
|
||||
Placement="Top" StaysOpen="False"
|
||||
AllowsTransparency="True" PopupAnimation="Fade">
|
||||
<Border Background="{DynamicResource LauncherBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1" CornerRadius="18"
|
||||
Padding="10" MinWidth="300">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="22" ShadowDepth="0" Opacity="0.15"/>
|
||||
</Border.Effect>
|
||||
<StackPanel x:Name="DataUsageItems" Margin="2"/>
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<!-- ── 슬래시 명령어 팝업 ── -->
|
||||
<Popup x:Name="SlashPopup" Grid.Row="4"
|
||||
PlacementTarget="{Binding ElementName=InputBorder}"
|
||||
@@ -2198,28 +2182,7 @@
|
||||
<Border x:Name="FormatMoodSeparator" Grid.Column="4" Width="1" Height="18" Margin="4,0"
|
||||
Background="{DynamicResource SeparatorColor}" Visibility="Collapsed"/>
|
||||
|
||||
<!-- 데이터 활용 메뉴 -->
|
||||
<Border x:Name="BtnDataUsage" Grid.Column="5" Cursor="Hand"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="10,5"
|
||||
CornerRadius="8"
|
||||
MouseLeftButtonUp="BtnDataUsage_Click"
|
||||
ToolTip="폴더 데이터 활용 수준">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="DataUsageIcon" Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock x:Name="DataUsageLabel" Text="활용하지 않음" FontSize="12"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 구분선 -->
|
||||
<Border Grid.Column="6" Width="1" Height="18" Margin="4,0"
|
||||
Background="{DynamicResource SeparatorColor}"/>
|
||||
<!-- 폴더 데이터 활용은 코드/코워크 자동 정책으로만 동작 -->
|
||||
|
||||
<!-- 권한 메뉴 -->
|
||||
<Button x:Name="BtnPermission" Grid.Column="7" Style="{StaticResource OutlineHoverBtn}"
|
||||
@@ -2653,6 +2616,30 @@
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
</Border>
|
||||
<Border x:Name="OverlayThemeStyleNordCard"
|
||||
Cursor="Hand"
|
||||
CornerRadius="8"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
Padding="10,7"
|
||||
Margin="0,0,8,8"
|
||||
MouseLeftButtonUp="OverlayThemeStyleNordCard_MouseLeftButtonUp">
|
||||
<TextBlock Text="Nord"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
</Border>
|
||||
<Border x:Name="OverlayThemeStyleEmberCard"
|
||||
Cursor="Hand"
|
||||
CornerRadius="8"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
Padding="10,7"
|
||||
Margin="0,0,8,8"
|
||||
MouseLeftButtonUp="OverlayThemeStyleEmberCard_MouseLeftButtonUp">
|
||||
<TextBlock Text="Ember"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
</Border>
|
||||
<Border x:Name="OverlayThemeStyleSlateCard"
|
||||
Cursor="Hand"
|
||||
CornerRadius="8"
|
||||
@@ -3469,43 +3456,6 @@
|
||||
<ComboBoxItem Content="모드 · 사외 모드" Tag="external"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
<Grid x:Name="OverlayFolderDataUsageRow" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="폴더 데이터 활용"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Border Style="{StaticResource OverlayHelpBadge}">
|
||||
<TextBlock Text="?"
|
||||
FontSize="10"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
<Border.ToolTip>
|
||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Foreground="White"
|
||||
FontSize="12"
|
||||
LineHeight="18"
|
||||
MaxWidth="280">현재 폴더의 파일과 문맥을 응답에 얼마나 적극적으로 활용할지 정합니다. 적극 활용은 관련 파일을 더 넓게 참조하고, 활용하지 않음은 현재 입력 중심으로만 답합니다.</TextBlock>
|
||||
</ToolTip>
|
||||
</Border.ToolTip>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="CmbOverlayFolderDataUsage"
|
||||
Grid.Column="1"
|
||||
MinWidth="140"
|
||||
Style="{StaticResource OverlayComboBox}"
|
||||
SelectionChanged="CmbOverlayFolderDataUsage_SelectionChanged">
|
||||
<ComboBoxItem Content="데이터 · 활용하지 않음" Tag="none"/>
|
||||
<ComboBoxItem Content="데이터 · 소극 활용" Tag="passive"/>
|
||||
<ComboBoxItem Content="데이터 · 적극 활용" Tag="active"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="OverlayTlsRow" Visibility="Collapsed" Margin="0,0,0,0">
|
||||
<CheckBox x:Name="OverlayHiddenTlsToggle" Visibility="Collapsed"/>
|
||||
@@ -4134,7 +4084,9 @@
|
||||
<CheckBox x:Name="ChkOverlayWorkflowVisualizer"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"/>
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayWorkflowVisualizer_Changed"
|
||||
Unchecked="ChkOverlayWorkflowVisualizer_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleShowTotalCallStats" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
|
||||
@@ -4154,7 +4106,9 @@
|
||||
<CheckBox x:Name="ChkOverlayShowTotalCallStats"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"/>
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayShowTotalCallStats_Changed"
|
||||
Unchecked="ChkOverlayShowTotalCallStats_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleAuditLog" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
|
||||
@@ -4174,7 +4128,9 @@
|
||||
<CheckBox x:Name="ChkOverlayEnableAuditLog"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"/>
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayEnableAuditLog_Changed"
|
||||
Unchecked="ChkOverlayEnableAuditLog_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid x:Name="OverlayOpenAuditLogRow" Margin="0,8,0,0">
|
||||
@@ -4847,11 +4803,11 @@
|
||||
CornerRadius="12"
|
||||
Padding="14,12"
|
||||
Margin="0,8,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="120"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="180"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="도구 훅 스크립트 제한 시간"
|
||||
FontSize="12"
|
||||
@@ -4866,7 +4822,7 @@
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="44"/>
|
||||
<ColumnDefinition Width="56"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Slider x:Name="SldOverlayToolHookTimeoutMs"
|
||||
Minimum="3000"
|
||||
@@ -4880,7 +4836,7 @@
|
||||
<Border Grid.Column="1"
|
||||
Margin="10,0,0,0"
|
||||
Padding="8,4"
|
||||
MinWidth="44"
|
||||
MinWidth="56"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource LauncherBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
@@ -4922,7 +4878,8 @@
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,6"
|
||||
Margin="12,0,0,0"
|
||||
Margin="16,0,0,0"
|
||||
MinWidth="92"
|
||||
MouseLeftButtonUp="OverlayAddHookBtn_MouseLeftButtonUp">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text=""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -306,6 +306,25 @@ internal sealed class PlanViewerWindow : Window
|
||||
_statusBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public void LoadPlanPreview(string planText, List<string> steps)
|
||||
{
|
||||
_planText = planText;
|
||||
_steps = steps;
|
||||
_tcs = null;
|
||||
_currentStep = -1;
|
||||
_isExecuting = false;
|
||||
_expandedSteps.Clear();
|
||||
if (_uiExpressionLevel == "rich")
|
||||
{
|
||||
for (int i = 0; i < _steps.Count; i++)
|
||||
_expandedSteps.Add(i);
|
||||
}
|
||||
|
||||
RenderSteps();
|
||||
BuildCloseButton();
|
||||
_statusBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
|
||||
{
|
||||
LoadPlan(planText, steps, tcs);
|
||||
|
||||
@@ -4957,22 +4957,6 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="폴더 데이터 활용"/>
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<StackPanel HorizontalAlignment="Left">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="기본 데이터 활용 수준"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="작업 폴더 내 문서/데이터를 보고서 작성에 활용하는 수준입니다."/>
|
||||
</StackPanel>
|
||||
<ComboBox HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Width="180" SelectedValue="{Binding FolderDataUsage, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag">
|
||||
<ComboBoxItem Content="활용하지 않음" Tag="none"/>
|
||||
<ComboBoxItem Content="소극 활용 (요청 시)" Tag="passive"/>
|
||||
<ComboBoxItem Content="적극 활용 (자동 탐색)" Tag="active"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── 품질 검증 ── -->
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="품질 검증"/>
|
||||
|
||||
Reference in New Issue
Block a user