diff --git a/README.md b/README.md index c0735fe..f6bf0a4 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ public class MyHandler : IActionHandler ### v0.7.3 — AX Agent 권한 코어 재구성 + 입력 계층 정리 -업데이트: 2026-04-04 10:05 (KST) +업데이트: 2026-04-04 12:11 (KST) | 분류 | 내용 | |------|------| @@ -230,6 +230,40 @@ public class MyHandler : IActionHandler | 규칙 해석 순서 정리 | 권한 판정을 `deny 규칙 → allow/override 규칙 → 글로벌 모드 → 기본 Ask` 순서로 재구성 | | 도구 권한 클래스 분리 | `file_write/file_edit/file_manage`와 `process/build_run/test_loop/snippet_runner/spawn_agent` 계열을 분리해 `AcceptEdits`와 `Plan`의 실제 동작 차이를 반영 | | AX Agent 권한 UI 반영 | 권한 팝업, 상단 배너, slash 명령 결과가 새 권한 모드 의미를 표시하도록 정리 | +| AX Agent 설정창 복구 | `AgentSettingsWindow`의 깨진 한글 문자열을 정리하고 운영 모드, 계획 모드, 추론 강도, 폴더 데이터 활용 라벨을 현재 AX 표현 체계로 복구 | +| 권한 용어 통일 | 권한 팝업과 인라인 설정에서 `계획 중심`, `완전 자동`, `질문 없이 진행` 등 한국어 표기를 일관되게 사용하도록 정리 | +| 권한 UI·로직 결합 정리 | `claw-code` 권한 팝업 흐름에 맞춰 코어 4개(`권한 요청/편집 자동 승인/계획 모드/권한 건너뛰기`)를 기본 선택 순서로 정렬하고, 토글 순환도 동일 축으로 단순화 | +| 슬래시 팔레트 단순화 | `/` 팝업의 기본 선택을 첫 항목으로 바꾸고, 즐겨찾기 버튼/배지 UI를 제거해 `아이콘+명령+설명` 중심의 단순 리스트 탐색으로 정리 | +| 컴포저 패널 축소 | 하단 인라인 설정을 `Fast/추론/계획/권한` 중심으로 축소하고, 스킬/브라우저/MCP 버튼은 숨겨 입력 중심 UX로 정리 | +| 모델/프리셋 바 컴팩트화 | 입력창 상단 바를 더 촘촘한 크기로 정리하고, 긴 모델명은 자동 말줄임 처리해 레이아웃이 흔들리지 않도록 보강 | +| 좌측 패널 타이포 정돈 | 사이드바 헤더/메뉴/대화 리스트의 폰트 크기와 여백을 줄여 밀도를 맞추고, 대화 카드 제목·시간·상태 배지의 크기를 통일해 시인성을 개선 | +| 메시지 버블 정돈 | 사용자/어시스턴트 버블의 여백·폰트·타임스탬프·액션 버튼 크기를 줄이고 좌우 마진을 대칭화해 대화 로그 가독성과 정렬감을 개선 | +| 에이전트 이벤트 표시방식 정리 | 실행 배너의 과도한 펼침 UI를 제거하고 요약 길이를 제한해 한눈에 상태를 읽도록 단순화, 권한/계획/실행 라벨을 한국어 기준으로 통일 | +| 도구 결과 카드 단순화 | 파일 경로 배너를 `파일명 + 디렉터리` 2단 구조로 정리하고, 빠른 작업 버튼을 아이콘 전용(프리뷰/열기/폴더/복사)으로 축소해 시각적 복잡도를 낮춤 | +| 작업 요약 팝업 정돈 | 팝업 헤더/필터 밀도를 낮추고 `전체·권한·대기·도구·서브·훅` 중심으로 재정렬해 스크롤 시 탐색 피로를 줄임 | +| 권한 이력 카드 재구성 | 권한 카드/이력을 `현재 모드·설명·기본/예외` + `시간·도구·결과` 구조로 단순화하고, 권한 액션 버튼도 최신 용어(`활용하지 않음/권한 요청/편집 자동 승인/계획 모드/권한 건너뛰기`)로 통일 | +| 최근 실행 카드 압축 | `최근 에이전트 실행` 카드에서 상세 로그/파일 나열과 분기 액션을 줄이고 `요약+카운트+타임라인` 중심으로 정리해 표시 밀도와 스캔 속도를 개선 | +| 작업 카드 버튼 스타일 통일 | 작업 요약/권한/훅/백그라운드 카드 버튼을 공통 생성 함수로 통일해 패딩·폰트·테두리·색상 톤을 일관화하고 카드 간 버튼 밀도 차이를 제거 | +| 카드 타이포 계층 정렬 | 작업/훅/백그라운드 카드의 제목·본문 글자 크기와 굵기를 통일하고 `PrimaryText/SecondaryText` 기반으로 대비를 맞춰 카드 간 시각 리듬을 정리 | +| 카드 아이콘 규칙 통일 | 작업 종류별 아이콘/색을 공통 매핑으로 통일하고, 작업·훅·백그라운드 카드 헤더에 아이콘을 배치해 상태 인지가 한눈에 되도록 정리 | +| 권한 팝업 선택 강조 개선 | 권한 모드 리스트에서 활성 항목에 배경/테두리/체크 아이콘을 적용하고 설명 줄간격·아이콘 정렬을 조정해 선택 상태와 읽기 흐름을 명확화 | +| 권한 예외/거부 영역 압축 | 권한 팝업의 `도구별 예외`와 `최근 권한 거부` 블록을 아이콘 헤더+간결 라벨 체계로 정리하고 버튼 명칭을 권한 용어(`권한 요청/편집 자동 승인/활용하지 않음/예외 해제`)로 통일 | +| 권한 팝업 섹션 접힘/펼침 | 기본 화면은 `핵심 권한 모드`만 노출하고 `현재 권한 요약/도구별 예외/최근 권한 거부/고급 모드`는 접힘 섹션으로 전환해 codex/claude식 간결 흐름으로 정리 | +| 권한 팝업 섹션 상태 기억 | 접힘 섹션의 마지막 펼침 상태를 `settings.dat`에 저장해 팝업 재오픈 시 사용자 마지막 선택을 복원 | +| 슬래시 팔레트 그룹 상태 기억 | `/` 팔레트에 `명령/스킬` 접힘 헤더를 추가하고 마지막 펼침 상태를 저장해 재오픈 시 복원, Up/Down 이동도 펼쳐진 그룹 항목만 순회하도록 보정 | +| 슬래시 최근 사용 상단 고정 | `/` 팔레트의 `명령/스킬` 그룹 내부 항목을 최근 사용(MRU) 기준으로 상단 정렬하고 `최근` 배지를 표시, 선택 스크롤도 실제 렌더 항목 기준으로 정확히 보정 | +| 슬래시 핀 고정 결합 | `/` 팔레트 항목 우측에 핀 토글을 추가하고 정렬 우선순위를 `핀 > 최근 > 이름`으로 적용해 자주 쓰는 명령을 고정 유지 | +| AX Agent 기본 활성화 | `ai_enabled` 기본값을 활성화로 변경하고 설정 로드 정규화 단계에서 비활성 값이 들어와도 자동으로 활성화되도록 보정 | +| 업데이트 안내 메시지 제거 | 앱 시작 시 설정 마이그레이션 후 표시되던 `설정 업데이트` 메시지박스를 제거해 업데이트 설치 직후 팝업 노출을 중단 | +| 슬래시 핀/최근 개수 설정 연동 | AX Agent 설정창에서 `슬래시 핀 최대 개수`, `슬래시 최근 최대 개수`를 조절할 수 있도록 추가하고 런타임(MRU/핀 정렬) 제한에 즉시 반영 | +| 슬래시 개수 설정 QA 완료 | 핀/최근 상한 적용 후 `/` 팔레트 정렬과 실행 경로를 회귀 점검하고 `ChatWindowSlashPolicyTests` 39개를 통과하여 동작 안정성을 확인 | +| 슬래시 퀵관리/권한 키보드 보강 | `/` 팔레트 헤더에 `정리/전체 접기·펼치기`를 추가하고, 권한 팝업에 `Tab/Enter/Esc` 중심 키보드 조작(항목 선택·섹션 토글·닫기)을 보강 | +| 슬래시/권한 접근성 완성도 보강 | `/` 팔레트에 `모두 접힘` 안내와 섹션 상태 라벨을 추가하고, 권한 팝업 오픈 시 첫 포커스 이동/Enter·Space 선택/ESC 닫기를 보강해 키보드 사용성을 개선 | +| 설정 즉시 반영 가시성 보강 | AX Agent 설정의 슬래시 핀/최근 상한 항목에 `저장 후 즉시 반영` 안내를 추가해 조작 결과를 명확히 인지하도록 정리 | +| 회귀 패키지 통과 | 전체 테스트 436개 통과로 슬래시/권한/설정 저장 경로 변경 후 회귀 안정성 확보 | +| 슬래시 탐색 입력 확장 | `/` 팝업에서 휠/방향키 외에 `PageUp/PageDown/Home/End` 이동을 추가하고 고해상도 휠 델타를 단계 이동으로 보정해 스크롤 사용성을 개선 | +| 모델 빠른설정 단일 라인 강화 | 입력창 상단 모델 버튼을 AX Agent 내부 빠른 설정 토글로 전환하고, 모델/프리셋 버튼 높이와 패딩을 정돈해 Codex/Claude형 단일 라인 흐름에 맞춤 | +| UI 점검 체크리스트 추가 | 내부/사외 모드 포함 UI 회귀 점검 문서를 `docs/UI_UX_CHECKLIST.md`로 추가해 시나리오 기반 검증 기준을 명문화 | | Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 | | 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 | | 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed | @@ -241,3 +275,9 @@ public class MyHandler : IActionHandler ## 라이선스 MIT License + + + + + + diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4cdad41..2cc01b2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1992,7 +1992,7 @@ allowed-tools: process file_read grep_tool ### v0.7.3 > **개발 범위**: AX Agent 권한 코어 재구성 + slash 입력 계층 분리 + 설정/상태 표현 정리 -업데이트: 2026-04-04 10:05 (KST) +업데이트: 2026-04-04 10:49 (KST) | 구분 | 내용 | |------|------| @@ -2000,6 +2000,16 @@ allowed-tools: process file_read grep_tool | **권한 판정 체인 재구성** | 권한 해석 순서를 `deny 패턴 규칙 → allow/override 규칙 → 글로벌 모드 → 기본 Ask` 순서로 재정리하여 `claude-code` 수준의 정책 해석 흐름에 맞춤 | | **도구 권한 클래스 분리** | `file_write/file_edit/file_manage`와 `process/build_run/test_loop/snippet_runner/spawn_agent`를 별도 권한 클래스로 분리해 `AcceptEdits`는 파일 편집 자동 허용, `Plan`은 쓰기 차단으로 동작하도록 보강 | | **AX Agent 권한 UI 반영** | 권한 팝업, 상단 배너, slash 명령 응답, 상태 요약에 새 권한 명칭과 설명을 반영하고, 도구별 override 요약이 현재 모드와 함께 보이도록 정리 | +| **AX Agent 설정창 문자열 복구** | `AgentSettingsWindow`를 전면 정리해 깨진 한글 문자열을 복구하고, 운영 모드/계획 모드/추론 강도/폴더 데이터 활용을 현재 AX 용어로 표시하도록 수정 | +| **권한 용어 및 상태 표현 통일** | `Plan`, `Bypass`, `DontAsk`처럼 영문으로 남아 있던 권한 표현을 `계획 중심`, `완전 자동`, `질문 없이 진행`으로 정리하고 인라인 설정 패널 문구도 함께 통일 | +| **권한 UI·실행 로직 결합 정리** | 권한 팝업을 `권한 요청/편집 자동 승인/계획 모드/권한 건너뛰기` 중심 리스트로 재구성하고, `BtnPermissionMode` 및 인라인 `NextPermission` 순환 로직도 같은 4단계 축으로 단순화 | +| **슬래시 팔레트 UI 정리** | `/` 입력 시 첫 항목을 기본 선택하도록 조정하고, 항목 우측 즐겨찾기 버튼/유형 배지를 제거해 `아이콘+명령+설명` 중심의 단순 리스트 탐색 구조로 정리 | +| **하단 컴포저 패널 단순화** | 인라인 설정 빠른 버튼을 `Fast/추론/계획/권한` 중심으로 줄이고 부가 버튼(스킬/명령 브라우저/MCP)을 숨겨 입력 흐름을 방해하지 않도록 정리 | +| **모델/프리셋 상단 바 컴팩트화** | 입력창 상단 모델/프리셋 버튼의 높이와 패딩을 줄이고, 모델명이 길 때 자동 말줄임(`…`) 처리해 입력창 폭이 좁아도 상단 바가 깨지지 않도록 보강 | +| **좌측 사이드바 밀도 정리** | 사이드바 헤더/검색/메뉴/대화 리스트의 간격과 글자 크기를 재조정하고, 대화 카드의 제목/시간/상태 배지 크기를 낮춰 `claw-code`형 컴팩트 밀도에 맞춤 | +| **메시지 버블 레이아웃 정리** | 사용자/어시스턴트 버블의 패딩·폰트·타임스탬프 크기와 좌우 마진을 조정해 대화 영역을 더 촘촘하게 정리하고, 액션 버튼 크기도 줄여 시각적 잡음을 완화 | +| **에이전트 이벤트 배너 표시방식 정리** | 실행 이벤트 배너의 카드 밀도(패딩/마진/폰트)를 줄이고, ToolCall/ToolResult 긴 요약은 자동 절단해 펼침 토글 없이 읽히도록 정리. 권한/계획/결정 배지 라벨도 한국어로 통일 | +| **도구 결과 카드 시각 단순화** | 파일 경로 배너를 파일명 중심 2단 표기(파일명/디렉터리)로 바꾸고, 빠른 작업 버튼을 텍스트 라벨 대신 아이콘 전용으로 축소해 이벤트 카드 본문 가독성을 개선 | | **Slash palette 상태 분리 시작** | `ChatWindow` 내부에 퍼져 있던 slash 선택 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편의 기반을 마련 | | **설정/상태 연동** | `AppSettings`, `AppStateService`, `AgentSettingsWindow`, `ChatWindow`가 동일 권한 체계를 참조하도록 정리하고 대화별 권한 상태 저장 흐름을 유지 | | **런처 이미지 미리보기 창 (Phase L2-3)** | `ClipboardImagePreviewWindow` 신규 추가. 원본 해상도 이미지 표시, `Ctrl+휠`/`+`/`-`/`0`/`F`/`Esc` 단축키, PNG·JPEG·BMP 저장, 클립보드 복사, `#` 이미지 항목 `Shift+Enter` 연결, 단축키 도움말 갱신 | @@ -2811,6 +2821,361 @@ else: - `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (421 passed, 0 failed). +## 2026-04-04 추가 진행 기록 (연속 실행 5차: 작업 요약 팝업 표시 정렬) + +업데이트: 2026-04-04 10:55 (KST) + +### 1) 작업 요약 팝업 밀도/표시 방식 정리 +- 팝업 타이틀을 `작업 요약`으로 정리하고 보조 설명(`현재 실행/권한/작업 흐름`)을 추가. +- 필터 칩을 `전체·권한·대기·도구·서브·훅` 중심으로 재배치하고, 칩 패딩/폰트/마진을 축소해 스크롤 피로를 완화. +- 팝업 루트 배경을 테마 리소스(`LauncherBackground`) 기반으로 정리하고, 본문 텍스트는 `PrimaryText/SecondaryText`를 우선 사용하도록 정돈. + +### 2) 권한 카드/이력 구조 단순화 +- 권한 요약 카드를 `현재 권한·설명·기본 모드·예외 개수` 구조로 단순화. +- 모드별 시각 팔레트 함수를 도입해 `활용하지 않음/권한 요청/편집 자동 승인/계획 모드/권한 건너뛰기` 상태를 일관 색상으로 표시. +- 최근 권한 이력을 기존 단일 문장 포맷에서 `시간·도구·결과·요약` 포맷으로 재구성. + +### 3) 권한 액션 라벨 최신 체계 반영 +- Task Summary의 권한 액션 버튼을 아래 순서로 통일: + - `활용하지 않음` + - `권한 요청` + - `편집 자동 승인` + - `계획 모드` + - `권한 건너뛰기` + - `해제` + +### 4) 불필요 실패 중심 액션 축소 +- 작업 요약 팝업 하단 액션에서 `실패 대화만 보기` 버튼을 제거하고, 재시도/진행중 필터 중심으로 단순화. + +### 5) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 6차: 최근 실행 카드 압축) + +업데이트: 2026-04-04 11:00 (KST) + +### 1) 최근 에이전트 실행 카드 표시 간소화 +- 최근 실행 표시 개수를 4개에서 3개로 축소. +- 카드 본문 요약을 길이 제한(말줄임) 기반으로 정리해 긴 설명이 레이아웃을 깨지 않도록 조정. +- `관련 실행 로그/관련 파일` 상세 목록 나열을 제거하고 `실행 로그 n · 관련 파일 n` 한 줄 요약으로 압축. + +### 2) 실행 카드 액션 정리 +- 실행 상세 이동은 `타임라인 보기` 단일 액션으로 통일. +- 완료 상태 카드에서 `새 분기에서 이어가기`를 제거하고 `후속 작업 큐에 넣기` 중심으로 단순화. + +### 3) 시각 밀도 정리 +- 카드 패딩/간격을 축소해 동일 영역에서 더 빠르게 스캔 가능하도록 조정. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 7차: 작업 카드 버튼 스타일 통일) + +업데이트: 2026-04-04 11:03 (KST) + +### 1) 공통 버튼 빌더 도입 +- `ChatWindow.xaml.cs`에 `CreateTaskSummaryActionButton(...)` 추가. +- 작업 요약 팝업 내 액션 버튼 공통 속성을 일원화: + - `FontSize 10.5`, `MinHeight 28`, `Padding 9x4`, `BorderThickness 1` + - 일관된 마진 규칙(기본 trailing 6px) + +### 2) 적용 범위 +- `BuildTaskSummaryActionRow` (queue/permission) +- `BuildHookSummaryCard` +- `BuildActiveBackgroundSummaryCard` +- `BuildRecentBackgroundJobCard` +- `ShowTaskSummaryPopup`의 최근 실행/재시도/진행중 필터 버튼 + +### 3) 효과 +- 카드 영역마다 버튼 크기/두께/간격이 달라 보이던 문제를 해소. +- 색상 체계는 유지하면서 시각적 밀도와 클릭 영역 일관성을 확보. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 8차: 카드 타이포 계층 통일) + +업데이트: 2026-04-04 11:06 (KST) + +### 1) 타이포 계층 정렬 +- `BuildTaskSummaryCard`의 핵심 라벨을 `11px + SemiBold`로 정리해 카드 헤더 위계를 고정. +- `BuildHookSummaryCard`, `BuildActiveBackgroundSummaryCard`, `BuildRecentBackgroundJobCard` 본문을 `10.5px` 기준으로 통일. + +### 2) 테마 대비 일관화 +- 카드 본문/보조문구의 기본 색상을 `SecondaryText` 리소스 우선 사용으로 정리. +- 활성/강조 상태만 별도 강조색을 유지하여 정보 계층이 더 명확해지도록 조정. + +### 3) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 9차: 카드 아이콘/표시 규칙 통일) + +업데이트: 2026-04-04 11:08 (KST) + +### 1) 작업 카드 아이콘 헤더 도입 +- `BuildTaskSummaryCard`에 작업 종류별 아이콘을 헤더에 추가하고, 제목/요약을 2줄 구조로 분리. +- 작업 종류별 공통 매핑 함수 `GetTaskKindVisual(kind)` 추가: + - permission, queue, tool, hook, subagent, default + +### 2) 훅/백그라운드 카드 아이콘 정렬 +- `BuildHookSummaryCard`에 `훅 이벤트` 아이콘 헤더 추가. +- `BuildActiveBackgroundSummaryCard`에 실행중 배경작업 아이콘 헤더 추가. +- `BuildRecentBackgroundJobCard`에 작업 상태 아이콘 헤더 추가. + +### 3) 효과 +- 카드 타입을 텍스트 읽기 전에 아이콘/색으로 먼저 인지 가능. +- 동일 카드군에서 헤더 표시 방식이 통일되어 시각 흐름이 안정화됨. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 10차: 권한 팝업 표시/강조 정렬) + +업데이트: 2026-04-04 11:11 (KST) + +### 1) 권한 모드 리스트 선택 강조 +- 활성 항목에 `배경(#EEF2FF) + 테두리(#C7D2FE) + 체크 아이콘`을 적용해 선택 상태 인지를 강화. +- hover 시 비활성 항목은 연한 배경/테두리, 활성 항목은 강조색을 유지하도록 분리. + +### 2) 설명 가독성 정리 +- 권한 요약 설명/모드 설명 텍스트에 줄간격(`LineHeight`)을 적용. +- 아이콘/텍스트 정렬과 패딩을 조정해 각 행의 시각 중심을 맞춤. + +### 3) 라벨/색상 보정 +- 권한 요약에 기본 모드를 내부 값 대신 표시 라벨(`PermissionModeCatalog.ToDisplayLabel`)로 출력. +- `권한 건너뛰기` 색상을 경고형 붉은 계열에서 허용형 녹색 계열로 보정. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 11차: 권한 예외/거부 블록 정리) + +업데이트: 2026-04-04 11:12 (KST) + +### 1) 도구별 예외 블록 정리 +- `도구별 override` 명칭을 `도구별 예외`로 변경. +- 각 예외 칩에 권한 아이콘을 추가하고, 모드 값은 내부 키 대신 표시 라벨로 출력. +- 칩 패딩/마진을 줄여 팝업 높이 사용량을 절감. + +### 2) 최근 권한 거부 블록 정리 +- 블록 헤더를 아이콘+제목 구조로 정리하고, 이벤트 상세를 본문 줄로 분리. +- 본문 줄간격(LineHeight) 적용으로 긴 문장 가독성 개선. + +### 3) 빠른 액션 용어 통일 +- `기본으로/적극 허용/차단 유지/override 해제`를 + - `권한 요청/편집 자동 승인/활용하지 않음/예외 해제` + 로 통일. +- 버튼 크기/마진을 축소해 같은 영역에 더 많은 액션을 안정적으로 배치. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 12차: 권한 팝업 섹션 접힘/펼침) + +업데이트: 2026-04-04 11:17 (KST) + +### 1) 기본 노출 구조 변경 +- 권한 팝업에서 기본 노출 영역을 `핵심 권한 모드`로 고정. +- `현재 권한 요약`, `도구별 예외`, `최근 권한 거부`, `고급 모드`는 접힘 섹션으로 재구성. + +### 2) 접힘 UI 구현 +- 섹션 헤더(아이콘/타이틀/캐럿) + 본문 토글 구조를 공통 빌더(`CreateCollapsibleSection`)로 구현. +- hover/클릭 반응과 펼침 상태 캐럿(▶/▼)을 동기화. + +### 3) 기대 효과 +- 초기 팝업 높이가 줄어 입력/채팅 영역을 가리는 정도 완화. +- 필요한 정보만 단계적으로 열람 가능해 권한 메뉴 탐색 피로 감소. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 13차: 권한 팝업 섹션 상태 저장) + +업데이트: 2026-04-04 11:19 (KST) + +### 1) 설정 모델 확장 +- `LlmSettings`에 `permissionPopupSections` 딕셔너리 추가. +- 권한 팝업 섹션별 마지막 펼침 상태를 설정 파일(`settings.dat`)에 저장 가능하도록 확장. + +### 2) ChatWindow 상태 연동 +- `GetPermissionPopupSectionExpanded(sectionKey)` / `SetPermissionPopupSectionExpanded(sectionKey, expanded)` 헬퍼 추가. +- 접힘 헤더 클릭 시 상태를 즉시 저장하고, 팝업 재오픈 시 저장된 상태를 복원. + +### 3) 적용 섹션 키 +- `permission_summary` +- `permission_overrides` +- `permission_denied` +- `permission_advanced` + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 14차: 슬래시 팔레트 그룹 접힘/상태 저장) + +업데이트: 2026-04-04 11:23 (KST) + +### 1) 설정 모델 확장 +- `LlmSettings`에 `slashPaletteSections` 딕셔너리 추가. +- `/` 팔레트 그룹의 펼침 상태를 `settings.dat`에 저장 가능하게 확장. + +### 2) 슬래시 팔레트 그룹 UI +- `RenderSlashPage()`에 그룹 헤더 추가: + - `명령` + - `스킬` +- 각 헤더에서 접기/펼치기 토글 가능하며, 상태를 즉시 저장. + +### 3) 선택/이동 보정 +- 접힘 상태를 고려해 기본 선택 인덱스를 계산(`GetFirstVisibleSlashIndex`). +- Up/Down/휠 이동(`SlashPopup_ScrollByDelta`)이 펼쳐진 그룹 항목만 순회하도록 보정. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 15차: 슬래시 최근 사용 상단 고정) + +업데이트: 2026-04-04 11:32 (KST) + +### 1) 설정 모델 확장 +- `LlmSettings`에 `recentSlashCommands`(MRU) 추가. +- 최근 실행한 슬래시 명령어를 최신 순으로 저장(최대 20개). + +### 2) 실행 시점 기록 +- `ExecuteSlashSelectedItem()`에서 실제 실행된 명령을 MRU에 기록. + +### 3) 그룹 내부 정렬/표시 +- `RenderSlashPage()`에서 `명령`/`스킬` 그룹 각각을 최근 사용 순으로 정렬. +- 최근 사용 항목에 `최근` 배지를 표시해 시각적으로 구분. + +### 4) 선택 스크롤 정확도 보강 +- 섹션 헤더/접힘으로 인해 발생하던 인덱스 불일치를 제거: + - 렌더된 실제 아이템 맵(`_slashVisibleItemByAbsoluteIndex`)으로 가시 영역 스크롤 보정. + +### 5) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 16차: 슬래시 핀 고정 결합) + +업데이트: 2026-04-04 11:36 (KST) + +### 1) 수동 핀 고정 UI 추가 +- 슬래시 팔레트 항목 우측에 핀 토글(고정/해제) 추가. +- 핀 상태 항목에 `핀` 배지 표시. + +### 2) 정렬 우선순위 통합 +- 그룹 내부 정렬을 `핀(Favorite) > 최근(MRU) > 명령어 이름` 순으로 통합. +- 명령/스킬 그룹 모두 동일 우선순위 적용. + +### 3) 상호작용 보정 +- 핀 토글 클릭 시 명령 실행 이벤트로 전파되지 않도록 처리(`Handled=true`). +- 팝업 오픈 상태에서는 TextChanged 트리거 없이 즉시 재렌더링하도록 `ToggleSlashFavorite()` 보강. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 17차: AX Agent 기본 활성 + 업데이트 팝업 제거) + +업데이트: 2026-04-04 11:39 (KST) + +### 1) AX Agent 기본 활성화 +- `AppSettings.AiEnabled` 기본값을 `true`로 변경. +- 설정 로드 정규화(`SettingsService.NormalizeRuntimeSettings`)에서 `AiEnabled=false`인 기존 값도 자동으로 `true`로 보정. + +### 2) 업데이트 설치 후 메시지박스 제거 +- `App.xaml.cs` 시작 로직에서 `MigrationSummary`를 `CustomMessageBox`로 표시하던 블록 제거. +- 결과적으로 업데이트/마이그레이션 직후 표시되던 안내 팝업이 더 이상 노출되지 않음. + +### 3) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 18차: 슬래시 핀/최근 최대 개수 설정창 연동) + +업데이트: 2026-04-04 11:41 (KST) + +### 1) 설정 모델 확장 +- `LlmSettings`에 아래 항목 추가: + - `maxFavoriteSlashCommands` (기본 10) + - `maxRecentSlashCommands` (기본 20) + +### 2) 설정창(UI) 연동 +- AX Agent > 스킬 섹션에 슬라이더 2종 추가: + - `슬래시 핀 최대 개수` (1~30) + - `슬래시 최근 최대 개수` (5~50) +- `SettingsViewModel`에 바인딩 속성/Load/Save 매핑 추가. + +### 3) 런타임 제한 반영 +- `ToggleSlashFavorite()`에서 핀 개수 상한을 설정값으로 적용. +- `RegisterRecentSlashCommand()`에서 최근(MRU) 상한을 설정값으로 적용. +- `BuildFavoriteSlashRankMap()`은 최대 핀 개수까지만 정렬 우선순위에 반영. + +### 4) 정규화 보강 +- `SettingsService.NormalizeRuntimeSettings()`에서 핀/최근 최대값 범위 보정 및 기존 리스트 길이 자동 정리. + +### 5) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). + +## 2026-04-04 추가 진행 기록 (연속 실행 19차: 슬래시 개수 설정 회귀 점검) + +업데이트: 2026-04-04 11:46 (KST) + +### 1) 슬래시 설정 연동 검증 +- `maxFavoriteSlashCommands`, `maxRecentSlashCommands` 설정값이 + - 핀 토글 상한 + - 최근(MRU) 누적 상한 + - 그룹 정렬 우선순위 + 에 반영되는 경로를 점검. + +### 2) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj --filter "FullyQualifiedName~ChatWindowSlashPolicyTests"` 통과 (39 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 20차: 슬래시/권한 UX 마무리 + 전체 회귀) + +업데이트: 2026-04-04 12:05 (KST) + +### 1) 슬래시 팔레트 퀵관리 마무리 +- `/` 팝업 헤더에 `정리`, `전체 접기/펼치기` 버튼을 추가. +- 모든 섹션이 접힌 경우 하단 안내 문구를 노출해 빈 화면 오인 방지. +- 섹션 상태 라벨(`전체 접기`/`전체 펼치기`)을 현재 상태에 맞춰 즉시 갱신. + +### 2) 권한 팝업 키보드 접근성 보강 +- 섹션 헤더를 키보드 포커스 가능 상태로 변경하고 `Enter/Space` 토글 지원. +- 권한 모드 행을 키보드 포커스 가능 상태로 변경하고 `Enter/Space` 즉시 적용 지원. +- 팝업 오픈 시 첫 포커스 자동 이동, `Esc` 닫기 지원으로 키보드 완결성 확보. + +### 3) 설정 연동 가시성 개선 +- AX Agent 설정의 슬래시 핀/최근 상한 슬라이더에 `저장 후 즉시 반영` 힌트를 추가해 반영 시점을 명확화. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (436 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 21차: Codex/Claude형 입력 UX 보강 + 체크리스트 정비) + +업데이트: 2026-04-04 12:11 (KST) + +### 1) 슬래시 입력 탐색 보강 +- `/` 팝업 `ScrollViewer`에 직접 휠 이벤트를 연결해 스크롤 누락을 줄임. +- 키보드 탐색을 `Up/Down`에서 `PageUp/PageDown/Home/End`까지 확장. +- 고해상도 휠 델타를 단계 이동으로 환산해 과도한 점프/미동작을 방지. + +### 2) 권한 팝업 포커스 안정화 +- 권한 팝업 오픈 시 최상위 자식만 확인하던 포커스 로직을 재귀 탐색으로 교체. +- 실질적으로 포커스 가능한 첫 항목(행/섹션 헤더)에 안정적으로 진입하도록 보강. + +### 3) 모델 선택 UX 단순화 +- 입력창 상단 모델 버튼 클릭 동작을 AX Agent 내부 `빠른 설정 패널` 토글로 변경. +- 모델/프리셋 버튼 높이/패딩을 줄여 단일 라인 컴포저 밀도를 정돈. + +### 4) UI 회귀 문서화 +- `docs/UI_UX_CHECKLIST.md` 신규 추가. +- 공통 품질, 슬래시, 권한, 설정, 내부/사외 운영 모드, 빌드/테스트까지 시나리오 점검 기준을 문서화. + + + + + + + diff --git a/docs/UI_UX_CHECKLIST.md b/docs/UI_UX_CHECKLIST.md new file mode 100644 index 0000000..34f1403 --- /dev/null +++ b/docs/UI_UX_CHECKLIST.md @@ -0,0 +1,47 @@ +# AX Agent UI/UX 점검 체크리스트 + +업데이트: 2026-04-04 12:22 (KST) + +## 1. 공통 화면 품질 +- [ ] 창 진입 시 레이아웃 깨짐/문자열 깨짐 없이 렌더링된다. +- [ ] 상단 탭(`Chat`, `Cowork`, `Code`) 전환 시 불필요한 새 대화 이력이 자동 생성되지 않는다. +- [ ] 좌측 패널은 탭별 목적에 맞는 최소 메뉴만 표시된다. +- [ ] 입력창 상단의 모델/프리셋 바가 단일 라인으로 정돈되어 표시된다. +- [ ] 메시지 버블(사용자/어시스턴트)의 여백/폰트/액션 배치가 일관된다. + +## 2. 슬래시(`/`) 팔레트 +- [ ] `/` 입력 시 팝업이 즉시 열리고 첫 가시 항목이 선택된다. +- [ ] 마우스 휠 스크롤이 정상 동작한다. +- [ ] 방향키(Up/Down), `PageUp/PageDown`, `Home/End`, `Enter`, `Esc`가 모두 동작한다. +- [ ] `정리`, `전체 접기/펼치기` 버튼이 정상 동작한다. +- [ ] 모든 그룹을 접었을 때 안내 문구가 노출된다. +- [ ] 핀/최근 정렬 우선순위가 `핀 > 최근 > 이름`으로 유지된다. + +## 3. 권한 팝업 +- [ ] 권한 버튼 클릭 시 팝업이 즉시 열리고 키보드 포커스가 진입한다. +- [ ] `Tab`으로 항목 이동, `Enter/Space`로 모드 적용, `Esc`로 닫기가 동작한다. +- [ ] 섹션 접힘 상태(요약/예외/거부/고급)가 재오픈 시 복원된다. +- [ ] 하단 권한 표시와 상단 권한 배너의 표현이 현재 모드와 일치한다. + +## 4. AX Agent 설정 +- [ ] AX Agent 설정 창이 예외 없이 열린다(트레이/앱 내부 모두). +- [ ] 슬래시 핀 최대 개수, 최근 최대 개수 변경 후 즉시 반영된다. +- [ ] 모델 서비스/모델 변경이 입력창 모델 라벨과 일치한다. + +## 5. 운영 모드 시나리오 + +### External 모드 +- [ ] AX Agent 설정에서 `operationMode=external` 전환 후 저장한다. +- [ ] `? 검색어`를 실행하면 웹 검색이 정상 수행된다. +- [ ] 외부 LLM 호출(허용된 서비스)이 정상 수행된다. + +### Internal 모드 +- [ ] AX Agent 설정에서 `operationMode=internal` 전환 후 저장한다. +- [ ] `? 검색어`를 실행하면 외부 검색이 차단되고 안내 메시지가 표시된다. +- [ ] 외부 URL 열기/외부 HTTP 도구가 차단된다. +- [ ] 사내 허용 경로(온프레미스 LLM/MCP)는 정상 동작한다. + +## 6. 회귀 확인 +- [ ] `dotnet build src/AxCopilot/AxCopilot.csproj` 경고 0 / 오류 0 +- [ ] `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 전체 통과 + diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 08bfba0..7061666 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -188,23 +188,24 @@ Background="{DynamicResource ItemBackground}"> - + + - + - + - @@ -219,7 +220,7 @@ + CornerRadius="10" Margin="12,0,12,6" Padding="10,6"> @@ -231,32 +232,17 @@ Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"/> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - @@ -408,16 +516,12 @@ Margin="0,4,0,0"> - @@ -517,14 +675,10 @@ - + - + + + + + + + @@ -899,19 +1093,27 @@ - + + + + + + @@ -961,8 +1170,9 @@ Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/> @@ -1009,7 +1219,7 @@ SelectionChanged="CmbInlineModel_SelectionChanged"/> - + - - diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 9492e96..c74177b 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1086,7 +1086,7 @@ public partial class ChatWindow : Window { BtnTemplateSelector.Padding = level == "simple" ? new Thickness(8, 4, 8, 4) - : new Thickness(10, 6, 10, 6); + : new Thickness(9, 4, 9, 4); } if (_failedOnlyFilter) @@ -1600,8 +1600,101 @@ public partial class ChatWindow : Window ChatConversation? currentConversation; lock (_convLock) currentConversation = _currentConversation; var summary = _appState.GetPermissionSummary(currentConversation); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - PermissionItems.Children.Add(new Border + Border CreateCollapsibleSection(string sectionKey, string icon, string title, UIElement content, bool expanded, string accentHex = "#334155") + { + var body = new Border + { + Margin = new Thickness(0, 6, 0, 0), + Visibility = expanded ? Visibility.Visible : Visibility.Collapsed, + Child = content, + }; + var caret = new TextBlock + { + Text = expanded ? "\uE70D" : "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + headerGrid.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = BrushFromHex(accentHex), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + var titleBlock = new TextBlock + { + Text = title, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(titleBlock, 1); + headerGrid.Children.Add(titleBlock); + Grid.SetColumn(caret, 2); + headerGrid.Children.Add(caret); + + var headerBorder = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(8, 6, 8, 6), + Cursor = Cursors.Hand, + Focusable = true, + Child = headerGrid, + }; + KeyboardNavigation.SetIsTabStop(headerBorder, true); + headerBorder.MouseEnter += (_, _) => headerBorder.Background = BrushFromHex("#F8FAFC"); + headerBorder.MouseLeave += (_, _) => headerBorder.Background = Brushes.Transparent; + void ToggleSection() + { + var show = body.Visibility != Visibility.Visible; + body.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + caret.Text = show ? "\uE70D" : "\uE76C"; + SetPermissionPopupSectionExpanded(sectionKey, show); + } + headerBorder.MouseLeftButtonUp += (_, _) => ToggleSection(); + headerBorder.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + ToggleSection(); + } + }; + + return new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(2), + Margin = new Thickness(0, 0, 0, 6), + Child = new StackPanel + { + Children = + { + headerBorder, + body, + } + } + }; + } + + var summaryCard = new Border { Background = string.Equals(summary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) ? BrushFromHex("#FFF7ED") @@ -1634,22 +1727,24 @@ public partial class ChatWindow : Window }, new TextBlock { - Text = $"{summary.Description} 기본값 {summary.DefaultMode} · override {summary.OverrideCount}개", + Text = $"{summary.Description} 기본값 {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개", FontSize = 10.5, Margin = new Thickness(0, 4, 0, 0), Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, TextWrapping = TextWrapping.Wrap, - MaxWidth = 240, + LineHeight = 15, + MaxWidth = 250, } } } - }); + }; + StackPanel? overrideSection = null; if (summary.TopOverrides.Count > 0) { var overrideWrap = new WrapPanel { - Margin = new Thickness(0, 0, 0, 8), + Margin = new Thickness(0, 0, 0, 6), }; foreach (var overrideEntry in summary.TopOverrides) @@ -1660,46 +1755,89 @@ public partial class ChatWindow : Window BorderBrush = BrushFromHex("#E2E8F0"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), - Padding = new Thickness(8, 4, 8, 4), - Margin = new Thickness(0, 0, 6, 6), - Child = new TextBlock + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 5, 5), + Child = new StackPanel { - Text = $"{overrideEntry.Key} · {overrideEntry.Value}", - FontSize = 10, - Foreground = Brushes.DimGray, + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE72E", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = BrushFromHex("#2563EB"), + Margin = new Thickness(0, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"{overrideEntry.Key} · {PermissionModeCatalog.ToDisplayLabel(overrideEntry.Value)}", + FontSize = 10, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray, + } + } } }); } - PermissionItems.Children.Add(new StackPanel + overrideSection = new StackPanel { Margin = new Thickness(0, 0, 0, 8), Children = { new TextBlock { - Text = "도구별 override", + Text = "도구별 예외", FontSize = 10.5, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Margin = new Thickness(2, 0, 0, 4), + Margin = new Thickness(2, 0, 0, 3), }, overrideWrap } - }); + }; } var latestDenied = _appState.GetLatestDeniedPermission(); + Border? deniedCard = null; if (latestDenied != null) { var deniedStack = new StackPanel(); + deniedStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uEA39", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = "최근 권한 거부", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex("#991B1B"), + } + } + }); deniedStack.Children.Add(new TextBlock { - Text = $"최근 권한 거부 · {_appState.FormatPermissionEventLine(latestDenied)}", + Text = _appState.FormatPermissionEventLine(latestDenied), FontSize = 10.5, Foreground = BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 0, 0), TextWrapping = TextWrapping.Wrap, - MaxWidth = 240, + LineHeight = 14.5, + MaxWidth = 250, }); if (!string.IsNullOrWhiteSpace(latestDenied.ToolName)) @@ -1708,7 +1846,7 @@ public partial class ChatWindow : Window { Text = $"도구 {latestDenied.ToolName}에 바로 적용", FontSize = 10, - Margin = new Thickness(0, 6, 0, 0), + Margin = new Thickness(0, 5, 0, 0), Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }); @@ -1726,11 +1864,11 @@ public partial class ChatWindow : Window Foreground = BrushFromHex(foregroundHex), BorderBrush = BrushFromHex(backgroundHex), BorderThickness = new Thickness(1), - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 6), - FontSize = 10.5, + Padding = new Thickness(9, 4, 9, 4), + Margin = new Thickness(0, 0, 5, 5), + FontSize = 10, Cursor = Cursors.Hand, - MinWidth = 62, + MinWidth = 58, }; ApplyHoverScaleAnimation(button, 1.02); button.Click += (_, _) => @@ -1741,104 +1879,205 @@ public partial class ChatWindow : Window return button; } - actionRow.Children.Add(CreateActionButton("기본으로", "#F8FAFC", "#334155", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default))); - actionRow.Children.Add(CreateActionButton("적극 허용", "#FFF7ED", "#C2410C", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits))); - actionRow.Children.Add(CreateActionButton("차단 유지", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny))); - actionRow.Children.Add(CreateActionButton("override 해제", "#F3F4F6", "#374151", () => SetToolPermissionOverride(latestDenied.ToolName!, null))); + actionRow.Children.Add(CreateActionButton("권한 요청", "#F8FAFC", "#334155", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default))); + actionRow.Children.Add(CreateActionButton("편집 자동 승인", "#FFF7ED", "#C2410C", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits))); + actionRow.Children.Add(CreateActionButton("활용하지 않음", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny))); + actionRow.Children.Add(CreateActionButton("예외 해제", "#F3F4F6", "#374151", () => SetToolPermissionOverride(latestDenied.ToolName!, null))); deniedStack.Children.Add(actionRow); } - PermissionItems.Children.Add(new Border + deniedCard = new Border { Background = BrushFromHex("#FEF2F2"), BorderBrush = BrushFromHex("#FECACA"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(0, 0, 0, 8), + Padding = new Thickness(11, 8, 11, 8), + Margin = new Thickness(0, 0, 0, 7), Child = deniedStack, - }); + }; } - var levels = new (string Level, string Sym, string Title, string Desc, string Color)[] { - (PermissionModeCatalog.Deny, "\uE711", "활용하지 않음", "읽기 전용 — 파일 읽기만 허용하고 생성/수정/삭제는 차단합니다", "#107C10"), - (PermissionModeCatalog.Default, "\uE8D7", "소극 활용", "매번 확인 — 파일 접근이나 명령 실행 전에 사용자에게 묻습니다", "#4B5EFC"), - (PermissionModeCatalog.AcceptEdits, "\uE73E", "적극 활용", "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다", "#DD6B20"), - (PermissionModeCatalog.Plan, "\uE7C3", "Plan", "계획/승인 중심 — 실행 전 계획과 사용자 확인 흐름을 우선합니다", "#4338CA"), - (PermissionModeCatalog.BypassPermissions, "\uE7BA", "Bypass", "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다", "#DC2626"), - (PermissionModeCatalog.DontAsk, "\uE8A5", "DontAsk", "권한 질문 없이 계속 진행합니다. 자동 실행 범위를 점검해야 합니다", "#B91C1C"), + var coreLevels = new (string Level, string Sym, string Title, string Desc, string Color)[] + { + (PermissionModeCatalog.Default, "\uE8D7", "권한 요청", "변경하기 전에 항상 확인", "#4B5EFC"), + (PermissionModeCatalog.AcceptEdits, "\uE70F", "편집 자동 승인", "모든 파일 편집 자동 승인", "#DD6B20"), + (PermissionModeCatalog.Plan, "\uE7C3", "계획 모드", "변경하기 전에 계획 만들기", "#4338CA"), + (PermissionModeCatalog.BypassPermissions, "\uE814", "권한 건너뛰기", "모든 권한 허용", "#166534"), + }; + var advancedLevels = new (string Level, string Sym, string Title, string Desc, string Color)[] + { + (PermissionModeCatalog.Deny, "\uE711", "활용하지 않음", "파일 읽기만 허용하고 생성/수정/삭제를 차단합니다", "#107C10"), + (PermissionModeCatalog.DontAsk, "\uE8A5", "질문 없이 진행", "권한 질문 없이 진행합니다. 자동 실행 범위를 점검하세요", "#B91C1C"), }; var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); - foreach (var (level, sym, title, desc, color) in levels) + void AddPermissionRows(Panel container, IEnumerable<(string Level, string Sym, string Title, string Desc, string Color)> levels) { - var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); + foreach (var (level, sym, title, desc, color) in levels) + { + var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); + var rowBorder = new Border + { + Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent, + BorderBrush = isActive ? BrushFromHex("#C7D2FE") : Brushes.Transparent, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + Focusable = true, + }; + KeyboardNavigation.SetIsTabStop(rowBorder, true); - // 라운드 코너 템플릿 (기본 Button 크롬 제거) - var template = new ControlTemplate(typeof(Button)); - var bdFactory = new FrameworkElementFactory(typeof(Border)); - bdFactory.SetValue(Border.BackgroundProperty, Brushes.Transparent); - bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); - bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 8, 12, 8)); - bdFactory.Name = "Bd"; - var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter)); - cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Left); - cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); - bdFactory.AppendChild(cpFactory); - template.VisualTree = bdFactory; - // 호버 효과 - var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; - hoverTrigger.Setters.Add(new Setter(Border.BackgroundProperty, - new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)), "Bd")); - template.Triggers.Add(hoverTrigger); + 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 }); - var btn = new Button - { - Template = template, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - HorizontalContentAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 0, 1), - }; - ApplyHoverScaleAnimation(btn, 1.02); - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive)); - sp.Children.Add(new TextBlock - { - Text = sym, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, - Foreground = BrushFromHex(color), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0), - }); - var textStack = new StackPanel(); - textStack.Children.Add(new TextBlock - { - Text = title, FontSize = 13, FontWeight = FontWeights.Bold, - Foreground = BrushFromHex(color), - }); - textStack.Children.Add(new TextBlock - { - Text = desc, FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 220, - }); - sp.Children.Add(textStack); - btn.Content = sp; + row.Children.Add(new TextBlock + { + Text = sym, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13.5, + Foreground = BrushFromHex(color), + Margin = new Thickness(2, 0, 9, 0), + VerticalAlignment = VerticalAlignment.Center, + }); - var capturedLevel = level; - btn.Click += (_, _) => - { - _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); - try { _settings.Save(); } catch { } - _appState.LoadFromSettings(_settings); - UpdatePermissionUI(); - SaveConversationSettings(); - RefreshInlineSettingsPanel(); - PermissionPopup.IsOpen = false; - }; - PermissionItems.Children.Add(btn); + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = title, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + }); + textStack.Children.Add(new TextBlock + { + Text = desc, + FontSize = 10.5, + Margin = new Thickness(0, 2, 0, 0), + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + LineHeight = 14.5, + MaxWidth = 230, + }); + 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(10, 0, 1, 0), + }; + Grid.SetColumn(check, 2); + row.Children.Add(check); + + rowBorder.Child = row; + rowBorder.MouseEnter += (_, _) => + { + rowBorder.Background = isActive ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"); + rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0"); + }; + rowBorder.MouseLeave += (_, _) => + { + rowBorder.Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent; + rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : Brushes.Transparent; + }; + + var capturedLevel = level; + void ApplyPermission() + { + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); + try { _settings.Save(); } catch { } + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + PermissionPopup.IsOpen = false; + } + rowBorder.MouseLeftButtonUp += (_, _) => ApplyPermission(); + rowBorder.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + ApplyPermission(); + } + }; + + container.Children.Add(rowBorder); + } } + + PermissionItems.Children.Add(new TextBlock + { + Text = "핵심 권한 모드", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(2, 0, 0, 4), + }); + AddPermissionRows(PermissionItems, coreLevels); + + PermissionItems.Children.Add(CreateCollapsibleSection( + "permission_summary", + "\uE946", + "현재 권한 요약", + summaryCard, + expanded: GetPermissionPopupSectionExpanded("permission_summary", false))); + if (overrideSection != null) + PermissionItems.Children.Add(CreateCollapsibleSection( + "permission_overrides", + "\uE72E", + "도구별 예외", + overrideSection, + expanded: GetPermissionPopupSectionExpanded("permission_overrides", false), + accentHex: "#1D4ED8")); + if (deniedCard != null) + PermissionItems.Children.Add(CreateCollapsibleSection( + "permission_denied", + "\uEA39", + "최근 권한 거부", + deniedCard, + expanded: GetPermissionPopupSectionExpanded("permission_denied", false), + accentHex: "#991B1B")); + + var advancedPanel = new StackPanel(); + AddPermissionRows(advancedPanel, advancedLevels); + PermissionItems.Children.Add(CreateCollapsibleSection( + "permission_advanced", + "\uE9CE", + "고급 모드", + advancedPanel, + expanded: GetPermissionPopupSectionExpanded("permission_advanced", false), + accentHex: "#64748B")); PermissionPopup.IsOpen = true; + Dispatcher.BeginInvoke(() => + { + TryFocusFirstPermissionElement(PermissionItems); + }, DispatcherPriority.Input); + } + + private static bool TryFocusFirstPermissionElement(DependencyObject root) + { + if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible) + return ui.Focus(); + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var i = 0; i < childCount; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (TryFocusFirstPermissionElement(child)) + return true; + } + + return false; } private void SetToolPermissionOverride(string toolName, string? mode) @@ -1872,6 +2111,21 @@ public partial class ChatWindow : Window private bool _permissionTopBannerDismissed; // 상단 권한 배너 닫힘 상태 private string _lastPermissionBannerMode = ""; + private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false) + { + var map = _settings.Settings.Llm.PermissionPopupSections; + if (map != null && map.TryGetValue(sectionKey, out var expanded)) + return expanded; + return defaultValue; + } + + private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded) + { + var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + map[sectionKey] = expanded; + try { _settings.Save(); } catch { } + } + private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e) { _permissionTopBannerDismissed = true; @@ -1916,13 +2170,13 @@ public partial class ChatWindow : Window PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74"); PermissionTopBannerIcon.Text = "\uE7BA"; PermissionTopBannerIcon.Foreground = warnColor; - PermissionTopBannerTitle.Text = "현재 권한 모드 · 적극 활용"; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인"; PermissionTopBannerTitle.Foreground = BrushFromHex("#C2410C"); PermissionTopBannerText.Text = "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다."; PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; } } - else if (perm == "Deny") + else if (perm == PermissionModeCatalog.Deny) { var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10)); PermissionLabel.Foreground = denyColor; @@ -1948,9 +2202,7 @@ public partial class ChatWindow : Window PermissionTopBanner.BorderBrush = BrushFromHex("#FCA5A5"); PermissionTopBannerIcon.Text = "\uE814"; PermissionTopBannerIcon.Foreground = dangerColor; - PermissionTopBannerTitle.Text = perm == PermissionModeCatalog.BypassPermissions - ? "현재 권한 모드 · Bypass" - : "현재 권한 모드 · DontAsk"; + PermissionTopBannerTitle.Text = $"현재 권한 모드 · {PermissionModeCatalog.ToDisplayLabel(perm)}"; PermissionTopBannerTitle.Foreground = dangerColor; PermissionTopBannerText.Text = "권한 확인을 거의 생략합니다. 민감한 작업 전에는 설정을 다시 확인하세요."; PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; @@ -1967,7 +2219,22 @@ public partial class ChatWindow : Window PermissionLabel.Foreground = defaultFg; PermissionIcon.Foreground = iconFg; if (PermissionTopBanner != null) - PermissionTopBanner.Visibility = Visibility.Collapsed; + { + if (perm == PermissionModeCatalog.Plan) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE"); + PermissionTopBannerIcon.Text = "\uE7C3"; + PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerText.Text = "쓰기 작업은 제한하고, 먼저 계획과 승인 흐름을 우선합니다."; + PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; + } + else + { + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + } } } @@ -2011,7 +2278,7 @@ public partial class ChatWindow : Window var overrides = summary.TopOverrides.Count > 0 ? string.Join(", ", summary.TopOverrides.Select(x => $"{x.Key}:{x.Value}")) : "없음"; - return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)} ({mode})\n설명: {summary.Description}\n기본값: {summary.DefaultMode} · override: {summary.OverrideCount}개\n상위 override: {overrides}"; + return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)} ({mode})\n설명: {summary.Description}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · override: {summary.OverrideCount}개\n상위 override: {overrides}"; } private void OpenPermissionPanelFromSlash(string command, string usageText) @@ -2649,10 +2916,10 @@ public partial class ChatWindow : Window var header = new TextBlock { Text = text, - FontSize = 11, + FontSize = 10.5, FontWeight = FontWeights.SemiBold, Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), - Margin = new Thickness(8, 12, 0, 4) + Margin = new Thickness(8, 10, 0, 3) }; ConversationPanel.Children.Add(header); } @@ -2670,8 +2937,8 @@ public partial class ChatWindow : Window ? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)) : Brushes.Transparent, CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = isBranch ? new Thickness(16, 1, 0, 1) : new Thickness(0, 1, 0, 1), + Padding = new Thickness(10, 7, 10, 7), + Margin = isBranch ? new Thickness(14, 1, 0, 1) : new Thickness(0, 1, 0, 1), Cursor = Cursors.Hand }; @@ -2710,16 +2977,16 @@ public partial class ChatWindow : Window var title = new TextBlock { Text = item.Title, - FontSize = 12.5, + FontSize = 12, Foreground = titleColor, TextTrimming = TextTrimming.CharacterEllipsis }; var date = new TextBlock { Text = item.UpdatedAtText, - FontSize = 10, + FontSize = 9.5, Foreground = dateColor, - Margin = new Thickness(0, 2, 0, 0) + Margin = new Thickness(0, 1, 0, 0) }; stack.Children.Add(title); stack.Children.Add(date); @@ -2739,7 +3006,7 @@ public partial class ChatWindow : Window Text = _appState.ActiveTasks.Count > 0 ? $"진행 중 {_appState.ActiveTasks.Count}" : "진행 중", - FontSize = 9.5, + FontSize = 9, FontWeight = FontWeights.SemiBold, Foreground = BrushFromHex("#1D4ED8"), } @@ -2767,7 +3034,7 @@ public partial class ChatWindow : Window Child = new TextBlock { Text = $"실패 {FormatDate(item.LastFailedAt.Value)}", - FontSize = 9.5, + FontSize = 9, FontWeight = FontWeights.SemiBold, Foreground = BrushFromHex("#991B1B"), } @@ -2789,7 +3056,7 @@ public partial class ChatWindow : Window Child = new TextBlock { Text = $"성공 {FormatDate(item.LastCompletedAt.Value)}", - FontSize = 9.5, + FontSize = 9, FontWeight = FontWeights.SemiBold, Foreground = BrushFromHex("#166534"), } @@ -2803,7 +3070,7 @@ public partial class ChatWindow : Window Text = item.FailedAgentRunCount > 0 ? $"실행 {item.AgentRunCount} · 실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}" : $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 32)}", - FontSize = 10, + FontSize = 9.5, Foreground = item.FailedAgentRunCount > 0 ? BrushFromHex("#B91C1C") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), @@ -3614,6 +3881,8 @@ public partial class ChatWindow : Window var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var userBubbleBg = hintBg; + var assistantBubbleBg = itemBg; if (isUser) { @@ -3621,24 +3890,24 @@ public partial class ChatWindow : Window var wrapper = new StackPanel { HorizontalAlignment = HorizontalAlignment.Right, - MaxWidth = 540, - Margin = new Thickness(120, 6, 40, 6), + MaxWidth = GetMessageMaxWidth(), + Margin = new Thickness(120, 6, 36, 6), }; var bubble = new Border { - Background = itemBg, + Background = userBubbleBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), - Padding = new Thickness(16, 10, 16, 10), + Padding = new Thickness(14, 9, 14, 9), Child = new TextBlock { Text = content, - FontSize = 13.5, + FontSize = 13, Foreground = primaryText, TextWrapping = TextWrapping.Wrap, - LineHeight = 21, + LineHeight = 20, } }; wrapper.Children.Add(bubble); @@ -3649,7 +3918,7 @@ public partial class ChatWindow : Window Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Opacity = 0, - Margin = new Thickness(0, 2, 0, 0), + Margin = new Thickness(0, 1, 0, 0), }; var capturedUserContent = content; var userBtnColor = secondaryText; @@ -3666,7 +3935,7 @@ public partial class ChatWindow : Window userBottomBar.Children.Add(new TextBlock { Text = timestamp.ToString("HH:mm"), - FontSize = 10, Opacity = 0.5, + FontSize = 9.5, Opacity = 0.55, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, @@ -3695,13 +3964,13 @@ public partial class ChatWindow : Window { HorizontalAlignment = HorizontalAlignment.Left, MaxWidth = GetMessageMaxWidth(), - Margin = new Thickness(40, 8, 80, 8) + Margin = new Thickness(36, 6, 120, 6) }; if (animate) ApplyMessageEntryAnimation(container); // AI 에이전트 이름 + 아이콘 var (agentName, _, _) = GetAgentIdentity(); - var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) }; + var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 3) }; // 다이아몬드 심볼 아이콘 (회전 애니메이션) var iconBlock = new TextBlock @@ -3717,7 +3986,7 @@ public partial class ChatWindow : Window headerSp.Children.Add(new TextBlock { Text = agentName, - FontSize = 11, + FontSize = 10.5, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, Margin = new Thickness(6, 0, 0, 0), @@ -3727,11 +3996,11 @@ public partial class ChatWindow : Window var contentCard = new Border { - Background = itemBg, + Background = assistantBubbleBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), - Padding = new Thickness(14, 10, 14, 10), + Padding = new Thickness(13, 9, 13, 9), }; var contentStack = new StackPanel(); @@ -3860,7 +4129,7 @@ public partial class ChatWindow : Window { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 6, 0, 0), + Margin = new Thickness(0, 5, 0, 0), Opacity = 0 }; @@ -3880,7 +4149,7 @@ public partial class ChatWindow : Window actionBar.Children.Add(new TextBlock { Text = aiTimestamp.ToString("HH:mm"), - FontSize = 10, Opacity = 0.5, + FontSize = 9.5, Opacity = 0.55, Foreground = btnColor, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), @@ -4089,7 +4358,7 @@ public partial class ChatWindow : Window { Text = symbol, FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12, + FontSize = 11.5, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center }; @@ -4099,7 +4368,7 @@ public partial class ChatWindow : Window Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, - Padding = new Thickness(6, 4, 6, 4), + Padding = new Thickness(5, 3, 5, 3), Margin = new Thickness(0, 0, 4, 0), ToolTip = tooltip }; @@ -4756,6 +5025,7 @@ public partial class ChatWindow : Window // ── 슬래시 명령어 팝업 상태 ── private readonly SlashPaletteState _slashPalette = new(); + private readonly Dictionary _slashVisibleItemByAbsoluteIndex = new(); // ── 슬래시 명령어 (탭별 분류) ── @@ -4889,17 +5159,8 @@ public partial class ChatWindow : Window if (matches.Count > 0) { - // 즐겨찾기를 상단에 고정 정렬 - var favorites = _settings.Settings.Llm.FavoriteSlashCommands; - if (favorites.Count > 0) - { - matches = matches - .OrderByDescending(m => favorites.Contains(m.Cmd, StringComparer.OrdinalIgnoreCase)) - .ToList(); - } - _slashPalette.Matches = matches; - _slashPalette.SelectedIndex = -1; + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches); RenderSlashPage(); SlashPopup.IsOpen = true; RefreshDraftQueueUi(); @@ -4919,33 +5180,211 @@ public partial class ChatWindow : Window return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}"; } + private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true) + { + var map = _settings.Settings.Llm.SlashPaletteSections; + if (map != null && map.TryGetValue(sectionKey, out var expanded)) + return expanded; + return defaultValue; + } + + private void SetSlashSectionExpanded(string sectionKey, bool expanded) + { + var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + map[sectionKey] = expanded; + try { _settings.Save(); } catch { } + } + + private bool AreAllSlashSectionsExpanded() + { + var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); + return commandsExpanded && skillsExpanded; + } + + private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e) + { + var expandAll = !AreAllSlashSectionsExpanded(); + SetSlashSectionExpanded("slash_commands", expandAll); + SetSlashSectionExpanded("slash_skills", expandAll); + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + } + + private void BtnSlashReset_Click(object sender, RoutedEventArgs e) + { + _settings.Settings.Llm.FavoriteSlashCommands.Clear(); + _settings.Settings.Llm.RecentSlashCommands.Clear(); + try { _settings.Save(); } catch { } + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + } + + private Dictionary BuildRecentSlashRankMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var recent = _settings.Settings.Llm.RecentSlashCommands; + for (var i = 0; i < recent.Count; i++) + { + var key = recent[i]?.Trim(); + if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) + continue; + map[key] = i; // index 낮을수록 최근 + } + return map; + } + + private Dictionary BuildFavoriteSlashRankMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var fav = _settings.Settings.Llm.FavoriteSlashCommands; + var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); + for (var i = 0; i < fav.Count; i++) + { + if (i >= maxFavorites) + break; + var key = fav[i]?.Trim(); + if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) + continue; + map[key] = i; // index 낮을수록 우선 + } + return map; + } + + private void RegisterRecentSlashCommand(string cmd) + { + if (string.IsNullOrWhiteSpace(cmd)) + return; + var recent = _settings.Settings.Llm.RecentSlashCommands; + var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50); + recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase)); + recent.Insert(0, cmd); + if (recent.Count > maxRecent) + recent.RemoveRange(maxRecent, recent.Count - maxRecent); + try { _settings.Save(); } catch { } + } + + private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches) + { + var commandExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillExpanded = GetSlashSectionExpanded("slash_skills", true); + for (var i = 0; i < matches.Count; i++) + { + var visible = matches[i].IsSkill ? skillExpanded : commandExpanded; + if (visible) + return i; + } + return -1; + } + + private bool IsSlashItemVisibleByIndex(int index) + { + if (index < 0 || index >= _slashPalette.Matches.Count) + return false; + var item = _slashPalette.Matches[index]; + return item.IsSkill + ? GetSlashSectionExpanded("slash_skills", true) + : GetSlashSectionExpanded("slash_commands", true); + } + /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. private void RenderSlashPage() { SlashItems.Items.Clear(); + _slashVisibleItemByAbsoluteIndex.Clear(); var total = _slashPalette.Matches.Count; var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; + var favoriteRank = BuildFavoriteSlashRankMap(); + var recentRank = BuildRecentSlashRankMap(); var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); - SlashPopupTitle.Text = "명령 팔레트"; + SlashPopupTitle.Text = "명령 및 스킬"; SlashPopupHint.Text = expressionLevel switch { "simple" => $"명령 {totalCommands} · 스킬 {totalSkills}", - "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · 추천: /compact /mcp /chrome", - _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개", + "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동", + _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행", }; - for (int i = 0; i < total; i++) + var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); + var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); + if (SlashToggleGroupsLabel != null) + SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기"; + + Border CreateSlashSectionHeader(string key, string title, int count, bool expanded) + { + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + 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 hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; + + var header = new Border + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(0, 0, 0, 6), + Cursor = Cursors.Hand, + }; + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + grid.Children.Add(new TextBlock + { + Text = expanded ? "\uE70D" : "\uE76C", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + var titleText = new TextBlock + { + Text = $"{title} {count}", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(titleText, 1); + grid.Children.Add(titleText); + var metaText = new TextBlock + { + Text = expanded ? "접기" : "펼치기", + FontSize = 10, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(metaText, 2); + grid.Children.Add(metaText); + + header.Child = grid; + header.MouseEnter += (_, _) => header.Background = hoverBrushItem; + header.MouseLeave += (_, _) => header.Background = itemBg; + header.MouseLeftButtonDown += (_, _) => + { + SetSlashSectionExpanded(key, !expanded); + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + }; + return header; + } + + void AddSlashItem(int i) { var (cmd, label, isSkill) = _slashPalette.Matches[i]; + var isFavorite = favoriteRank.ContainsKey(cmd); + var isRecent = recentRank.ContainsKey(cmd); var capturedCmd = cmd; var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; - var isFav = _settings.Settings.Llm.FavoriteSlashCommands - .Contains(cmd, StringComparer.OrdinalIgnoreCase); - var absoluteIndex = i; var isSelected = absoluteIndex == _slashPalette.SelectedIndex; var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; @@ -4989,32 +5428,40 @@ public partial class ChatWindow : Window Foreground = skillAvailable ? primaryText : secondaryText, VerticalAlignment = VerticalAlignment.Center, }); - var badge = new Border + if (isFavorite) { - CornerRadius = new CornerRadius(6), - BorderThickness = new Thickness(1), - BorderBrush = borderBrush, - Background = Brushes.Transparent, - Margin = new Thickness(8, 0, 0, 0), - Padding = new Thickness(6, 1, 6, 1), - Child = new TextBlock + titleRow.Children.Add(new Border { - Text = isSkill ? "스킬" : "명령", - FontSize = 10, - Foreground = secondaryText, - }, - VerticalAlignment = VerticalAlignment.Center, - }; - titleRow.Children.Add(badge); - if (isFav) + Background = BrushFromHex("#FEF3C7"), + BorderBrush = BrushFromHex("#F59E0B"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(5, 0, 5, 0), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = "핀", + FontSize = 9.5, + Foreground = BrushFromHex("#92400E"), + } + }); + } + if (isRecent) { - titleRow.Children.Add(new TextBlock + titleRow.Children.Add(new Border { - Text = " \uE735", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 10.5, - Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)), - VerticalAlignment = VerticalAlignment.Center, + Background = BrushFromHex("#EEF2FF"), + BorderBrush = BrushFromHex("#C7D2FE"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(5, 0, 5, 0), + Margin = new Thickness(6, 0, 0, 0), + Child = new TextBlock + { + Text = "최근", + FontSize = 9.5, + Foreground = BrushFromHex("#3730A3"), + } }); } leftStack.Children.Add(titleRow); @@ -5030,37 +5477,32 @@ public partial class ChatWindow : Window Grid.SetColumn(leftStack, 0); itemGrid.Children.Add(leftStack); - var favCapturedCmd = cmd; - var favBtn = new Border + var pinToggle = new Border { - Width = 24, - Height = 24, - CornerRadius = new CornerRadius(4), Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(6, 0, 0, 0), Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, Child = new TextBlock { - Text = isFav ? "\uE735" : "\uE734", + Text = isFavorite ? "\uE77A" : "\uE718", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, - Foreground = isFav - ? new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)) - : secondaryText, - HorizontalAlignment = HorizontalAlignment.Center, + Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText, VerticalAlignment = VerticalAlignment.Center, - Opacity = isFav ? 1.0 : 0.4, }, + ToolTip = isFavorite ? "핀 해제" : "핀 고정", }; - favBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8; - favBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; - favBtn.MouseLeftButtonDown += (_, me) => + pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem; + pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent; + pinToggle.MouseLeftButtonDown += (s, e) => { - me.Handled = true; - ToggleSlashFavorite(favCapturedCmd); + e.Handled = true; + ToggleSlashFavorite(capturedCmd); }; - Grid.SetColumn(favBtn, 1); - itemGrid.Children.Add(favBtn); + Grid.SetColumn(pinToggle, 1); + itemGrid.Children.Add(pinToggle); item.Child = itemGrid; @@ -5085,6 +5527,46 @@ public partial class ChatWindow : Window } SlashItems.Items.Add(item); + _slashVisibleItemByAbsoluteIndex[absoluteIndex] = item; + } + + SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded)); + if (commandsExpanded) + { + var commandIndices = Enumerable.Range(0, total) + .Where(i => !_slashPalette.Matches[i].IsSkill) + .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) + .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) + .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); + foreach (var i in commandIndices) + { + AddSlashItem(i); + } + } + + SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded)); + if (skillsExpanded) + { + var skillIndices = Enumerable.Range(0, total) + .Where(i => _slashPalette.Matches[i].IsSkill) + .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) + .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) + .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); + foreach (var i in skillIndices) + { + AddSlashItem(i); + } + } + + var visibleCommandCount = commandsExpanded ? totalCommands : 0; + var visibleSkillCount = skillsExpanded ? totalSkills : 0; + if (visibleCommandCount + visibleSkillCount == 0) + { + SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기"; + } + else + { + SlashPopupFooter.Text = $"Enter 실행 · ↑↓ 이동 · 휠 스크롤 · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}"; } EnsureSlashSelectionVisible(); @@ -5097,24 +5579,47 @@ public partial class ChatWindow : Window SlashPopup_ScrollByDelta(e.Delta); } + private void MoveSlashSelection(int direction) + { + if (_slashPalette.Matches.Count == 0) + return; + + if (_slashPalette.SelectedIndex < 0 || !IsSlashItemVisibleByIndex(_slashPalette.SelectedIndex)) + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + + if (_slashPalette.SelectedIndex < 0) + return; + + if (direction < 0) + { + for (var i = _slashPalette.SelectedIndex - 1; i >= 0; i--) + { + if (!IsSlashItemVisibleByIndex(i)) continue; + _slashPalette.SelectedIndex = i; + return; + } + } + else if (direction > 0) + { + for (var i = _slashPalette.SelectedIndex + 1; i < _slashPalette.Matches.Count; i++) + { + if (!IsSlashItemVisibleByIndex(i)) continue; + _slashPalette.SelectedIndex = i; + return; + } + } + } + /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. private void SlashPopup_ScrollByDelta(int delta) { if (_slashPalette.Matches.Count == 0) return; - if (delta > 0) - { - if (_slashPalette.SelectedIndex > 0) - _slashPalette.SelectedIndex--; - } - else - { - if (_slashPalette.SelectedIndex < 0) - _slashPalette.SelectedIndex = 0; - else if (_slashPalette.SelectedIndex < _slashPalette.Matches.Count - 1) - _slashPalette.SelectedIndex++; - } + var steps = Math.Max(1, Math.Abs(delta) / 120); + var direction = delta > 0 ? -1 : 1; + for (var i = 0; i < steps; i++) + MoveSlashSelection(direction); RenderSlashPage(); } @@ -5129,6 +5634,7 @@ public partial class ChatWindow : Window var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; if (!skillAvailable) return; + RegisterRecentSlashCommand(cmd); SlashPopup.IsOpen = false; _slashPalette.SelectedIndex = -1; @@ -5148,16 +5654,7 @@ public partial class ChatWindow : Window if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0) return; - if (VisualTreeHelper.GetChildrenCount(SlashItems) == 0) - return; - - if (VisualTreeHelper.GetChild(SlashItems, 0) is not FrameworkElement presenter) - return; - - if (VisualTreeHelper.GetChildrenCount(presenter) <= _slashPalette.SelectedIndex) - return; - - if (VisualTreeHelper.GetChild(presenter, _slashPalette.SelectedIndex) is not FrameworkElement item) + if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item)) return; var bounds = item.TransformToAncestor(SlashScrollViewer) @@ -5173,15 +5670,26 @@ public partial class ChatWindow : Window private void ToggleSlashFavorite(string cmd) { var favs = _settings.Settings.Llm.FavoriteSlashCommands; + var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase)); if (existing != null) favs.Remove(existing); else + { favs.Add(cmd); + if (favs.Count > maxFavorites) + favs.RemoveRange(maxFavorites, favs.Count - maxFavorites); + } _settings.Save(); - // 팝업 새로고침: TextChanged 이벤트를 트리거하여 팝업 재렌더링 + if (SlashPopup.IsOpen) + { + RenderSlashPage(); + return; + } + + // 팝업이 닫힌 경우에만 TextChanged 트리거 var currentText = InputBox.Text; InputBox.TextChanged -= InputBox_TextChanged; InputBox.Text = ""; @@ -8180,6 +8688,7 @@ public partial class ChatWindow : Window private Border CreateTaskSummaryFilterChip(string key, string label) { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase); var chip = new Border { @@ -8187,14 +8696,14 @@ public partial class ChatWindow : Window BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 6), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 5, 5), Cursor = Cursors.Hand, Child = new TextBlock { Text = label, - FontSize = 11, - Foreground = active ? BrushFromHex("#4338CA") : Brushes.DimGray, + FontSize = 10.5, + Foreground = active ? BrushFromHex("#4338CA") : secondaryText, } }; chip.MouseLeftButtonUp += (_, _) => @@ -9262,31 +9771,31 @@ public partial class ChatWindow : Window var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; var (icon, label, bgHex, fgHex) = isTotalStats - ? ("\uE9D2", "Total Stats", "#F3EEFF", "#7C3AED") + ? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED") : evt.Type switch { - AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"), + AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"), AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true), AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false), - AgentEventType.PermissionDenied => ("\uE783", "Denied", "#FEF2F2", "#DC2626"), + AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"), AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary), - AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"), - AgentEventType.ToolResult => ("\uE73E", evt.ToolName, "#EEF9EE", "#16A34A"), - AgentEventType.SkillCall => ("\uE8A5", evt.ToolName, "#FFF7ED", "#EA580C"), - AgentEventType.Error => ("\uE783", "Error", "#FEF2F2", "#DC2626"), - AgentEventType.Complete => ("\uE930", "Complete", "#F0FFF4", "#15803D"), - AgentEventType.StepDone => ("\uE73E", "Step Done", "#EEF9EE", "#16A34A"), - AgentEventType.Paused => ("\uE769", "Paused", "#FFFBEB", "#D97706"), - AgentEventType.Resumed => ("\uE768", "Resumed", "#ECFDF5", "#059669"), - _ => ("\uE946", "Agent", "#F5F5F5", "#6B7280"), + AgentEventType.ToolCall => ("\uE8A7", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 실행" : evt.ToolName, "#EEF6FF", "#3B82F6"), + AgentEventType.ToolResult => ("\uE73E", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 완료" : evt.ToolName, "#EEF9EE", "#16A34A"), + AgentEventType.SkillCall => ("\uE8A5", string.IsNullOrWhiteSpace(evt.ToolName) ? "스킬 실행" : evt.ToolName, "#FFF7ED", "#EA580C"), + AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"), + AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"), + AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"), + AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"), + AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"), + _ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"), }; var banner = new Border { Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex)), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(40, 2, 40, 2), + CornerRadius = new CornerRadius(7), + Padding = new Thickness(10, 7, 10, 7), + Margin = new Thickness(36, 2, 36, 2), HorizontalAlignment = HorizontalAlignment.Stretch, }; if (!string.IsNullOrWhiteSpace(evt.RunId)) @@ -9375,7 +9884,7 @@ public partial class ChatWindow : Window sp.Children.Add(new TextBlock { Text = shortSummary, - FontSize = 11, + FontSize = 10.5, Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")), TextWrapping = TextWrapping.NoWrap, TextTrimming = TextTrimming.CharacterEllipsis, @@ -9386,81 +9895,15 @@ public partial class ChatWindow : Window // detailed/debug 모드: 기존 접이식 표시 else if (!string.IsNullOrEmpty(evt.Summary)) { - var summaryText = evt.Summary; - var isExpandable = (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.ToolResult) - && summaryText.Length > 60; - - if (isExpandable) + var summaryText = evt.Summary.Length > 180 ? evt.Summary[..180] + "…" : evt.Summary; + sp.Children.Add(new TextBlock { - // 첫 줄만 표시하고 클릭하면 전체 내용 펼침 - var shortText = summaryText.Length > 80 ? summaryText[..80] + "..." : summaryText; - var summaryTb = new TextBlock - { - Text = shortText, - FontSize = 11.5, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 3, 0, 0), - Cursor = Cursors.Hand, - }; - - // Diff가 포함된 경우 색상 하이라이팅 적용 - var hasDiff = summaryText.Contains("--- ") && summaryText.Contains("+++ "); - UIElement fullContent; - - if (hasDiff) - { - fullContent = BuildDiffView(summaryText); - } - else - { - fullContent = new TextBlock - { - Text = summaryText, - FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")), - TextWrapping = TextWrapping.Wrap, - FontFamily = new FontFamily("Consolas"), - }; - } - fullContent.Visibility = Visibility.Collapsed; - ((FrameworkElement)fullContent).Margin = new Thickness(0, 4, 0, 0); - - // 펼침/접기 토글 - var expandIcon = new TextBlock - { - Text = "\uE70D", // ChevronDown - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 9, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")), - Margin = new Thickness(6, 0, 0, 0), - VerticalAlignment = VerticalAlignment.Center, - }; - header.Children.Add(expandIcon); - - var isExpanded = false; - banner.MouseLeftButtonDown += (_, _) => - { - isExpanded = !isExpanded; - fullContent.Visibility = isExpanded ? Visibility.Visible : Visibility.Collapsed; - summaryTb.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible; - expandIcon.Text = isExpanded ? "\uE70E" : "\uE70D"; // ChevronUp : ChevronDown - }; - - sp.Children.Add(summaryTb); - sp.Children.Add(fullContent); - } - else - { - sp.Children.Add(new TextBlock - { - Text = summaryText, - FontSize = 11.5, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 3, 0, 0), - }); - } + Text = summaryText, + FontSize = 10.5, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 3, 0, 0), + }); } var reviewChipRow = BuildReviewSignalChipRow( @@ -9498,11 +9941,20 @@ public partial class ChatWindow : Window { Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8FAFC")), CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 4, 8, 4), + Padding = new Thickness(8, 5, 8, 5), Margin = new Thickness(0, 4, 0, 0), }; - var pathPanel = new StackPanel { Orientation = Orientation.Horizontal }; - pathPanel.Children.Add(new TextBlock + + var pathGrid = new Grid(); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var left = new StackPanel { Orientation = Orientation.Vertical }; + var fileName = System.IO.Path.GetFileName(evt.FilePath); + var dirName = System.IO.Path.GetDirectoryName(evt.FilePath) ?? ""; + + var topRow = new StackPanel { Orientation = Orientation.Horizontal }; + topRow.Children.Add(new TextBlock { Text = "\uE8B7", // folder icon FontFamily = new FontFamily("Segoe MDL2 Assets"), @@ -9511,21 +9963,37 @@ public partial class ChatWindow : Window VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), }); - pathPanel.Children.Add(new TextBlock + topRow.Children.Add(new TextBlock { - Text = evt.FilePath, + Text = string.IsNullOrWhiteSpace(fileName) ? evt.FilePath : fileName, FontSize = 10.5, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")), - FontFamily = new FontFamily("Consolas"), + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#374151")), VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, }); + left.Children.Add(topRow); + if (!string.IsNullOrWhiteSpace(dirName)) + { + left.Children.Add(new TextBlock + { + Text = dirName, + FontSize = 9.5, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")), + FontFamily = new FontFamily("Consolas"), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + } + Grid.SetColumn(left, 0); + pathGrid.Children.Add(left); // 빠른 작업 버튼들 var quickActions = BuildFileQuickActions(evt.FilePath); - pathPanel.Children.Add(quickActions); + Grid.SetColumn(quickActions, 1); + pathGrid.Children.Add(quickActions); - pathBorder.Child = pathPanel; + pathBorder.Child = pathGrid; sp.Children.Add(pathBorder); } @@ -9558,39 +10026,34 @@ public partial class ChatWindow : Window var panel = new StackPanel { Orientation = Orientation.Horizontal, - Margin = new Thickness(6, 0, 0, 0), + Margin = new Thickness(8, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }; var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); var accentBrush = new SolidColorBrush(accentColor); - Border MakeBtn(string mdlIcon, string label, Action action) + Border MakeBtn(string mdlIcon, string tooltip, Action action) { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock + var icon = new TextBlock { Text = mdlIcon, FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 9, - Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 3, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 10, Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, - }); + }; var btn = new Border { - Child = sp, + Child = icon, Background = Brushes.Transparent, CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 2, 5, 2), + Width = 22, + Height = 22, + Margin = new Thickness(0, 0, 2, 0), Cursor = Cursors.Hand, + ToolTip = tooltip, }; btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; @@ -9624,24 +10087,7 @@ public partial class ChatWindow : Window var path4 = filePath; panel.Children.Add(MakeBtn("\uE8C8", "복사", () => { - try - { - Clipboard.SetText(path4); - // 1.5초 피드백: "복사됨" 표시 - if (panel.Children[^1] is Border lastBtn && lastBtn.Child is StackPanel lastSp) - { - var origLabel = lastSp.Children.OfType().LastOrDefault(); - if (origLabel != null) - { - var prev = origLabel.Text; - origLabel.Text = "복사됨 ✓"; - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; - timer.Tick += (_, _) => { origLabel.Text = prev; timer.Stop(); }; - timer.Start(); - } - } - } - catch { } + try { Clipboard.SetText(path4); } catch { } })); return panel; @@ -10496,12 +10942,45 @@ public partial class ChatWindow : Window SlashPopup_ScrollByDelta(-120); // 아래로 1칸 e.Handled = true; } + else if (e.Key == Key.PageUp) + { + SlashPopup_ScrollByDelta(600); + e.Handled = true; + } + else if (e.Key == Key.PageDown) + { + SlashPopup_ScrollByDelta(-600); + e.Handled = true; + } + else if (e.Key == Key.Home) + { + _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + RenderSlashPage(); + e.Handled = true; + } + else if (e.Key == Key.End) + { + for (var i = _slashPalette.Matches.Count - 1; i >= 0; i--) + { + if (!IsSlashItemVisibleByIndex(i)) continue; + _slashPalette.SelectedIndex = i; + break; + } + RenderSlashPage(); + e.Handled = true; + } else if (e.Key == Key.Enter && _slashPalette.SelectedIndex >= 0) { e.Handled = true; ExecuteSlashSelectedItem(); } } + + if (PermissionPopup.IsOpen && e.Key == Key.Escape) + { + PermissionPopup.IsOpen = false; + e.Handled = true; + } } private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); @@ -12325,7 +12804,11 @@ public partial class ChatWindow : Window "vllm" => "vLLM", _ => "Ollama", }; - ModelLabel.Text = $"{serviceLabel} · {GetCurrentModelDisplayName()}"; + var model = GetCurrentModelDisplayName(); + const int maxLen = 22; + if (model.Length > maxLen) + model = model[..(maxLen - 1)] + "…"; + ModelLabel.Text = $"{serviceLabel} · {model}"; } private static string NextPlanMode(string current) => (current ?? "off").ToLowerInvariant() switch @@ -12336,9 +12819,9 @@ public partial class ChatWindow : Window }; private static string PlanModeLabel(string value) => (value ?? "off").ToLowerInvariant() switch { - "always" => "Always", - "auto" => "Auto", - _ => "Off", + "always" => "항상 계획", + "auto" => "자동 계획", + _ => "끄기", }; private static string NextReasoning(string current) => (current ?? "normal").ToLowerInvariant() switch { @@ -12354,12 +12837,10 @@ public partial class ChatWindow : Window }; private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch { - "deny" => "Default", "default" => "AcceptEdits", "acceptedits" => "Plan", "plan" => "BypassPermissions", - "bypasspermissions" => "DontAsk", - _ => "Deny", + _ => "Default", }; private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch { @@ -12434,11 +12915,11 @@ public partial class ChatWindow : Window CmbInlineModel.SelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; } - BtnInlineFastMode.Content = $"Fast mode · {(llm.FreeTierMode ? "On" : "Off")}"; - BtnInlineReasoning.Content = $"Reasoning · {ReasoningLabel(llm.AgentDecisionLevel)}"; - BtnInlinePlanMode.Content = $"Plan mode · {PlanModeLabel(llm.PlanMode)}"; - BtnInlinePermission.Content = $"Permission · {PermissionModeCatalog.ToDisplayLabel(llm.FilePermission)}"; - BtnInlineSkill.Content = $"Skill system · {(llm.EnableSkillSystem ? "On" : "Off")}"; + BtnInlineFastMode.Content = $"Fast · {(llm.FreeTierMode ? "On" : "Off")}"; + BtnInlineReasoning.Content = $"추론 · {ReasoningLabel(llm.AgentDecisionLevel)}"; + BtnInlinePlanMode.Content = $"계획 · {PlanModeLabel(llm.PlanMode)}"; + BtnInlinePermission.Content = $"권한 · {PermissionModeCatalog.ToDisplayLabel(llm.FilePermission)}"; + BtnInlineSkill.Content = $"스킬 · {(llm.EnableSkillSystem ? "On" : "Off")}"; BtnInlineCommandBrowser.Content = "명령/스킬 브라우저"; var mcpTotal = llm.McpServers?.Count ?? 0; @@ -12453,7 +12934,21 @@ public partial class ChatWindow : Window private void BtnModelSelector_Click(object sender, RoutedEventArgs e) { - OpenAgentSettingsWindow(); + RefreshInlineSettingsPanel(); + InlineSettingsPanel.Visibility = InlineSettingsPanel.Visibility == Visibility.Visible + ? Visibility.Collapsed + : Visibility.Visible; + + if (InlineSettingsPanel.Visibility == Visibility.Visible) + { + Dispatcher.BeginInvoke(() => + { + if (CmbInlineModel.Items.Count > 0) + CmbInlineModel.Focus(); + else + InputBox.Focus(); + }, DispatcherPriority.Input); + } } private void OpenAgentSettingsWindow() @@ -13931,14 +14426,14 @@ public partial class ChatWindow : Window if (spinning) StartStatusAnimation(); } - private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary) - { - if (IsDecisionApproved(summary)) - return ("\uE73E", "Plan Approved", "#ECFDF5", "#059669"); - if (IsDecisionRejected(summary)) - return ("\uE783", "Plan Rejected", "#FEF2F2", "#DC2626"); - return ("\uE70F", "Plan Decision", "#FFF7ED", "#C2410C"); - } +private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary) +{ + if (IsDecisionApproved(summary)) + return ("\uE73E", "계획 승인", "#ECFDF5", "#059669"); + if (IsDecisionRejected(summary)) + return ("\uE783", "계획 반려", "#FEF2F2", "#DC2626"); + return ("\uE70F", "계획 확인", "#FFF7ED", "#C2410C"); +} private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending) { @@ -13946,23 +14441,23 @@ public partial class ChatWindow : Window if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) return pending - ? ("\uE756", "Command Permission", "#FEF2F2", "#DC2626") - : ("\uE73E", "Command Approved", "#ECFDF5", "#059669"); + ? ("\uE756", "명령 권한 요청", "#FEF2F2", "#DC2626") + : ("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669"); if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) return pending - ? ("\uE774", "Network Permission", "#FFF7ED", "#C2410C") - : ("\uE73E", "Network Approved", "#ECFDF5", "#059669"); + ? ("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669"); if (tool.Contains("file")) return pending - ? ("\uE8A5", "File Permission", "#FFF7ED", "#C2410C") - : ("\uE73E", "File Approved", "#ECFDF5", "#059669"); + ? ("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669"); return pending - ? ("\uE897", "Permission", "#FFF7ED", "#C2410C") - : ("\uE73E", "Approved", "#ECFDF5", "#059669"); - } + ? ("\uE897", "권한 요청", "#FFF7ED", "#C2410C") + : ("\uE73E", "권한 허용", "#ECFDF5", "#059669"); +} private static bool IsDecisionPending(string? summary) { @@ -14465,14 +14960,24 @@ public partial class ChatWindow : Window 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 = "작업 상태", + Text = "작업 요약", FontSize = 12.5, FontWeight = FontWeights.SemiBold, - Foreground = Brushes.Black, - Margin = new Thickness(10, 8, 10, 6), + Foreground = primaryText, + Margin = new Thickness(10, 8, 10, 4), + }); + panel.Children.Add(new TextBlock + { + Text = "현재 실행/권한/작업 흐름", + FontSize = 10, + Foreground = secondaryText, + Margin = new Thickness(10, 0, 10, 6), }); var taskFilterRow = new WrapPanel { @@ -14480,10 +14985,10 @@ public partial class ChatWindow : Window }; taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("all", "전체")); taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("permission", "권한")); - taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("queue", "큐")); - taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("hook", "훅")); - taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("subagent", "서브")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("queue", "대기")); taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("tool", "도구")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("subagent", "서브")); + taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("hook", "훅")); panel.Children.Add(taskFilterRow); ChatConversation? currentConversation; @@ -14508,7 +15013,7 @@ public partial class ChatWindow : Window { Text = $"현재 실행 run {ShortRunId(_appState.AgentRun.RunId)}", FontWeight = FontWeights.SemiBold, - Foreground = Brushes.Black, + Foreground = primaryText, }, new TextBlock { @@ -14529,7 +15034,7 @@ public partial class ChatWindow : Window panel.Children.Add(currentRun); } - var recentAgentRuns = _appState.GetRecentAgentRuns(4); + var recentAgentRuns = _appState.GetRecentAgentRuns(3); if (recentAgentRuns.Count > 0) { var latestFailedRun = _appState.GetLatestFailedRun(); @@ -14564,7 +15069,7 @@ public partial class ChatWindow : Window Text = string.IsNullOrWhiteSpace(latestFailedRun.Summary) ? "요약 없음" : latestFailedRun.Summary, Margin = new Thickness(0, 4, 0, 0), TextWrapping = TextWrapping.Wrap, - Foreground = Brushes.DimGray, + Foreground = secondaryText, } } } @@ -14584,7 +15089,6 @@ public partial class ChatWindow : Window { var runEvents = GetExecutionEventsForRun(run.RunId); var runFilePaths = GetExecutionEventFilePaths(run.RunId); - var runPlanHistory = GetRunPlanHistory(run.RunId); var runDisplay = _appState.GetRunDisplay(run); var runCardStack = new StackPanel { @@ -14600,20 +15104,42 @@ public partial class ChatWindow : Window { Text = runDisplay.MetaText, Margin = new Thickness(0, 2, 0, 0), - Foreground = Brushes.Gray, + Foreground = secondaryText, }, new TextBlock { - Text = runDisplay.SummaryText, + Text = TruncateForStatus(runDisplay.SummaryText, 140), Margin = new Thickness(0, 3, 0, 0), TextWrapping = TextWrapping.Wrap, - Foreground = Brushes.DimGray, + Foreground = secondaryText, } } }; - if (runEvents.Count > 0) + if (runEvents.Count > 0 || runFilePaths.Count > 0) { + var activitySummary = new StackPanel(); + activitySummary.Children.Add(new TextBlock + { + Text = $"실행 로그 {runEvents.Count} · 관련 파일 {runFilePaths.Count}", + FontSize = 10, + Foreground = secondaryText, + }); + + if (!string.IsNullOrWhiteSpace(run.RunId)) + { + var capturedRunId = run.RunId; + var timelineButton = CreateTaskSummaryActionButton( + "타임라인 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ScrollToRunInTimeline(capturedRunId), + trailingMargin: false); + timelineButton.Margin = new Thickness(0, 6, 0, 0); + activitySummary.Children.Add(timelineButton); + } + runCardStack.Children.Add(new Border { Background = BrushFromHex("#F8FAFC"), @@ -14622,216 +15148,38 @@ public partial class ChatWindow : Window CornerRadius = new CornerRadius(8), Padding = new Thickness(8, 6, 8, 6), Margin = new Thickness(0, 8, 0, 0), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = "관련 실행 로그", - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.Gray, - Margin = new Thickness(0, 0, 0, 4), - } - } - } + Child = activitySummary }); - - var logContainer = (StackPanel)((Border)runCardStack.Children[^1]).Child; - foreach (var executionEvent in runEvents) - { - var executionLink = new Button - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(0), - Margin = new Thickness(0, 1, 0, 1), - HorizontalContentAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - ToolTip = "타임라인에서 이 실행 위치로 이동", - Content = new TextBlock - { - Text = _appState.FormatExecutionEventLine(executionEvent), - FontSize = 10, - Foreground = string.Equals(executionEvent.Type, "Error", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#B91C1C") - : Brushes.DimGray, - TextWrapping = TextWrapping.Wrap, - TextDecorations = TextDecorations.Underline, - } - }; - var capturedRunId = run.RunId; - executionLink.Click += (_, _) => ScrollToRunInTimeline(capturedRunId); - logContainer.Children.Add(executionLink); - } - } - - var planHistoryCard = BuildRunPlanHistoryCard(runPlanHistory); - if (planHistoryCard != null) - runCardStack.Children.Add(planHistoryCard); - - if (runFilePaths.Count > 0) - { - runCardStack.Children.Add(new Border - { - Background = BrushFromHex("#FFFBEB"), - BorderBrush = BrushFromHex("#FDE68A"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(0, 8, 0, 0), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = "관련 파일", - FontSize = 10, - FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex("#92400E"), - Margin = new Thickness(0, 0, 0, 4), - } - } - } - }); - - var fileContainer = (StackPanel)((Border)runCardStack.Children[^1]).Child; - foreach (var path in runFilePaths) - { - var row = new Grid(); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var fileLink = new Button - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(0), - Margin = new Thickness(0, 1, 0, 1), - HorizontalContentAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - ToolTip = path, - Content = new TextBlock - { - Text = TruncateForStatus(path, 64), - FontSize = 10, - Foreground = BrushFromHex("#A16207"), - TextWrapping = TextWrapping.Wrap, - TextDecorations = TextDecorations.Underline, - } - }; - var capturedPath = path; - fileLink.Click += (_, _) => OpenRunFilePath(capturedPath); - Grid.SetColumn(fileLink, 0); - row.Children.Add(fileLink); - - var openExternalButton = new Button - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(4, 0, 4, 0), - Margin = new Thickness(6, 0, 0, 0), - Cursor = Cursors.Hand, - ToolTip = "외부 프로그램으로 열기", - Content = new TextBlock - { - Text = "\uE8A7", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = BrushFromHex("#A16207"), - } - }; - openExternalButton.Click += (_, _) => OpenRunFileExternal(capturedPath); - Grid.SetColumn(openExternalButton, 1); - row.Children.Add(openExternalButton); - - var revealFolderButton = new Button - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(4, 0, 4, 0), - Margin = new Thickness(2, 0, 0, 0), - Cursor = Cursors.Hand, - ToolTip = "폴더에서 보기", - Content = new TextBlock - { - Text = "\uED25", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, - Foreground = BrushFromHex("#A16207"), - } - }; - revealFolderButton.Click += (_, _) => RevealRunFileInFolder(capturedPath); - Grid.SetColumn(revealFolderButton, 2); - row.Children.Add(revealFolderButton); - - fileContainer.Children.Add(row); - } } if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase)) { - var completedActions = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(0, 8, 0, 0), - }; - - var followUpButton = new Button - { - Content = "후속 작업 큐에 넣기", - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#ECFDF5"), - BorderBrush = BrushFromHex("#BBF7D0"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#166534"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; var capturedRun = run; - followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedRun); - completedActions.Children.Add(followUpButton); - - var branchButton = new Button - { - Content = "새 분기에서 이어가기", - Margin = new Thickness(8, 0, 0, 0), - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#3730A3"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; - branchButton.Click += (_, _) => BranchConversationFromRun(capturedRun); - completedActions.Children.Add(branchButton); - - runCardStack.Children.Add(completedActions); + var followUpButton = CreateTaskSummaryActionButton( + "후속 작업 큐에 넣기", + "#ECFDF5", + "#BBF7D0", + "#166534", + (_, _) => EnqueueFollowUpFromRun(capturedRun), + trailingMargin: false); + followUpButton.Margin = new Thickness(0, 8, 0, 0); + runCardStack.Children.Add(followUpButton); } if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) { - var retryButton = new Button - { - Content = "이 실행 다시 시도", - Margin = new Thickness(0, 8, 0, 0), - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#FEF2F2"), - BorderBrush = BrushFromHex("#FCA5A5"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#991B1B"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; - retryButton.Click += (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - RetryLastUserMessageFromConversation(); - }; + var retryButton = CreateTaskSummaryActionButton( + "이 실행 다시 시도", + "#FEF2F2", + "#FCA5A5", + "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }, + trailingMargin: false); + retryButton.Margin = new Thickness(0, 8, 0, 0); runCardStack.Children.Add(retryButton); } @@ -14841,7 +15189,7 @@ public partial class ChatWindow : Window BorderBrush = BrushFromHex("#E5E7EB"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), - Padding = new Thickness(10, 7, 10, 7), + Padding = new Thickness(9, 6, 9, 6), Margin = new Thickness(8, 0, 8, 6), Child = runCardStack }); @@ -14856,68 +15204,35 @@ public partial class ChatWindow : Window Margin = new Thickness(8, 2, 8, 8), }; - var retryButton = new Button - { - Content = "마지막 사용자 요청 다시 시도", - Margin = new Thickness(0, 0, 8, 0), - Padding = new Thickness(10, 6, 10, 6), - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#3730A3"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; - retryButton.Click += (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - RetryLastUserMessageFromConversation(); - }; + var retryButton = CreateTaskSummaryActionButton( + "마지막 요청 다시 시도", + "#EEF2FF", + "#C7D2FE", + "#3730A3", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }); actionsPanel.Children.Add(retryButton); - var filterButton = new Button - { - Content = "실패 대화만 보기", - Padding = new Thickness(10, 6, 10, 6), - Background = BrushFromHex("#FEF2F2"), - BorderBrush = BrushFromHex("#FECACA"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#991B1B"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; - filterButton.Click += (_, _) => - { - _failedOnlyFilter = true; - UpdateConversationFailureFilterUi(); - PersistConversationListPreferences(); - RefreshConversationList(); - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - }; - actionsPanel.Children.Add(filterButton); - if (_appState.ActiveTasks.Count > 0) { - var runningFilterButton = new Button - { - Content = "진행 중 대화만 보기", - Margin = new Thickness(8, 0, 0, 0), - Padding = new Thickness(10, 6, 10, 6), - Background = BrushFromHex("#DBEAFE"), - BorderBrush = BrushFromHex("#93C5FD"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#1D4ED8"), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = Cursors.Hand, - }; - runningFilterButton.Click += (_, _) => - { - _runningOnlyFilter = true; - UpdateConversationRunningFilterUi(); - PersistConversationListPreferences(); - RefreshConversationList(); - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - }; + var runningFilterButton = CreateTaskSummaryActionButton( + "진행 중 대화만 보기", + "#DBEAFE", + "#93C5FD", + "#1D4ED8", + (_, _) => + { + _runningOnlyFilter = true; + UpdateConversationRunningFilterUi(); + PersistConversationListPreferences(); + RefreshConversationList(); + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + }, + trailingMargin: false); + runningFilterButton.Margin = new Thickness(2, 0, 0, 0); actionsPanel.Children.Add(runningFilterButton); } panel.Children.Add(actionsPanel); @@ -14936,7 +15251,7 @@ public partial class ChatWindow : Window { Text = "표시할 작업 이력이 없습니다.", Margin = new Thickness(10, 2, 10, 8), - Foreground = Brushes.DimGray, + Foreground = secondaryText, }); } @@ -14949,7 +15264,7 @@ public partial class ChatWindow : Window PopupAnimation = PopupAnimation.Fade, Child = new Border { - Background = Brushes.White, + Background = popupBackground, BorderBrush = BrushFromHex("#E5E7EB"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), @@ -14957,7 +15272,7 @@ public partial class ChatWindow : Window Child = new ScrollViewer { Content = panel, - MaxHeight = 320, + MaxHeight = 340, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, } } @@ -15726,15 +16041,46 @@ public partial class ChatWindow : Window private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active) { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind); var taskStack = new StackPanel(); - taskStack.Children.Add(new TextBlock + var headerRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + }; + headerRow.Children.Add(new TextBlock + { + Text = kindIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = kindColor, + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + headerRow.Children.Add(new TextBlock { Text = active - ? $"진행 중 · {task.Title} · {task.Summary}" - : $"{GetTaskStatusLabel(task.Status)} · {task.Title} · {task.Summary}", - Foreground = active ? Brushes.Black : Brushes.DimGray, + ? $"진행 중 · {task.Title}" + : $"{GetTaskStatusLabel(task.Status)} · {task.Title}", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = active ? primaryText : secondaryText, TextWrapping = TextWrapping.Wrap, }); + taskStack.Children.Add(headerRow); + + if (!string.IsNullOrWhiteSpace(task.Summary)) + { + taskStack.Children.Add(new TextBlock + { + Text = task.Summary, + FontSize = 10.5, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + } var reviewChipRow = BuildReviewSignalChipRow( kind: task.Kind, @@ -15760,6 +16106,32 @@ public partial class ChatWindow : Window }; } + private Button CreateTaskSummaryActionButton( + string label, + string bg, + string border, + string fg, + RoutedEventHandler onClick, + bool trailingMargin = true) + { + var button = new Button + { + Content = label, + FontSize = 10.5, + MinHeight = 28, + Padding = new Thickness(9, 4, 9, 4), + Margin = trailingMargin ? new Thickness(0, 0, 6, 0) : new Thickness(0), + Background = BrushFromHex(bg), + BorderBrush = BrushFromHex(border), + BorderThickness = new Thickness(1), + Foreground = BrushFromHex(fg), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Left, + }; + button.Click += onClick; + return button; + } + private WrapPanel? BuildTaskSummaryActionRow(TaskRunStore.TaskRun task, bool active) { if (string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase)) @@ -15769,31 +16141,21 @@ public partial class ChatWindow : Window Margin = new Thickness(0, 8, 0, 0), }; - var primaryButton = new Button - { - Content = active ? "지금 실행" : "다시 실행", - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 0), - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#3730A3"), - Cursor = Cursors.Hand, - }; - primaryButton.Click += (_, _) => RetryQueueTask(task); + var primaryButton = CreateTaskSummaryActionButton( + active ? "지금 실행" : "다시 실행", + "#EEF2FF", + "#C7D2FE", + "#3730A3", + (_, _) => RetryQueueTask(task)); actions.Children.Add(primaryButton); - var secondaryButton = new Button - { - Content = active ? "큐에서 제거" : "정리", - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#CBD5E1"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#334155"), - Cursor = Cursors.Hand, - }; - secondaryButton.Click += (_, _) => RemoveQueueTask(task); + var secondaryButton = CreateTaskSummaryActionButton( + active ? "큐에서 제거" : "정리", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => RemoveQueueTask(task), + trailingMargin: false); actions.Children.Add(secondaryButton); return actions; } @@ -15807,24 +16169,17 @@ public partial class ChatWindow : Window Button BuildPermissionButton(string label, string bg, string border, string fg, string? mode, bool margin = true) { - var button = new Button - { - Content = label, - Padding = new Thickness(10, 5, 10, 5), - Margin = margin ? new Thickness(0, 0, 6, 0) : new Thickness(0), - Background = BrushFromHex(bg), - BorderBrush = BrushFromHex(border), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex(fg), - Cursor = Cursors.Hand, - }; - button.Click += (_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode); - return button; + return CreateTaskSummaryActionButton( + label, bg, border, fg, + (_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode), + trailingMargin: margin); } - actions.Children.Add(BuildPermissionButton("기본", "#F8FAFC", "#CBD5E1", "#334155", PermissionModeCatalog.Default)); - actions.Children.Add(BuildPermissionButton("적극", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.AcceptEdits)); - actions.Children.Add(BuildPermissionButton("차단", "#FEF2F2", "#FCA5A5", "#991B1B", PermissionModeCatalog.Deny)); + actions.Children.Add(BuildPermissionButton("활용하지 않음", "#FEF2F2", "#FCA5A5", "#991B1B", PermissionModeCatalog.Deny)); + actions.Children.Add(BuildPermissionButton("권한 요청", "#F8FAFC", "#CBD5E1", "#334155", PermissionModeCatalog.Default)); + actions.Children.Add(BuildPermissionButton("편집 자동 승인", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.AcceptEdits)); + actions.Children.Add(BuildPermissionButton("계획 모드", "#EEF2FF", "#C7D2FE", "#3730A3", PermissionModeCatalog.Plan)); + actions.Children.Add(BuildPermissionButton("권한 건너뛰기", "#ECFDF5", "#BBF7D0", "#166534", PermissionModeCatalog.BypassPermissions)); actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false)); return actions; } @@ -15834,12 +16189,38 @@ public partial class ChatWindow : Window private Border BuildHookSummaryCard(AppStateService.HookEventState hook) { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var hookCardStack = new StackPanel(); + hookCardStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uE756", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = "훅 이벤트", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"), + } + } + }); hookCardStack.Children.Add(new TextBlock { Text = _appState.FormatHookEventLine(hook), + FontSize = 10.5, TextWrapping = TextWrapping.Wrap, - Foreground = hook.Success ? Brushes.DimGray : BrushFromHex("#991B1B"), + Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"), }); var hookActionRow = new WrapPanel @@ -15847,43 +16228,33 @@ public partial class ChatWindow : Window Margin = new Thickness(0, 8, 0, 0), }; - var hookFilterButton = new Button - { - Content = "훅만 보기", - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 0), - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#CBD5E1"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#334155"), - Cursor = Cursors.Hand, - }; - hookFilterButton.Click += (_, _) => - { - _taskSummaryTaskFilter = "hook"; - if (_taskSummaryTarget != null) - ShowTaskSummaryPopup(); - }; + var hookFilterButton = CreateTaskSummaryActionButton( + "훅만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => + { + _taskSummaryTaskFilter = "hook"; + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + }); hookActionRow.Children.Add(hookFilterButton); if (!string.IsNullOrWhiteSpace(hook.RunId)) { - var timelineButton = new Button - { - Content = "관련 로그로 이동", - Padding = new Thickness(10, 5, 10, 5), - Background = hook.Success ? BrushFromHex("#EEF2FF") : BrushFromHex("#FEF2F2"), - BorderBrush = hook.Success ? BrushFromHex("#C7D2FE") : BrushFromHex("#FCA5A5"), - BorderThickness = new Thickness(1), - Foreground = hook.Success ? BrushFromHex("#3730A3") : BrushFromHex("#991B1B"), - Cursor = Cursors.Hand, - }; var capturedRunId = hook.RunId; - timelineButton.Click += (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - ScrollToRunInTimeline(capturedRunId); - }; + var timelineButton = CreateTaskSummaryActionButton( + "관련 로그로 이동", + hook.Success ? "#EEF2FF" : "#FEF2F2", + hook.Success ? "#C7D2FE" : "#FCA5A5", + hook.Success ? "#3730A3" : "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + ScrollToRunInTimeline(capturedRunId); + }, + trailingMargin: false); hookActionRow.Children.Add(timelineButton); } @@ -15903,12 +16274,30 @@ public partial class ChatWindow : Window private Border BuildActiveBackgroundSummaryCard(IReadOnlyList activeBackgroundJobs, int activeBackgroundCount) { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var activeBackgroundStack = new StackPanel(); - activeBackgroundStack.Children.Add(new TextBlock + activeBackgroundStack.Children.Add(new StackPanel { - Text = $"실행 중인 백그라운드 작업 {activeBackgroundCount}개", - Foreground = BrushFromHex("#1D4ED8"), - FontWeight = FontWeights.SemiBold, + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE9F9", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = BrushFromHex("#1D4ED8"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"실행 중인 백그라운드 작업 {activeBackgroundCount}개", + FontSize = 11, + Foreground = BrushFromHex("#1D4ED8"), + FontWeight = FontWeights.SemiBold, + } + } }); foreach (var job in activeBackgroundJobs) @@ -15917,7 +16306,8 @@ public partial class ChatWindow : Window { Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 52)}", Margin = new Thickness(0, 4, 0, 0), - Foreground = Brushes.DimGray, + FontSize = 10.5, + Foreground = secondaryText, TextWrapping = TextWrapping.Wrap, }); } @@ -15927,31 +16317,21 @@ public partial class ChatWindow : Window Margin = new Thickness(0, 8, 0, 0), }; - var runningFilterButton = new Button - { - Content = "진행 중 대화만 보기", - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 0), - Background = BrushFromHex("#DBEAFE"), - BorderBrush = BrushFromHex("#93C5FD"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#1D4ED8"), - Cursor = Cursors.Hand, - }; - runningFilterButton.Click += (_, _) => ShowRunningConversationsOnly(); + var runningFilterButton = CreateTaskSummaryActionButton( + "진행 중 대화만 보기", + "#DBEAFE", + "#93C5FD", + "#1D4ED8", + (_, _) => ShowRunningConversationsOnly()); activeActionRow.Children.Add(runningFilterButton); - var subTaskButton = new Button - { - Content = "서브 작업만 보기", - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#CBD5E1"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#334155"), - Cursor = Cursors.Hand, - }; - subTaskButton.Click += (_, _) => ShowSubAgentTasksOnly(); + var subTaskButton = CreateTaskSummaryActionButton( + "서브 작업만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ShowSubAgentTasksOnly(), + trailingMargin: false); activeActionRow.Children.Add(subTaskButton); activeBackgroundStack.Children.Add(activeActionRow); @@ -15969,14 +16349,41 @@ public partial class ChatWindow : Window private Border BuildRecentBackgroundJobCard(BackgroundJobService.BackgroundJobState job) { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var jobCardStack = new StackPanel(); + var isFailed = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase); + jobCardStack.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + Children = + { + new TextBlock + { + Text = "\uE823", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"), + Margin = new Thickness(0, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"), + } + } + }); jobCardStack.Children.Add(new TextBlock { - Text = $"{job.UpdatedAt:HH:mm:ss} · {job.Title} · {GetTaskStatusLabel(job.Status)} · {TruncateForStatus(job.Summary, 72)}", + Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 72)}", + FontSize = 10.5, TextWrapping = TextWrapping.Wrap, - Foreground = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) + Foreground = isFailed ? BrushFromHex("#991B1B") - : Brushes.DimGray, + : secondaryText, }); var jobActionRow = new WrapPanel @@ -15984,55 +16391,39 @@ public partial class ChatWindow : Window Margin = new Thickness(0, 8, 0, 0), }; - var focusButton = new Button - { - Content = "이 대화로 이동", - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 0), - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#CBD5E1"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#334155"), - Cursor = Cursors.Hand, - }; - focusButton.Click += (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - FocusCurrentConversation(); - }; + var focusButton = CreateTaskSummaryActionButton( + "이 대화로 이동", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + FocusCurrentConversation(); + }); jobActionRow.Children.Add(focusButton); - var subagentOnlyButton = new Button - { - Content = "서브 작업만 보기", - Padding = new Thickness(10, 5, 10, 5), - Margin = new Thickness(0, 0, 6, 0), - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#CBD5E1"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#334155"), - Cursor = Cursors.Hand, - }; - subagentOnlyButton.Click += (_, _) => ShowSubAgentTasksOnly(); + var subagentOnlyButton = CreateTaskSummaryActionButton( + "서브 작업만 보기", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ShowSubAgentTasksOnly()); jobActionRow.Children.Add(subagentOnlyButton); if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) { - var retryBackgroundButton = new Button - { - Content = "이 작업 다시 시도", - Padding = new Thickness(10, 5, 10, 5), - Background = BrushFromHex("#FEF2F2"), - BorderBrush = BrushFromHex("#FCA5A5"), - BorderThickness = new Thickness(1), - Foreground = BrushFromHex("#991B1B"), - Cursor = Cursors.Hand, - }; - retryBackgroundButton.Click += (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - RetryLastUserMessageFromConversation(); - }; + var retryBackgroundButton = CreateTaskSummaryActionButton( + "이 작업 다시 시도", + "#FEF2F2", + "#FCA5A5", + "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }, + trailingMargin: false); jobActionRow.Children.Add(retryBackgroundButton); } @@ -16054,100 +16445,149 @@ public partial class ChatWindow : Window }; } + private static (Brush Background, Brush Border, Brush Foreground) GetPermissionModePalette(string? mode) + { + var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode); + return normalized switch + { + var x when string.Equals(x, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#FEF2F2"), BrushFromHex("#FCA5A5"), BrushFromHex("#991B1B")), + var x when string.Equals(x, PermissionModeCatalog.AcceptEdits, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")), + var x when string.Equals(x, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")), + var x when string.Equals(x, PermissionModeCatalog.BypassPermissions, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), + var x when string.Equals(x, PermissionModeCatalog.DontAsk, StringComparison.OrdinalIgnoreCase) + => (BrushFromHex("#ECFDF5"), BrushFromHex("#86EFAC"), BrushFromHex("#166534")), + _ => (BrushFromHex("#F8FAFC"), BrushFromHex("#CBD5E1"), BrushFromHex("#334155")), + }; + } + + private static (string Icon, Brush Color) GetTaskKindVisual(string? kind) + { + var normalized = kind?.Trim().ToLowerInvariant() ?? ""; + return normalized switch + { + "permission" => ("\uE72E", BrushFromHex("#1D4ED8")), + "queue" => ("\uE14C", BrushFromHex("#7C3AED")), + "tool" => ("\uE90F", BrushFromHex("#A16207")), + "hook" => ("\uE756", BrushFromHex("#0F766E")), + "subagent" => ("\uE902", BrushFromHex("#2563EB")), + _ => ("\uE946", BrushFromHex("#475569")), + }; + } + + private static (string Label, Brush Background, Brush Border, Brush Foreground) GetPermissionEventStatusDisplay(string? status) + { + var normalized = status?.Trim().ToLowerInvariant() ?? ""; + return normalized switch + { + "denied" => ("차단", BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")), + "granted" => ("허용", BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), + "approved" => ("승인", BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")), + "auto" => ("자동 허용", BrushFromHex("#FFF7ED"), BrushFromHex("#FED7AA"), BrushFromHex("#9A3412")), + _ => ("확인 대기", BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")), + }; + } + private void AddTaskSummaryPermissionSection(StackPanel panel, ChatConversation? currentConversation) { var permissionSummary = _appState.GetPermissionSummary(currentConversation); + var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(permissionSummary.EffectiveMode); + var (bg, border, fg) = GetPermissionModePalette(normalizedMode); + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + + var content = new StackPanel(); + content.Children.Add(new TextBlock + { + Text = $"현재 권한 · {PermissionModeCatalog.ToDisplayLabel(normalizedMode)}", + FontWeight = FontWeights.SemiBold, + Foreground = fg, + }); + content.Children.Add(new TextBlock + { + Text = permissionSummary.Description, + Margin = new Thickness(0, 3, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + }); + content.Children.Add(new TextBlock + { + Text = $"기본 {PermissionModeCatalog.ToDisplayLabel(permissionSummary.DefaultMode)} · 예외 {permissionSummary.OverrideCount}개", + Margin = new Thickness(0, 2, 0, 0), + Foreground = primaryText, + }); + panel.Children.Add(new Border { - Background = string.Equals(permissionSummary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#FFF7ED") - : string.Equals(permissionSummary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#F3F4F6") - : BrushFromHex("#EEF2FF"), - BorderBrush = string.Equals(permissionSummary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#FDBA74") - : string.Equals(permissionSummary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#D1D5DB") - : BrushFromHex("#C7D2FE"), + Background = bg, + BorderBrush = border, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Padding = new Thickness(10, 8, 10, 8), Margin = new Thickness(8, 0, 8, 8), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = $"현재 권한 모드 · {PermissionModeCatalog.ToDisplayLabel(permissionSummary.EffectiveMode)}", - FontWeight = FontWeights.SemiBold, - Foreground = string.Equals(permissionSummary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#C2410C") - : string.Equals(permissionSummary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#374151") - : BrushFromHex("#4338CA"), - }, - new TextBlock - { - Text = $"{permissionSummary.Description} 기본값 {permissionSummary.DefaultMode} · override {permissionSummary.OverrideCount}개", - Margin = new Thickness(0, 3, 0, 0), - TextWrapping = TextWrapping.Wrap, - Foreground = Brushes.DimGray, - } - } - } + Child = content, }); } private void AddTaskSummaryPermissionHistorySection(StackPanel panel) { - var recentPermissions = _appState.GetRecentPermissionEvents(5); + var recentPermissions = _appState.GetRecentPermissionEvents(4); if (recentPermissions.Count == 0) return; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; panel.Children.Add(new TextBlock { Text = "최근 권한 이력", FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = Brushes.DimGray, + Foreground = secondaryText, Margin = new Thickness(10, 0, 10, 4), }); foreach (var permission in recentPermissions) { + var (statusLabel, statusBg, statusBorder, statusFg) = GetPermissionEventStatusDisplay(permission.Status); + var card = new StackPanel(); + card.Children.Add(new TextBlock + { + Text = $"{permission.Timestamp:HH:mm:ss} · {permission.ToolName}", + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + FontSize = 10.5, + }); + card.Children.Add(new TextBlock + { + Text = statusLabel, + Margin = new Thickness(0, 3, 0, 0), + Foreground = statusFg, + FontWeight = FontWeights.SemiBold, + FontSize = 10.5, + }); + if (!string.IsNullOrWhiteSpace(permission.Summary)) + { + card.Children.Add(new TextBlock + { + Text = TruncateForStatus(permission.Summary, 80), + Margin = new Thickness(0, 2, 0, 0), + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + FontSize = 10, + }); + } + panel.Children.Add(new Border { - Background = string.Equals(permission.Status, "denied", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#FEF2F2") - : string.Equals(permission.Status, "granted", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#ECFDF5") - : BrushFromHex("#FFF7ED"), - BorderBrush = string.Equals(permission.Status, "denied", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#FECACA") - : string.Equals(permission.Status, "granted", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#BBF7D0") - : BrushFromHex("#FED7AA"), + Background = statusBg, + BorderBrush = statusBorder, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 7, 10, 7), Margin = new Thickness(8, 0, 8, 6), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = _appState.FormatPermissionEventLine(permission), - TextWrapping = TextWrapping.Wrap, - Foreground = string.Equals(permission.Status, "denied", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#991B1B") - : string.Equals(permission.Status, "granted", StringComparison.OrdinalIgnoreCase) - ? BrushFromHex("#166534") - : BrushFromHex("#9A3412"), - } - } - } + Child = card, }); } }