Compare commits

...

130 Commits

Author SHA1 Message Date
4c1513a5da AX Agent 공통 팝업과 시각 상호작용 렌더를 분리해 메인 창 구조를 정리한다
Some checks are pending
Release Gate / gate (push) Waiting to run
- ChatWindow.VisualInteractionHelpers.cs를 추가해 메시지 액션 버튼, 선택 스타일, hover 애니메이션, 공통 체크 아이콘 생성을 메인 창 코드 밖으로 이동했다.

- ChatWindow.PopupPresentation.cs를 추가해 공통 테마 팝업 컨테이너, 메뉴 아이템, 구분선, 최근 폴더 컨텍스트 메뉴 구성을 한 곳으로 모았다.

- README와 DEVELOPMENT 문서에 2026-04-06 09:03 (KST) 기준 구조 분리 이력을 반영했다.

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 검증 결과 경고 0 / 오류 0을 확인했다.
2026-04-06 09:02:41 +09:00
ccaa24745e AX Agent 파일 브라우저 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.FileBrowserPresentation.cs를 추가해 파일 탐색기 열기/닫기, 폴더 트리 구성, 파일 헤더·아이콘·크기 표시, 우클릭 메뉴, 디바운스 새로고침 흐름을 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 transcript·runtime orchestration 중심으로 더 정리해 claw-code 기준 sidebar/file surface 품질 개선을 이어가기 쉬운 구조로 개선함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:55 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:56:18 +09:00
1ce6ccb030 AX Agent 프리뷰 패널 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.PreviewPresentation.cs를 추가해 프리뷰 탭 목록, 헤더, 파일 로드, CSV·텍스트·마크다운·HTML 표시, 컨텍스트 메뉴, 별도 창 미리보기 흐름을 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 transcript 및 런타임 orchestration 중심으로 더 정리해 claw-code 기준 preview surface 품질 개선을 이어가기 쉬운 구조로 개선함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:47 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:48:32 +09:00
2b21e8cdfb AX Agent 상태선 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.StatusPresentation.cs로 UpdateStatusBar, StartStatusAnimation, StopStatusAnimation을 이동해 runtime 상태 이벤트와 상태선 표현 책임을 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 transcript 오케스트레이션 중심으로 더 정리했고, claw-code 기준 status/footer 품질 개선을 이어가기 쉬운 구조로 개선함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:39 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:40:56 +09:00
35ec073eb9 AX Agent 메시지 상호작용 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.MessageInteractions.cs를 추가해 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름을 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리해 claw-code 기준 renderer 분리 작업을 이어가기 쉬운 구조로 개선함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:27 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:31:48 +09:00
68524c1c94 AX Agent composer·대기열 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.ComposerQueuePresentation.cs를 추가해 입력창 높이 계산, draft kind 해석, 대기열 요약/카드/배지/액션 버튼 생성 책임을 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 대기열 실행 orchestration과 세션 변경 흐름 중심으로 정리해 claw-code 기준 footer/composer 품질 개선 기반을 강화함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:28 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:24:25 +09:00
b4a506de96 AX Agent 하단 작업바 렌더 구조 분리 및 문서 갱신
- ChatWindow.FooterPresentation.cs를 추가해 폴더 바, 선택 프리셋 안내, Git 브랜치 팝업 렌더를 메인 창 코드에서 분리함

- ChatWindow.xaml.cs는 대화 흐름과 런타임 orchestration 중심으로 정리해 claw-code 기준 footer presentation 개선 기반을 마련함

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:12 (KST) 기준 변경 이력을 반영함

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-06 08:14:01 +09:00
82b42b3ba3 AX Agent 권한·컨텍스트 카드 렌더 구조 분리 및 문서 갱신
권한 선택 팝업과 권한 상태 배너 렌더를 ChatWindow.PermissionPresentation partial로 분리했습니다.

컨텍스트 사용량 카드와 hover 팝업 렌더를 ChatWindow.ContextUsagePresentation partial로 분리해 메인 ChatWindow.xaml.cs의 책임을 줄였습니다.

README와 DEVELOPMENT 문서에 2026-04-06 07:31 (KST) 기준 변경 이력을 반영했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 07:33:21 +09:00
90bd77f945 AX Agent 계획 승인 흐름과 상태선 표현 계층 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 계획 승인 기본 흐름을 transcript inline 우선 구조로 더 정리하고, 계획 버튼은 저장된 계획을 여는 상세 보기 성격으로 분리했습니다.

- OperationalStatusPresentationState를 확장해 runtime badge, compact strip, quick strip의 문구·강조색·노출 여부를 한 번에 계산하도록 통합했습니다.

- ChatWindow 상태선/quick strip/status token 로직을 StatusPresentation partial로 분리해 메인 창 코드의 직접 분기와 렌더 책임을 줄였습니다.

- 문서 이력(README, DEVELOPMENT)을 2026-04-06 01:37 KST 기준으로 갱신했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 07:17:16 +09:00
95e40df354 AX Agent transcript 권한·도구 결과 표현 정교화 및 이벤트 렌더 분리
Some checks failed
Release Gate / gate (push) Has been cancelled
- claw-code 기준으로 transcript display catalog를 파일/문서/빌드/Git/웹/질문/에이전트 축으로 재정의
- 권한 요청 presentation catalog를 명령 실행, 웹 요청, 스킬 실행, 의견 요청, 파일 수정, 파일 접근 타입으로 세분화
- tool result catalog를 성공/실패/거부/취소와 도구 종류에 따라 더 읽기 쉬운 한국어 결과 라벨로 정리
- CreateCompactEventPill, AddAgentEventBanner, GetDecisionBadgeMeta를 ChatWindow.AgentEventRendering.cs로 분리해 메인 창 코드 비중 축소
- README와 DEVELOPMENT 문서에 변경 목적과 검증 결과 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-06 06:59:22 +09:00
f9d18fba08 AX Agent 폴더 데이터 활용 사용자 옵션 제거 및 자동 정책 고정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 코워크/코드의 폴더 내 문서 활용을 사용자 설정에서 제거하고 탭별 자동 정책으로 고정
- ChatWindow 하단 데이터 활용 버튼과 AX Agent 내부 설정 row, 메인 설정/구형 설정창의 관련 UI 제거
- Chat/Cowork/Code 탭별 자동 데이터 활용 정책을 none/passive/active로 고정하고 저장 경로에서 사용자 선택값 반영 제거
- README와 DEVELOPMENT 문서에 변경 이력 및 적용 기준 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 23:06:38 +09:00
f0af86cc1e AX Agent transcript 렌더 구조를 분리하고 권한/도구 결과 표시 체계를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.xaml.cs에 몰려 있던 의견 요청, 계획 승인, 작업 요약 렌더를 partial 파일로 분리해 transcript 책임을 낮췄다.

- PermissionRequestPresentationCatalog와 ToolResultPresentationCatalog를 추가해 권한 요청 및 도구 결과 badge를 타입별로 해석하도록 정리했다.

- AppStateService에 OperationalStatusPresentationState를 추가하고 상태선 계산을 presentation 계층으로 한 번 더 분리했다.

- README.md, docs/DEVELOPMENT.md, docs/claw-code-parity-plan.md에 2026-04-06 00:58 (KST) 기준 변경 내용을 반영했다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 22:55:56 +09:00
13f0e23ed5 AX Agent 테마 프리셋 2종 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 전용 테마를 재점검해 Nord와 Ember 프리셋을 새로 추가했습니다.

내부 설정의 테마 스타일 카드에서 새 프리셋을 바로 선택할 수 있게 연결했고 system, light, dark 모드 조합으로 동일하게 적용되도록 정리했으며 문서 이력과 빌드 검증도 함께 반영했습니다.
2026-04-05 22:41:15 +09:00
7cb27b70f8 내부 설정 훅 섹션 잘림 레이아웃 보정
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 내부 설정의 도구 훅 실행 타임아웃과 등록된 훅 영역에서 컨트롤이 잘리던 레이아웃을 조정했습니다.

슬라이더와 값 배지 컬럼 폭, 훅 추가 버튼 최소 폭과 여백을 넓혀 작은 폭에서도 텍스트와 컨트롤이 안정적으로 보이도록 정리했고 문서 이력과 빌드 검증도 함께 반영했습니다.
2026-04-05 22:37:11 +09:00
61f82bdd10 내부 설정 개발자 토글 저장 경로 복구
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 내부 설정 개발자 탭의 워크플로우 시각화, 전체 호출·토큰 합계 표시, 감사 로그 토글에 변경 이벤트를 연결했습니다.

오버레이 재동기화 시 기본값으로 되돌아가던 문제를 막고 즉시 저장되도록 보정했으며 문서 이력과 빌드 검증도 함께 반영했습니다.
2026-04-05 22:33:39 +09:00
fa349c2057 프리셋 선택 시 새 대화 중복 생성 방지
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 채팅과 코워크에서 메시지와 입력이 없는 fresh conversation에 프리셋만 선택해도 새 대화가 반복 생성되던 흐름을 수정했습니다.

기존 빈 대화가 있으면 해당 대화를 재사용하고 프리셋 메타데이터만 갱신하도록 정리했으며 문서 이력과 빌드 검증도 함께 반영했습니다.
2026-04-05 22:30:53 +09:00
be7328184a 코드 탭 폴더 데이터 활용 기본 허용 및 파일명 강조 적용
Some checks failed
Release Gate / gate (push) Has been cancelled
코드 탭에서는 폴더 내 데이터 활용을 항상 적극 활용으로 고정하고 하단 버튼과 내부 설정 옵션을 숨겨 사용 흐름을 단순화했습니다.

코워크와 코드 탭의 사용자 메시지도 파일 경로 강조 렌더러를 사용하도록 바꿔 폴더 하위 파일명 입력 시 파란색으로 표시되게 맞췄습니다.

README와 DEVELOPMENT 문서에 변경 이력을 반영했고 Release 빌드에서 경고 0 오류 0을 확인했습니다.
2026-04-05 22:28:29 +09:00
905ea41ed3 코드 탭 Git 브랜치 선택 UI 단순화
- 하단 Git 브랜치 버튼을 상태판형에서 브랜치 선택 버튼 형태로 정리\n- 브랜치 버튼 기본 노출에서 변경 파일/추가/삭제 수치를 숨기고 브랜치명 중심으로 단순화\n- README 및 DEVELOPMENT 문서 이력 갱신\n\n검증:\n- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\\n- 경고 0 / 오류 0
2026-04-05 22:21:58 +09:00
d0fa54f10e AX Agent 내부 설정 MCP 서버 관리와 대화 스타일 섹션 복구
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정 공통 탭에 대화 스타일 섹션 제목을 복구해 문서 형태/디자인 스타일 저장 항목을 명확히 노출

- 스킬 탭 MCP 서버 영역에 서버 추가 버튼을 복원하고 목록 카드에서 활성화 전환과 삭제를 바로 처리하도록 보강

- 오버레이 저장 경로를 그대로 사용해 내부 설정에서 변경 즉시 저장되도록 유지

- README와 DEVELOPMENT 문서에 2026-04-06 00:45 (KST) 기준 이력 반영 및 Release 빌드 경고 0 오류 0 확인
2026-04-05 22:09:29 +09:00
1948af3cc4 AX Agent 프리뷰 UI를 claw-code 스타일로 정리하고 프리뷰 surface를 공통화
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 권한 승인 프리뷰에 공통 preview surface helper를 도입해 제목/요약/본문 box 구성을 일관되게 정리함
- 우측 파일 프리뷰 패널에 파일명, 경로, 형식·크기 메타 헤더를 추가하고 텍스트 프리뷰를 bordered preview box 안에 렌더하도록 개선함
- README와 DEVELOPMENT 문서에 2026-04-06 01:08 (KST) 기준 변경 이력 반영 완료
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0 확인
2026-04-05 22:03:16 +09:00
53965083e3 AX Agent 메시지 액션과 응답 메타 표시 개선\n\n- assistant 메시지에 응답시간과 토큰 수를 저장하는 메타 필드 추가\n- 응답 커밋 시 토큰 사용량과 경과 시간을 메시지 모델에 함께 기록하도록 엔진과 전송 경로 보강\n- 사용자/assistant 메시지 액션 바를 기본 저강도 노출과 hover 강조 방식으로 바꿔 복사 편집 재생성 좋아요 싫어요가 보이도록 정리\n- README와 DEVELOPMENT 문서에 메시지 액션 및 응답 메타 개선 이력 반영\n- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 21:54:15 +09:00
ac4aada0af AX Agent 새 대화 세션 복원 오류 수정\n\n- 새 대화 생성 직후 저장되지 않은 fresh conversation을 우선 유지하도록 ChatSessionStateService LoadOrCreateConversation 경로를 보정\n- 기억된 대화 ID가 없는 상태에서 최신 저장 대화를 다시 불러와 기존 transcript가 복원되던 문제 차단\n- README와 DEVELOPMENT 문서에 새 대화 세션 복원 버그 수정 이력 추가\n- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 21:48:22 +09:00
53afdb3472 vLLM 모델 해석 및 max_tokens 상한 보정
Some checks failed
Release Gate / gate (push) Has been cancelled
vLLM 연결 시 등록 모델 alias와 실제 모델 ID가 섞여 payload로 전달되던 경로를 보정해 RegisteredModel에서 실제 모델명을 우선 찾아 요청에 사용하도록 수정했다.

OpenAI-compatible 일반 대화와 도구 호출 모두 vLLM 서버 허용 범위를 넘지 않도록 max_tokens를 자동 보정하도록 통일했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 21:40:43 +09:00
5765888229 AX Agent 상단 탭 여백 비율 재조정 및 문서 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
상단 중앙 채팅/Cowork/코드 탭 그룹의 버튼 패딩, 최소 폭/높이와 바깥 pill 래퍼 높이/패딩을 한 단계 더 줄여 바깥 테두리 안쪽 여백이 살아 있도록 조정했다.

README와 DEVELOPMENT 문서에 2026-04-06 00:31 (KST) 기준 변경 이력을 추가했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 21:35:35 +09:00
2958306caf AX Agent 기본 시작 높이 상향 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 채팅창 기본 Height를 820에서 880으로 상향
- 처음 열었을 때 상하 여백과 프리셋 영역이 더 여유 있게 보이도록 조정
- README 및 DEVELOPMENT 문서에 2026-04-06 00:27 (KST) 기준 이력 반영
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ 경고 0 오류 0 확인
2026-04-05 21:28:40 +09:00
216b050398 claw-code 대비 AX Agent 품질 향상 계획 구체화
Some checks failed
Release Gate / gate (push) Has been cancelled
- claw-code 소스 구조와 AX Agent 구조를 다시 대조해 추가 품질 향상 계획 수립
- transcript renderer 분리, permission presentation catalog, tool result taxonomy, plan approval inline 마감, runtime summary 계층화, regression prompt ritual 고정 계획 문서화
- 런타임 핵심 설정과 개발자 전용 이동 후보 설정을 구분해 정리
- README 및 DEVELOPMENT 문서에 2026-04-06 00:22 (KST) 기준 이력 반영
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ 경고 0 오류 0 확인
2026-04-05 21:26:25 +09:00
5352ca2ab2 AX Agent 상단 탭 여백 및 비율 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 중앙 탭 버튼의 패딩과 최소 크기를 소폭 줄여 과하게 꽉 찬 느낌 완화
- 탭 그룹 외곽 래퍼 높이와 패딩을 함께 줄여 레퍼런스에 가까운 여유 간격 확보
- README 및 DEVELOPMENT 문서에 2026-04-06 00:14 (KST) 기준 변경 이력 반영
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ 경고 0 오류 0 확인
2026-04-05 21:23:52 +09:00
929c1e9f05 AX Agent 등록 모델 리스트 UI 정리 및 액션 안정화
Some checks failed
Release Gate / gate (push) Has been cancelled
- 내부 설정 공통 탭에서 등록 모델 상단 중복 선택 칩 UI 제거
- 등록 모델 관리 영역을 리스트 중심 구조로 정리
- 선택 편집 삭제 액션을 팝업 친화적인 클릭 row 방식으로 변경
- 오버레이 동기화 경로에서 제거된 모델 칩 렌더 호출 삭제
- README 및 DEVELOPMENT 문서에 2026-04-06 00:08 (KST) 기준 이력 반영
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ 경고 0 오류 0 확인
2026-04-05 21:17:13 +09:00
bfa1e342c0 AX Agent 토큰 라벨 닫힘 보강 및 전송 버튼 정렬 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- 토큰 hover popup 닫힘 경로를 창 전체 마우스 이동, 클릭, 비활성화 기준까지 확장해 라벨이 남는 현상 완화
- 컨텍스트 라벨은 카드 밖으로 벗어났을 때 더 빠르게 닫히도록 idle 검사 경로 보강
- 전송 버튼 크기를 키우고 내부 send glyph 크기와 오프셋을 조정해 작고 치우쳐 보이던 인상 보정
- README 및 DEVELOPMENT 문서에 2026-04-06 00:01 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 21:11:39 +09:00
8331c0eedc AX Agent 컨텍스트 토큰 심볼 잘림 및 hover 라벨 닫힘 개선
- 하단 컨텍스트 토큰 카드 외곽 크기와 내부 원형 그리드 여백을 조정해 파이 심볼 왼쪽 잘림 인상 보정
- usage arc 및 threshold marker 계산 중심점을 실제 심볼 크기에 맞게 재설정
- hover popup을 비상호작용 툴팁 형태로 바꿔 카드 밖으로 벗어나면 더 자연스럽게 닫히도록 정리
- README 및 DEVELOPMENT 문서에 2026-04-05 23:55 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 21:04:08 +09:00
cf59a010ac AX Agent 상단 탭 잘림 보정 및 빈 상태 비율 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 중앙 탭의 글자 잘림을 줄이기 위해 탭 버튼 폰트, 패딩, 최소 크기와 탭 래퍼 크기를 소폭 축소
- 빈 상태 상단 아이콘과 제목, 설명 크기를 조정해 프리셋 카드 한 장과의 시각 비율을 더 자연스럽게 정리
- 상단 헤더와 중앙 안내 블록의 밀도를 분리해 탭은 더 단정하게, 빈 상태는 더 또렷하게 보이도록 조정
- README 및 DEVELOPMENT 문서에 2026-04-05 23:49 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 21:01:28 +09:00
1c9b13c14f AX Agent 권한 모드 팝업 단순 리스트형으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 권한 모드 팝업 폭과 padding, 그림자 강도를 줄여 레퍼런스처럼 가벼운 드롭다운 패널 스타일로 정리
- 상세 정보 섹션과 부가 요약 영역을 제거하고 실제 선택 가능한 권한 모드 행만 남기도록 단순화
- 계획 모드는 레거시로 제외하고 권한 요청, 편집 자동 승인, 권한 건너뛰기 중심으로 재배치
- 각 권한 모드 행은 왼쪽 accent bar 대신 라운드 row 리스트형으로 바꾸고 체크 위치와 텍스트 계층 정리
- README 및 DEVELOPMENT 문서에 2026-04-05 23:44 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 20:54:54 +09:00
87c05720ce AX Agent 컨텍스트 토큰 hover 라벨 닫힘 및 정보 표시 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 하단 컨텍스트 토큰 popup 닫힘 판정을 IsMouseOver 중심에서 실제 마우스 좌표 기준으로 보강
- 마우스를 떼었는데도 hover 라벨이 남아 있던 문제를 줄이도록 card/popup 경계 검사 로직 정리
- popup 내용에서 모델명 및 오늘 사용량 줄을 제거하고 현재 사용량과 자동 압축 기준만 남겨 정보 밀도 단순화
- README 및 DEVELOPMENT 문서에 2026-04-05 23:38 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 20:50:51 +09:00
25031d655d AX Agent 프리셋 아이콘과 빈 상태 심볼 비율 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 작업 유형/대화 주제 프리셋 카드 내부 아이콘 크기와 원형 배경 비율을 줄여 과하게 커 보이던 인상을 완화
- 기타 및 프리셋 추가 카드의 심볼 크기도 함께 조정해 프리셋 행 전체 시각 균형 통일
- 빈 상태 상단 중앙 심볼과 제목/설명 크기를 재조정해 프리셋 카드와 같은 밀도로 보이게 정리
- README 및 DEVELOPMENT 문서에 2026-04-05 23:33 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 20:48:21 +09:00
793a301353 AX Agent 작업 폴더 선택 팝업 스타일 정리 및 최근 항목 UI 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- 최근 작업 폴더 팝업 외곽 radius, 그림자, 내부 여백을 조정해 레퍼런스에 더 가까운 카드형 스타일로 보정
- 최근 폴더 항목을 하단 구분선 방식에서 개별 라운드 row 방식으로 변경하고 선택 체크와 텍스트 계층을 정돈
- 다른 폴더 선택 행도 동일한 row 스타일로 통일해 팝업 전체 시각 언어를 일관되게 정리
- README 및 DEVELOPMENT 문서에 2026-04-05 23:28 (KST) 기준 변경 이력 반영

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0 확인
2026-04-05 20:45:30 +09:00
a5790e28fb AX Agent 상단 탭 잘림 및 메시지 transcript 정렬 보정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 헤더 첫 행 높이와 중앙 탭 래퍼 패딩/최소 높이를 늘려 채팅, Cowork, 코드 탭 글자 잘림 수정
- 사용자, assistant, streaming 메시지 컨테이너를 동일 transcript 폭 기준으로 통일
- wrapper는 같은 중심 축을 공유하고 내부 bubble만 좌우 정렬되도록 변경해 메시지 박스 정렬 불일치 완화
- README 및 DEVELOPMENT 문서에 2026-04-05 23:22 (KST) 기준 변경 이력 반영

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0 확인
2026-04-05 20:40:51 +09:00
00d284b725 AX Agent 좌측 패널 드래그 리사이즈 지원 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
- 좌측 사이드바와 본문 사이 경계선에 GridSplitter 추가
- 사이드바가 열려 있을 때만 드래그 가능하도록 splitter 표시 상태 연동
- 사용자가 조절한 폭을 저장해 사이드바를 닫았다 다시 열어도 마지막 너비 유지
- 열기/닫기 애니메이션이 현재 폭과 저장 폭을 함께 사용하도록 정리
- README 및 DEVELOPMENT 문서에 2026-04-05 23:15 (KST) 기준 변경 이력 반영

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0 확인
2026-04-05 20:33:09 +09:00
891133a6bf AX Agent 좌측 패널 글자 크기 및 대화 목록 가독성 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- 좌측 사이드바 폭을 소폭 확장하고 헤더, 새 대화, 검색, 상단 필터, 보조 메뉴, 전체 삭제, 사용자 영역 타이포와 아이콘 크기 상향
- 대화 목록 그룹 헤더, 제목, 날짜, 실행 상태, 실행 요약, 편집 아이콘 크기와 카드 패딩 조정
- 좌측 패널 텍스트가 지나치게 작아 보이던 문제를 전체 시각 균형 기준으로 보정
- README 및 DEVELOPMENT 문서에 2026-04-05 23:09 (KST) 기준 변경 이력 반영

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0 확인
2026-04-05 20:29:57 +09:00
b1e11b27bc AX Agent 새 대화 전환 버그 수정 및 상태 초기화 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 새 대화 시작 시 LoadOrCreateConversation 재호출로 기존 대화가 다시 복원되던 경로 제거
- ClearCurrentConversation 이후 항상 fresh conversation 생성으로 current conversation 전환 고정
- 새 대화 전환 시 대화별 설정, 압축 메트릭, 앱 상태, 프리셋 안내, 조건부 스킬 상태를 새 세션 기준으로 재동기화
- 빈 transcript를 다시 렌더하도록 정리해 첫 화면 깜빡임 후 기존 메시지가 남는 현상 수정
- README 및 DEVELOPMENT 문서에 2026-04-05 23:02 (KST) 기준 변경 이력 반영

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0 확인
2026-04-05 20:26:59 +09:00
f79e657895 AX Agent 메시지 메타 가독성과 끝단 정렬 보정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 사용자와 assistant 메시지 행 여백을 줄여 transcript 좌우 끝단 정렬감을 높였습니다.
- AX 에이전트 라벨, 아이콘, 시간 표기 글꼴 크기와 대비를 키워 메타 정보 가독성을 보강했습니다.
- 관련 변경 이력을 README와 DEVELOPMENT 문서에 반영했습니다.

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 20:22:30 +09:00
1778b855c5 런처 클립보드 붙여넣기 포커스 복원 경로 통일
Some checks failed
Release Gate / gate (push) Has been cancelled
- 클립보드 히스토리, 클립보드 변환, 순차 붙여넣기 실행 경로에 공통 포커스 복원 helper를 추가했습니다.
- 이전 활성 창 복원, 최소 대기, Ctrl+V 주입 순서를 하나로 맞춰 포커스 누락으로 내용이 원래 창에 들어가지 않던 문제를 완화했습니다.
- 관련 변경 이력을 README와 DEVELOPMENT 문서에 반영했습니다.

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 20:19:44 +09:00
db957039d4 AX Agent 프리셋 카드 hover 깜빡임 완화
Some checks failed
Release Gate / gate (push) Has been cancelled
- 채팅과 코워크 프리셋 카드 설명 라벨을 Visibility 토글 대신 Opacity 전환으로 변경
- 카드 hover 중 설명 라벨 표시 때문에 레이아웃이 다시 잡히며 깜빡이던 현상 완화
- README와 DEVELOPMENT 문서에 2026-04-05 22:57 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 20:11:10 +09:00
78905d16c0 AX Agent 토큰 사용 라벨 hover 종료와 스타일 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 하단 컨텍스트 토큰 라벨이 hover 후 남아 있던 문제를 카드와 팝업의 실제 hover 상태 기준으로 닫히도록 수정
- 토큰 카드가 숨겨질 때 popup도 함께 닫히도록 보강
- 토큰 심볼과 popup 스타일을 얇은 테두리와 약한 그림자 중심으로 정리
- README와 DEVELOPMENT 문서에 2026-04-05 22:53 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 20:08:15 +09:00
2975bb39a2 AX Agent 최대 컨텍스트 토큰 프리셋 확장
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정의 최대 컨텍스트 토큰 프리셋에 32K와 128K 중간값 추가
- 현재 MaxContextTokens 값이 중간 구간에 있어도 가장 가까운 프리셋 카드가 자연스럽게 활성화되도록 선택 매핑 확장
- README와 DEVELOPMENT 문서에 2026-04-05 22:48 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 20:05:17 +09:00
5e63f13cf3 AX Agent 내부 설정 중복 실행 옵션 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정의 실행 방식 블록을 코워크/코드 공통 탭에만 노출되도록 조정
- 코워크와 코드 개별 탭에서 중복 노출되던 호출 간격 최적화, 의사결정 수준 항목 제거
- 레거시 실행 전 계획 행을 UI에서 삭제하고 관련 문서 이력 갱신
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 20:02:45 +09:00
1f581454e1 AX Agent 작업 폴더 바 X 버튼 제거 및 정렬 보정
- Cowork/Code 하단 작업 폴더 바에서 불필요한 폴더 해제 X 버튼 제거
- 작업 폴더 바 그리드 컬럼 구조를 다시 맞춰 데이터 활용, 권한, Git 상태가 더 자연스럽게 이어지도록 정렬 보정
- README와 DEVELOPMENT 문서에 2026-04-05 22:39 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 19:59:57 +09:00
cdd99fd4d2 AX Agent 좌측 중복 필터 메뉴 정리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 사이드바에서 주제/작업 유형/워크스페이스 보조 필터를 숨기고 상단 공통 필터만 유지
- 탭 전환 시 UpdateSidebarModeMenu 경로에서도 중복 필터 메뉴가 다시 나타나지 않도록 고정
- README와 DEVELOPMENT 문서에 2026-04-05 22:34 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 19:56:57 +09:00
b44df996c2 AX Agent 선택 주제 안내 위치 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
선택된 대화 주제와 작업 유형 안내 배너를 헤더 중앙에서 입력창 위 중앙으로 옮겼다.

실제 작성 흐름 가까이에서 선택 상태를 확인할 수 있도록 composer 상단 구조에 맞춰 재배치했다.

README와 DEVELOPMENT 문서에 변경 이력과 시점을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:52:42 +09:00
7f8a075553 AX Agent 작업 폴더 팝업 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
작업 폴더 선택 팝업을 검색과 요약 스트립 중심 구조에서 최근 폴더 목록 중심 구조로 재구성했다.

현재 선택 체크가 보이는 최근 폴더 목록과 다른 폴더 선택 액션만 남겨 캡처와 비슷한 빠른 선택 흐름으로 정리했다.

README와 DEVELOPMENT 문서에 변경 이력과 시점을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:49:53 +09:00
eed87db268 AX Agent 모델 선택 표시 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
모델 빠른 설정 팝업 하단의 중복 모델 칩 줄을 제거해 리스트 선택만 남기도록 정리했다.

하단 모델 표시 라벨은 서비스명과 모델명을 함께 나열하던 방식에서 현재 모델명 중심의 간결한 표기로 바꿨다.

README와 DEVELOPMENT 문서에 변경 이력과 시점을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:45:20 +09:00
cfacb903e1 AX Agent 프리셋 아이콘 비율 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
대화 주제와 작업 유형 프리셋 카드 내부 아이콘이 과하게 크게 보이던 문제를 조정했다.

빈 상태 상단 심볼은 더 또렷하게 키우고, 프리셋 카드와 기타/프리셋 추가 카드의 아이콘은 한 단계 줄여 전체 균형을 맞췄다.

README와 DEVELOPMENT 문서에 변경 이력과 시점을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:43:03 +09:00
3198f822f5 AX Agent 상단 탭 복구 및 헤더 세그먼트 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 상단 탭이 비정상적인 타원 형태로 깨져 보이던 문제를 수정했다.

TopTabBtn 스타일을 다시 정리해 채팅/Cowork/코드 pill 세그먼트가 예전 형태로 안정적으로 렌더되도록 복구했다.

헤더 중앙 탭 래퍼의 배경, 코너, 패딩, 최소 높이를 조정해 탭 그룹이 자연스럽게 보이도록 맞췄다.

README와 DEVELOPMENT 문서에 변경 이력과 시점을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:40:11 +09:00
21dc280e57 AX Agent 공통 설정 상단 정리와 패널 토글 아이콘 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정 공통 탭에서 테마 스타일과 테마 모드를 서비스/모델보다 위로 재배치함
- 좌측 사이드바 토글 버튼을 햄버거 아이콘 대신 패널 열기/닫기 의미가 드러나는 아이콘으로 교체함
- 메뉴 버튼과 혼동되지 않도록 상단 헤더 UX를 정리함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:29:23 +09:00
28e88d615f 런처 하단 위젯 기본값 비활성화
Some checks failed
Release Gate / gate (push) Has been cancelled
- 런처 하단 위젯 6종의 신규 기본값을 모두 꺼짐 상태로 변경함
- 성능, 포모도로, 메모, 날씨, 일정, 배터리 위젯이 새 설치와 초기화 시 기본 비노출로 시작하도록 조정함
- 기존 사용자 설정은 유지하고 AppSettings 기본값만 변경함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:25:15 +09:00
36828ba199 AX Agent transcript 품질 향상과 회귀 기준 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 도구/스킬 transcript 표시 카탈로그를 분리해 파일, 빌드, Git, 문서, 질문, 제안, 스킬 분류를 공통 라벨로 통일함
- task summary popup을 active 우선, recent/debug 보조 기준으로 축소하고 transcript policy helper를 partial로 분리함
- AX Agent와 claw-code 비교용 회귀 프롬프트 세트를 별도 문서로 추가하고 관련 개발 문서를 즉시 갱신함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 19:20:05 +09:00
8dc2841da6 AX Agent 도구·스킬 transcript 표현을 claw-code 기준으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 도구/스킬 이벤트 배지를 역할 중심 라벨로 단순화
- raw snake_case 대신 사람이 읽기 쉬운 표시명과 /skill 형식 적용
- 작업 요약 팝업의 권한/배경 작업 관측성 섹션을 debug 수준으로 제한
- parity 문서와 개발 이력 문서에 tool/skill UX 정리 기준 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 19:05:49 +09:00
4cbe60052e AX Agent 질문/의견 요청 흐름을 transcript 우선으로 전환
Some checks failed
Release Gate / gate (push) Has been cancelled
- user_ask 콜백을 별도 팝업 대신 본문 inline 카드 경로로 변경
- 선택지 pill, 직접 입력, 전달/취소 버튼을 timeline 안에서 처리
- 계획 승인과 질문 요청이 같은 transcript-first UX 원칙을 따르도록 정리
- claw-code parity 문서와 개발 이력 문서에 질문/승인 UX 기준을 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 18:59:32 +09:00
6c5b0c5be3 AX Agent 계획 승인 흐름을 transcript 우선으로 전환하고 claw-code 비교 기준을 문서화
- claw-code 대비 canonical prompt set 10종을 parity 문서에 추가해 Chat/Cowork/Code 회귀 검증 기준을 고정함
- AX와 claw-code의 도구/스킬 차이를 문서에 정리해 남은 parity 목표를 명확히 함
- PlanViewerWindow를 즉시 띄우지 않고 inline 승인/수정/취소 버튼을 transcript에 먼저 노출하도록 계획 승인 흐름을 변경함
- PlanViewerWindow는 하단 계획 버튼으로 여는 보조 상세 보기 역할로 축소함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
2026-04-05 18:53:28 +09:00
a3b3522bb7 AX Agent transcript 보조 메타를 마지막으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- StatusElapsed, StatusTokens를 값이 있을 때만 노출되게 바꿔 하단 상태선 빈 공간 제거

- ConversationQuickStrip을 실제 running/spotlight count가 있을 때만 보이게 조정

- README와 DEVELOPMENT 문서에 2026-04-05 21:43 (KST) 기준 최종 100% 마감 판단과 검증 결과 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:21:48 +09:00
3ba7c52980 AX Agent 계획 카드를 compact transcript 형태로 마감
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow planning card를 기본 접힘 상태의 요약형 카드로 전환

- 단계 목록은 펼치기/접기에서만 보이게 해 claw-code식 읽기 중심 transcript 흐름으로 정리

- README와 DEVELOPMENT 문서에 2026-04-05 21:36 (KST) 기준 100% 마감 판단과 검증 결과 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:19:11 +09:00
854f190531 AX Agent 레거시 plan 필드를 최종 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- AppSettings에서 planMode와 enablePlanModeTools JSON 필드를 삭제

- SubAgentTool의 llm.PlanMode 고정 대입 제거로 clean 코드 기준 PlanMode 참조를 0으로 정리

- README와 DEVELOPMENT 문서에 2026-04-05 21:29 (KST) 기준 변경 내역과 엔진 100% 판정 근거 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:16:41 +09:00
a0ce5846e1 AX Agent 구형 설정창의 PlanMode dead UI를 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- AgentSettingsWindow에서 계획 모드와 Plan Mode 도구 UI 및 save/load/event 코드 제거

- clean 파일 기준 검색상 남은 PlanMode 참조를 JSON 호환용 AppSettings와 SubAgentTool 안전 고정 경로로 축소

- README와 DEVELOPMENT 문서에 2026-04-05 21:20 (KST) 기준 변경 내역과 parity 99% 재평가 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:12:31 +09:00
1eb56404c7 AX Agent 설정/오버레이의 PlanMode dead UI를 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- SettingsWindow와 ChatWindow에서 숨김 PlanMode row, hidden toggle, dead handler와 binding 제거

- SettingsViewModel dead PlanMode property/save 경로 정리로 현재 정책과 설정 모델을 일치시킴

- README와 DEVELOPMENT 문서에 2026-04-05 21:12 (KST) 기준 변경 내역과 parity 98% 재평가 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:07:14 +09:00
d6bfca249e AX Agent 상태 모델의 PlanMode 잔재를 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- AppStateService PermissionPolicyState에서 레거시 PlanMode 필드를 제거

- 설정 로드 시 고정 off 값을 상태 모델에 복사하던 경로 정리

- README와 DEVELOPMENT 문서에 2026-04-05 21:03 (KST) 기준 변경 내역과 parity 재평가 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 18:01:10 +09:00
12746cdf11 AX Agent 레거시 plan 도구 registry와 상태 라벨을 추가 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ToolRegistry, SkillService, AgentLoopService에서 enter_plan_mode/exit_plan_mode 등록과 별칭 제거

- AppStateService runtime label을 실행/권한 대기/백그라운드 중심으로 단순화해 queue 메타 기본 노출 축소

- README와 DEVELOPMENT 문서에 2026-04-05 20:56 (KST) 기준 변경 내역과 parity 재평가 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 17:58:33 +09:00
6b645ccbb7 AX Agent 레거시 plan 도구와 상태 배지 노출을 추가 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- code 탭에서 enter_plan_mode/exit_plan_mode 도구를 항상 비활성화해 레거시 설정값이 런타임을 흔들지 않도록 정리

- queue/재시도 대기만 남은 상태에서는 RuntimeActivityBadge와 상단 strip이 기본 노출되지 않게 조정

- idle Cowork/Code 헤더 소음을 줄이도록 ChatWindow 상태 표시 조건을 추가 보정

- README와 DEVELOPMENT 문서에 2026-04-05 20:48 (KST) 기준 변경 내역 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 17:55:01 +09:00
51a398d269 AX Agent 하단 상태/큐 기본 노출 추가 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- draft queue 패널은 실행중·다음·실패 항목이 없으면 기본 화면에서 숨기도록 조정

- 컨텍스트 사용량 hover 팝업을 현재 모델 사용량과 compact 상태 2줄 요약으로 축소

- README와 DEVELOPMENT 이력에 claw-code parity 기준 하단 UX 정리 내용을 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 17:43:03 +09:00
595f8a76af AX Agent 하단 보조 UI 최소 노출 기준 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- draft queue 패널은 기본 상태에서 실행/다음/실패가 없으면 숨기도록 조정

- 컨텍스트 사용량 hover 팝업을 현재 모델 사용량과 compact 상태 2줄 요약으로 축소

- README와 DEVELOPMENT 이력에 claw-code parity 기준 상태를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 17:22:11 +09:00
050271e2a9 AX Agent 대기열 적재 경로 공통화 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
- retry, follow-up, branch follow-up, steering 요청이 모두 EnqueueDraftRequest helper를 타도록 정리

- 현재 대화 갱신과 세션 반영 지점을 queue 생성 helper 한 군데로 모아 이후 정책 변경 시 일관성을 높임

- README와 DEVELOPMENT 이력에 claw-code parity 기준 진행 상황을 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 17:18:56 +09:00
4184d89168 AX Agent 큐 요약 최소화와 플랜도구 기본값 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- PlanMode 및 EnablePlanModeTools를 레거시 호환용 설명과 false 기본값 기준으로 정리

- Cowork/Code compact 대기열 요약에서 실행/다음/실패만 기본 노출하고 보류/완료는 상세 보기에서만 보이도록 조정

- claw-code parity 계획 문서와 README, DEVELOPMENT 이력에 현재 상태와 남은 리뷰 항목을 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 17:15:30 +09:00
303a23130b AX Agent 플랜모드 잔재 제거와 상태바 소음 축소 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정 오버레이에서 Plan Mode 도구 저장/노출 경로를 false 고정으로 정리

- 메인 설정에 남아 있던 플랜 모드 및 Plan Mode 도구 UI를 숨기고 카드 상태를 off 고정으로 정리

- Cowork/Code 상태바가 debug가 아닐 때 ToolCall/SkillCall/Paused/Resumed 이벤트로 과하게 흔들리지 않도록 조정

- claw-code parity 계획 문서와 README, DEVELOPMENT 이력을 현재 정책과 진척율 기준으로 갱신

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 17:11:30 +09:00
78b962bece AX Agent 프리셋 안내와 상단 탭 레이아웃 복구\n\n- Chat/Cowork 프리셋 빈 화면을 Stretch 기반으로 바꿔 카드 하단 잘림을 줄임\n- 선택한 대화 주제/작업 유형을 헤더 중앙 가이드로 다시 표시하도록 복구\n- 상단 탭 pill 그룹과 하단 사용자 영역 설정 버튼 크기를 키워 가독성 보정\n- README와 DEVELOPMENT 문서에 2026-04-05 19:59(KST) 기준 변경 이력 반영\n- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 16:59:42 +09:00
f8e62bde2a AX Agent 계획 모드 잔재를 더 걷어내고 UI hover 안정화\n\n- AgentLoop 실행 경로에서 계획 모드 분기를 비활성 정책 기준으로 단순화해 기본 런타임이 설정값에 흔들리지 않게 정리\n- 내부 설정의 숨김 상태 계획 모드 버튼과 콤보 이벤트 연결 및 관련 dead code 제거\n- 작업유형 카드에서 hover 라벨과 ToolTip 충돌로 발생하던 깜박임을 해결\n- README와 DEVELOPMENT 문서에 parity 진척율, 남은 작업축, 설정 제거 후보를 반영\n\n검증 결과\n- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\\n- 경고 0 / 오류 0
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 16:51:24 +09:00
a315f587bf AX Agent 남은 parity 작업과 설정 정리 기준을 문서화하고 카드 hover 깜박임 수정\n\n- claw-code 대비 AX Agent 핵심 엔진/UI 남은 차이와 현재 추정 진척율을 parity 계획 문서에 기록\n- PlanMode, FreeTierDelaySeconds, MaxAgentIterations, MaxRetryOnError 등 런타임 영향 설정의 제거/개발자 전용 후보를 정리\n- 작업유형 카드에서 custom hover 라벨과 기본 ToolTip이 충돌해 발생하던 깜박임을 제거\n- README와 DEVELOPMENT 문서에 변경 이유와 검증 결과를 즉시 반영\n\n검증 결과\n- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\\n- 경고 0 / 오류 0
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 16:44:35 +09:00
500c8ffb06 AX Agent 좌측 패널과 컨텍스트 사용량 UI를 Codex 기준으로 재정렬\n\n- 좌측 사이드바 폭과 새 대화/검색/필터/탭 메뉴 타이포를 키워 레퍼런스와 더 비슷한 밀도로 조정\n- Cowork 작업 유형 카드 크기와 제목/hover 설명 라벨 폰트를 확대해 가독성 보정\n- 하단 컨텍스트 사용량 카드를 작은 원형 심볼로 축소하고 hover 전용 커스텀 팝업으로 상세 정보 분리\n- README와 DEVELOPMENT 문서에 변경 이력 및 검증 결과 즉시 반영\n\n검증 결과\n- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\\n- 경고 0 / 오류 0
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 16:39:32 +09:00
b53af39358 AX Agent 상태 노출과 계획 표시를 claw-code 기준으로 정리
- 대화 상단 빠른 스트립을 자동 카운트 노출 대신 사용자 필터와 정렬 전환 시에만 보이도록 조정
- 상단 상태 스트립은 권한 대기와 실패 계열만 남기고 queue 관련 기본 배너를 제거
- planning 이벤트는 기본 transcript에서 큰 카드 대신 compact pill만 표시되도록 변경
- README와 DEVELOPMENT 문서에 claw-code 비교 기준 정리 이력 및 검증 결과 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 16:32:40 +09:00
0fd6940975 AX Agent 상단 탭 스타일을 사용자 레퍼런스 형태로 복구
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 탭 버튼의 폰트, 패딩, 외곽선을 다시 키워 도톰한 pill 세그먼트 형태로 조정
- 탭 래퍼 배경과 선택 탭 배경을 LauncherBackground 기준으로 정리하고 채팅/코드 라벨을 한글로 복구
- README와 DEVELOPMENT 문서에 상단 탭 UI 복구 이력 및 검증 결과 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-05 16:28:44 +09:00
29652c3ad4 AX Agent 내부 설정 서비스 전환과 테마 노출 정리\n\n- 공통 탭의 서비스 상세 패널을 주소 입력과 API 키 패널로 분리하고 Gemini/Claude에서는 주소 입력을 숨기도록 조정\n- 서비스 변경 직후 현재 서비스, 현재 모델, 라벨이 즉시 갱신되도록 내부 설정 새로고침 경로 보강\n- 테마 스타일과 테마 모드를 서비스/모델 바로 아래로 이동해 공통 탭에서 바로 보이게 재배치\n- README와 DEVELOPMENT 문서에 내부 설정 공통 탭 수정 이력 및 검증 결과 반영\n\n검증 결과\n- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\\n- 경고 0 / 오류 0
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-05 16:26:15 +09:00
c3e1422b02 AX Agent 작업 유형 카드 정리 및 hover 설명 라벨 적용
Some checks failed
Release Gate / gate (push) Has been cancelled
- 작업 유형 카드 하단 상시 설명을 제거하고 모든 카드 크기를 동일 규격으로 통일함
- 카드 hover 시 확대 애니메이션을 제거하고 하단 설명 라벨과 배경/테두리 반응만 남겨 안정적으로 정리함
- 기타/프리셋 추가 카드도 같은 hover 규칙과 설명 노출 방식으로 통일함
- EmptyState 제목/설명 폰트를 키워 현재 화면 전반의 글자 크기를 보정함
- README 및 DEVELOPMENT 문서에 2026-04-05 19:02 (KST) 기준 변경 이력을 반영함

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-05 16:20:55 +09:00
a4d21ecc0b AX Agent 상단 탭 복구 및 하단 컴포저 겹침 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 Chat/Cowork/Code 탭의 폰트와 패딩을 키워 예전처럼 더 읽기 쉬운 pill 형태로 복구함
- 하단 컴포저 상단 줄에서 토큰 카드와 프리셋 버튼이 같은 Grid 컬럼을 공유하던 구조를 분리해 겹침을 제거함
- 모델 선택, 토큰 사용 카드, 프리셋 버튼의 패딩과 글자 크기를 다시 키워 하단 정보 가독성을 복구함
- README 및 DEVELOPMENT 문서에 2026-04-05 18:55 (KST) 기준 변경 이력을 반영함

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-05 16:17:57 +09:00
51ff046e1a AX Agent 타임라인 기본 노출 축소 및 계획/작업 카드 경량화
Some checks failed
Release Gate / gate (push) Has been cancelled
- non-debug 기본 로그에서 ToolCall 중간 이벤트를 숨겨 Cowork/Code 타임라인을 결과 중심으로 재정리함
- 계획 카드와 작업 요약 카드, 액션 버튼의 패딩/폰트/폭을 낮춰 transcript 중심 흐름을 강화함
- 권한 작업 카드에서 계획 모드 버튼을 제거해 현재 엔진 정책과 UI를 일치시킴
- README 및 DEVELOPMENT 문서에 2026-04-05 18:49 (KST) 기준 변경 이력과 parity 진척율을 반영함

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-05 16:15:31 +09:00
abd6928e4a AX Agent 상태 UI 최소 노출 기준 정리 및 작업 요약 팝업 경량화
Some checks failed
Release Gate / gate (push) Has been cancelled
- Cowork/Code 보조 상태 스트립과 실행 로그 토글 영역의 패딩/폰트/간격을 추가 축소해 transcript 중심 흐름을 강화함
- 작업 요약 팝업에서 필터 칩과 과한 대시보드형 액션을 제거하고 마지막 실행 1건 중심 요약으로 재구성함
- 훅/백그라운드/최근 작업 카드의 밀도를 낮추고 기본 팝업에서 권한/백그라운드 현재 상태만 남기도록 최소 노출 기준으로 정리함
- README 및 DEVELOPMENT 문서에 2026-04-05 18:40 (KST) 기준 변경 이력과 parity 진척율을 반영함

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-05 16:10:51 +09:00
aa3de8a6fd AX Agent 메인 UI를 claw-code 축으로 전면 재배치
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 메인 레이아웃을 기존 카드형 AX 틀에서 벗어나 사이드바·본문·컴포저 중심의 더 평평한 claw-code 스타일로 재정렬

- 창 기본 크기, 사이드바 폭, 상단 탭 헤더, 검색 바, 빈 상태, 컴포저 외곽선과 입력축을 다시 조정해 메시지 축과 입력축이 같은 중심선을 공유하도록 변경

- 메시지 버블, compact pill, 이전 대화 로드 카드, planning 카드의 패딩·코너·메타 밀도를 낮춰 transcript 우선 시각 언어로 통일

- README와 docs/DEVELOPMENT.md에 2026-04-05 18:30 (KST) 기준 작업 이력 및 claw-code 대비 진척율 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 16:03:26 +09:00
ed1b8497c6 AX Agent 재생성 흐름과 엔진 마감 문구를 상태 기반으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- regenerate/retry가 MessagePanel 직접 삭제 대신 conversation state 수정 후 RenderMessages 경로를 타도록 정리

- AxAgentExecutionEngine에 UI용 마감 helper를 추가해 취소/오류/빈 응답과 Cowork·Code 완료 문구를 정상 한국어 기준으로 정규화

- Paused/Resumed 실행 이벤트는 debug가 아닐 때 기본 타임라인에서 숨겨 Cowork/Code 노이즈를 축소

- README와 docs/DEVELOPMENT.md에 2026-04-05 18:20 (KST) 기준 작업 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:55:28 +09:00
57be80af3c AX Agent 실행 마감 helper 통합과 계획 노출 최소화
Some checks failed
Release Gate / gate (push) Has been cancelled
- send와 regenerate가 같은 실행/예외/취소/최종 커밋/후처리 helper를 타도록 ExecutePreparedTurnAsync 추가

- 계획 이벤트는 기본적으로 얇은 요약 pill만 노출하고 debug에서만 큰 카드가 보이도록 조정

- README와 DEVELOPMENT 문서에 2026-04-05 18:08 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:43:44 +09:00
e4fddca53c AX Agent 계획 카드와 타임라인 카드 밀도 추가 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- 계획 카드의 라운드, 패딩, 헤더, 진행률 텍스트, 단계 행 폰트를 더 줄여 claw-code 쪽 카드 밀도로 정리

- 컨텍스트 압축 pill과 이전 대화 로드 카드도 함께 축소해 본문 대비 존재감을 낮춤

- README와 DEVELOPMENT 문서에 2026-04-05 18:01 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:38:25 +09:00
3ea497f10a AX Agent 사이드바와 대화 목록 밀도 대폭 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 좌측 헤더, 액션, 검색 편집기, 필터, 탭별 메뉴, 삭제/사용자 영역을 전반적으로 축소해 claw-code 쪽 비율로 정리

- 사이드바 폭을 248로 줄이고 대화 목록 카드 패딩, 메타, 편집 버튼, 선택 액센트 바 두께를 함께 축소

- README와 DEVELOPMENT 문서에 2026-04-05 17:53 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:34:35 +09:00
825f7d55f2 AX Agent 상단 헤더 밀도 추가 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 탭 버튼과 탭 그룹 래퍼의 폰트, 패딩, 코너를 더 줄여 claw-code 쪽 밀도로 정리

- 제목 서브 바, 대화 제목, 빠른 스트립, 프리뷰 토글 규격을 함께 낮춰 상단 보조 정보 존재감을 축소

- README와 DEVELOPMENT 문서에 2026-04-05 17:45 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:31:25 +09:00
abd33eb5df AX Agent 실행 타임라인 배너 밀도 추가 축소
- 일반 실행 배너의 아이콘, 라벨, 경과 시간, 토큰 pill, 요약, 파일 경로 표시를 더 얇게 정리

- review 신호 칩은 debug 로그에서만 보이게 제한해 평소 Cowork/Code 본문 흐름을 단순화

- README와 DEVELOPMENT 문서에 2026-04-05 17:39 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:28:33 +09:00
cd1db562b1 AX Agent 메시지 메타와 완료 카드 문구 추가 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 메시지 버블 패딩, 코너, 폰트, 타임스탬프와 assistant 헤더 메타를 더 축소해 본문 중심 흐름 강화

- 작업 요약 팝업의 완료 카드 라벨과 run/step 메타를 더 짧고 가볍게 정리

- README와 DEVELOPMENT 문서에 2026-04-05 17:33 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 15:01:05 +09:00
d575139a6f AX Agent 상태 스트립과 작업 요약 팝업 밀도 추가 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- Cowork/Code 보조 상태 스트립의 폰트, 패딩, 간격을 더 줄여 본문 우선 시각 흐름에 맞춤

- 작업 요약 팝업 헤더/필터/최근 실행/훅/백그라운드 카드와 액션 버튼 규격을 축소

- README와 DEVELOPMENT 문서에 2026-04-05 17:27 (KST) 기준 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:52:21 +09:00
3b223dc7dc AX Agent 상단 헤더와 대화 목록 메타를 claw-code 쪽으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 상단 탭 그룹과 사이드바 토글 버튼을 더 얇은 세그먼트형 UI로 조정

- 좌측 대화 목록의 실행 메타와 편집 아이콘을 더 약하게 줄여 제목 중심의 목록 밀도로 재정렬

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:46:31 +09:00
f3c0366ee6 AX Agent 메시지 액션과 실행 배너를 claw-code 쪽으로 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- 메시지 액션 바를 텍스트+아이콘 조합에서 작은 아이콘 버튼 중심으로 바꿔 본문 집중도를 높임

- Cowork/Code 실행 배너의 여백, 메타 텍스트, 토큰 배지, 파일 경로 표시를 더 얇게 줄여 보조 정보 밀도를 낮춤

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:43:59 +09:00
7cc2b5b9b5 AX Agent UI를 claw-code 방향으로 골격 재정렬
Some checks failed
Release Gate / gate (push) Has been cancelled
- 좌측 패널, 축소 아이콘 바, 상태 스트립, 메시지 축, 컴포저를 더 얇고 평평한 업무형 레이아웃으로 조정

- 메시지 최대 폭 880, 컴포저 최대 폭 820 기준으로 반응형 폭 계산을 다시 맞춰 창 크기 변화에도 중심선이 자연스럽게 유지되도록 수정

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:40:44 +09:00
d99b46e3e2 AX Agent claw-code 진척율 문서화 및 메시지 축 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
- claw-code 대비 AX Agent 진척율을 README, DEVELOPMENT, parity 계획 문서에 수치로 기록

- 핵심 엔진 영향 설정 최소화 원칙을 정리하고 계획 모드 잔재 노출을 추가로 축소

- ChatWindow 메시지 버블과 좌측 대화 목록 카드를 더 얇은 밀도로 조정해 claw-code식 읽기 축에 가깝게 정리

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:38:03 +09:00
22eebc13d9 AX Agent UI와 응답 마감 엔진을 claw-code 기준으로 1차 재구성
Some checks failed
Release Gate / gate (push) Has been cancelled
사이드바, 메시지 축, 빈 상태, 컴포저를 더 얇고 밀도 높은 구조로 재배치하고 보조 상태 스트립 노출을 줄였습니다.

반응형 폭 계산을 다시 조정해 창 크기 변화에서도 메시지와 컴포저가 같은 중심선으로 자연스럽게 줄어들게 했습니다.

응답 취소와 오류 마감은 AxAgentExecutionEngine의 FinalizeExecutionContent로 공통화해 전송과 재생성의 후처리 경로를 맞췄습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 오류 0
2026-04-05 14:19:07 +09:00
f53f35bbed 계획 모드 설정 제거와 플랜 승인 UX 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
메인 설정과 AX Agent 내부 설정에서 계획 모드 UI를 숨기고 저장값을 항상 off로 고정했습니다.

AgentLoop 런타임도 계획 모드를 off로 고정해 코워크와 코드에서 자동 계획 승인 팝업이 반복 노출되지 않도록 정리했습니다.

PlanViewerWindow는 AX Agent 창 owner 리소스를 직접 받아 같은 테마 축을 따르도록 바꾸고 인라인 승인 버튼 중복 노출을 제거했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 오류 0
2026-04-05 14:12:22 +09:00
9fafcd0192 코워크 문서 계획/생성 흐름을 claw-code 기준으로 복구
- document_plan 결과에서 body 골격과 후속 생성 도구를 안정적으로 추출하도록 AgentLoop 분기를 수정

- 코워크 문서형 작업은 planMode=off여도 계획 선행(always) 경로를 타도록 보정

- 코워크 시스템 프롬프트를 강화해 계획만 제시하고 끝나지 않고 실제 문서 파일 생성까지 이어지게 조정

- README와 DEVELOPMENT 문서에 2026-04-05 16:02 (KST) 기준 변경 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-05 14:05:18 +09:00
35fbfc933d AX Agent: 실행 이벤트 세션 변이 경로 엔진으로 통합
- ChatWindow에 중복돼 있던 실행 이벤트/Agent run 교차 탭 복원 로직을 AxAgentExecutionEngine helper로 이동함

- AppendExecutionEvent, AppendAgentRun이 공통 session mutation 경로를 사용하도록 정리해 이후 runtime state 공통화 기반을 마련함

- README와 DEVELOPMENT 문서에 2026-04-05 15:42 (KST) 기준 변경 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 13:58:16 +09:00
5c142e1235 문서: claw-code 기준 AX Agent 개선 계획 재정립
Some checks failed
Release Gate / gate (push) Has been cancelled
- claw-code 실제 런타임 참조 축(state/initReplBridge/sessionRunner/REPL/Messages/StatusLine)을 기준으로 AX Agent 개선 계획을 재수립함

- README, DEVELOPMENT, parity plan 문서에 참조 파일, AX 적용 위치, 완료 조건, 품질 기준, 검증 시나리오를 반영함

- 이번 변경은 계획 문서 정리만 포함하며 코드 수정이나 빌드 검증은 수행하지 않음
2026-04-05 13:53:53 +09:00
5fd69d32f5 AX Commander 하단 위젯 설정 분리와 서버 상태 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- 일반 설정의 AX Commander 섹션에 성능, 포모도로, 메모, 날씨, 일정, 배터리 위젯 표시 토글을 추가

- 런처 하단의 Ollama, API, MCP 서버 상태 위젯을 완전히 제거하고 남은 위젯만 설정값 기준으로 표시되도록 정리

- 배터리 위젯은 실제 배터리 가용 상태와 사용자 토글을 함께 반영하고 위젯이 모두 꺼지면 하단 바 전체를 숨기도록 조정

- README와 DEVELOPMENT 문서를 2026-04-05 15:16 (KST) 기준으로 갱신하고 dotnet build 검증에서 경고 0 오류 0 확인
2026-04-05 13:48:00 +09:00
d368ebf822 AX Agent 내부 설정 탭 분리와 테마 복구
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정에서 스킬/차단 탭을 도구, 스킬, 차단으로 분리하고 각 패널을 기능별로 재배치

- 공통 탭에 테마 스타일과 테마 모드를 실제 선택 카드 UI로 복구하고 기존 숨김 플레이스홀더를 제거

- 메인 설정 좌측의 AX Agent 이동 항목을 맨 아래로 재배치하고 README 및 DEVELOPMENT 문서 이력을 2026-04-05 15:06 (KST) 기준으로 갱신

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0 오류 0
2026-04-05 13:39:44 +09:00
d102e17d47 AX Agent 대화 목록 카드와 축소 아이콘 바 UI 밀도 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 대화 목록 행 카드의 선택/호버 강조를 더 얇게 줄이고 제목, 메타, 배지, 편집 버튼 밀도를 claw-code 기준으로 정리

- 축소 아이콘 바의 행 높이, 버튼 규격, 아이콘 크기, 사용자 배지를 현재 사이드바와 같은 중립형 시각 언어로 통일

- README와 DEVELOPMENT 문서에 변경 이력과 2026-04-05 14:57 (KST) 기준 업데이트 시각 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0 오류 0
2026-04-05 13:35:16 +09:00
792dea2dc2 AX Agent 실행 로그 배너의 파일 경로와 디버그 노출 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- AddAgentEventBanner에서 debug용 ToolInput 카드 길이와 패딩을 줄이고 일반 로그에서는 파일 경로를 카드형이 아닌 파일명 한 줄로만 표시하도록 분기함

- 파일 빠른 액션과 상세 경로 카드는 debug에서만 크게 보이게 바꿔 Cowork/Code 본문 아래 실행 로그가 메시지 축을 덜 밀어내도록 조정함

- README와 DEVELOPMENT 문서에 2026-04-05 14:49 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:30:42 +09:00
cec4b75999 AX Agent 좌측 패널과 하단 상태바 형태 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 사이드바 폭을 줄이고 헤더 배지, 새 대화/검색/카테고리 메뉴 행의 패딩과 아이콘 크기를 낮춰 claw-code 방향의 얇은 내비게이션으로 조정함

- 아이콘 바 사용자 배지, 하단 계정 영역, 삭제 버튼, 상태바 높이와 상태 아이콘 형태를 함께 단순화해 전체 시각 언어를 더 중립적으로 통일함

- README와 DEVELOPMENT 문서에 2026-04-05 14:43 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:27:58 +09:00
2d7ede357e AX Agent 작업 요약 팝업 카드 밀도 추가 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- 작업 요약 액션 버튼 규격을 더 작게 낮추고 권한/훅/백그라운드 카드 패딩과 마진을 줄여 상태 패널 세로 길이를 완화함

- 최근 권한, 훅, 백그라운드 작업 표시 개수를 줄여 Cowork/Code 상태 요약이 긴 대시보드처럼 커지지 않도록 조정함

- README와 DEVELOPMENT 문서에 2026-04-05 14:36 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:24:51 +09:00
88a21ead92 AX Agent 상태 스트립과 작업 요약 UI 축소
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow의 ConversationStatusStrip, AgentProgressBar, RuntimeActivityBadge, 실행 로그 관련 상태 요소 패딩과 폰트 밀도를 낮춰 본문 우선 구조로 조정함

- 작업 요약 팝업의 제목/설명/최근 실행 카드 밀도를 줄이고 최근 실행 표시 수를 축소해 상태 패널이 보조 레이어로 남도록 정리함

- README와 DEVELOPMENT 문서에 2026-04-05 14:31 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:22:19 +09:00
458fd8da96 AX Agent 컴포저 상단 구조 밀도 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 입력 바의 InputBorder, DraftPreviewCard, DraftQueuePanel 패딩과 그림자를 줄여 claw-code 방향의 얇은 작업 바 인상으로 정리함

- 모델 선택, 토큰 사용 카드, 프리셋 버튼의 높이·패딩·폰트·아이콘 크기를 함께 낮춰 옵션 바가 메시지 축보다 튀지 않도록 조정함

- README와 DEVELOPMENT 문서에 2026-04-05 14:23 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:18:18 +09:00
b24afba2d8 AX Agent 채팅창 반응형 폭 계산 적용
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow의 ComposerShell 고정폭을 제거하고 실제 본문 영역 폭 기준으로 MessagePanel, EmptyState, ComposerShell을 함께 재계산하도록 정리함

- 창 크기 변경 시 메시지 축과 입력창이 자연스럽게 함께 줄고 늘어나도록 Loaded와 SizeChanged에 반응형 레이아웃 갱신을 연결함

- README와 DEVELOPMENT 문서에 2026-04-05 14:16 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:13:15 +09:00
382c78e32f AX Agent 메시지 축 UI 밀도 정리 및 로그 표시 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 메시지 카드의 여백, 패딩, 라운드, 타임스탬프 크기를 줄여 claw-code 기준의 본문 중심 읽기 흐름으로 정리함

- assistant 헤더와 액션 바, 실행 로그 배너의 아이콘/텍스트/배지 밀도를 낮춰 Cowork/Code에서 로그가 메시지보다 튀지 않도록 조정함

- README와 DEVELOPMENT 문서에 2026-04-05 14:09 (KST) 기준 작업 이력을 반영하고 Release 빌드 경고 0 오류 0을 확인함
2026-04-05 13:10:14 +09:00
28869caa32 AX Agent 채팅 UI를 claw-code 방향으로 1차 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 메시지 컬럼과 빈 상태 폭을 880 기준으로 정리하고 상단 진행률 바 패딩과 폭을 축소

- 빈 상태의 부유 애니메이션 아이콘을 제거하고 정적인 카드형 아이콘과 간결한 문구로 단순화

- 컴포저를 800px 축으로 넓히고 DraftPreview/Input 테두리의 라운드와 그림자 강도를 낮춰 메시지와 입력 축이 먼저 보이게 조정

- README와 DEVELOPMENT 문서에 2026-04-05 14:00 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 13:02:22 +09:00
bd8a1ef7bd AX Agent 구형 본문 재시도 카드를 제거해 메시지 축을 더 단순화
- ChatWindow의 AddRetryButton 경로를 제거하고 실패 시 토스트 안내 후 작업 요약 재시도 액션을 사용하도록 정리

- 본문 MessagePanel에 임시 실패 카드를 직접 삽입하던 구형 흐름을 걷어내고 claw-code 방향의 메시지/상태 중심 구조로 추가 정리

- README와 DEVELOPMENT 문서에 2026-04-05 13:52 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:59:22 +09:00
554b1fb83e AX Agent 실패 재시도도 입력창 UI에서 분리해 Cowork·Code 실행 축을 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- RetryLastUserMessageFromConversation이 입력창 텍스트와 포커스를 직접 바꾸지 않고 유휴 시 즉시 재실행, 진행 중에는 직접 대기열 적재로 전환

- 재시도 흐름을 입력 UI보다 세션/대기열 중심으로 옮겨 Cowork·Code 후처리 일관성을 강화

- README와 DEVELOPMENT 문서에 2026-04-05 13:44 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:56:29 +09:00
c5dfab8081 AX Agent 대기열 후속 실행을 입력창 UI에서 분리해 Cowork·Code 엔진 축을 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
- SendMessageAsync가 직접 텍스트 인자를 받을 수 있게 바꾸고 대기열 후속 실행이 InputBox 상태를 바꾸지 않도록 조정

- StartNextQueuedDraftIfAny에서 입력창 텍스트/포커스/높이 조작을 제거하고 SendMessageAsync(next.Text) 직접 실행 경로로 전환

- README와 DEVELOPMENT 문서에 2026-04-05 13:37 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:54:19 +09:00
52475b6628 AX Agent 실행 이벤트 UI 갱신을 배치형으로 조정해 Cowork·Code 흔들림을 줄임
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에 agent UI event timer를 추가해 상태바, 진행률, 플랜 뷰어, 자동 프리뷰를 최근 이벤트 기준으로 묶어서 반영

- OnAgentEvent는 실행 상태를 먼저 conversation/app state에 반영하고 화면 갱신은 FlushPendingAgentUiEvent 경로로 분리

- README와 DEVELOPMENT 문서에 2026-04-05 13:29 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:51:54 +09:00
0b1bc5f32f AX Agent 완료 직후 저장 경로를 정리해 Cowork·Code 실행 기록 누락을 방지
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에 PersistConversationSnapshot을 추가해 중간 저장, 최종 저장, 지연 저장 flush를 한 경로로 통합

- ResetStreamingUiState에서 대기 중인 실행 이벤트 저장을 먼저 flush 하도록 바꿔 마지막 이벤트 누락 가능성을 차단

- RunAgentLoopAsync 내부의 중복 저장/RememberConversation 호출을 제거하고 FinalizeConversationTurn 단일 완료 경로로 정리

- README와 DEVELOPMENT 문서에 2026-04-05 13:20 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:48:13 +09:00
f18f48789a AX Agent 실행 보조 UI를 축약형으로 안정화하고 메시지 축 흔들림을 줄임
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow의 suggest_actions 결과를 본문 MessagePanel 직접 삽입 대신 토스트 요약으로 전환

- DraftQueuePanel을 기본 축약형으로 바꾸고 상세 보기 토글을 추가해 컴포저 위 레이아웃 변동을 줄임

- README와 DEVELOPMENT 문서에 2026-04-05 13:12 (KST) 기준 이력과 검증 결과를 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-05 12:45:01 +09:00
abc355c451 AX Agent 실행 이벤트 저장을 배치형으로 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- execution event와 agent run 기록이 들어올 때마다 즉시 저장하지 않고 conversationPersistTimer로 220ms 단위 지연 저장하도록 변경함

- Cowork/Code 연속 이벤트 구간의 디스크 I/O를 줄여 체감 끊김과 잦은 저장 부하를 낮추는 방향으로 정리함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:39:54 +09:00
d24596a8ea AX Agent 상태 스트립 갱신도 배치형으로 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- task summary 전용 타이머를 추가해 RuntimeActivityBadge와 ConversationStatusStrip 갱신을 120ms 단위로 묶음

- 실행 이벤트 본문 재렌더 배치와 함께 Cowork/Code 실행 중 UI 깜빡임을 줄이는 방향으로 정리함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:37:29 +09:00
a40cacea4d AX Agent 실행 이벤트 재렌더를 배치형으로 조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- OnAgentEvent가 실행 이벤트마다 본문 전체를 즉시 다시 그리지 않도록 execution history 전용 배치 렌더 타이머를 추가함

- Cowork/Code 실행 중 RenderMessages 호출을 120ms 단위로 묶어 본문 번쩍임과 잦은 로그 재배치를 줄임

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:34:53 +09:00
8921f5da0f AX Agent 실행 후처리 경로를 공통화
Some checks failed
Release Gate / gate (push) Has been cancelled
- ResetStreamingUiState, FinalizeConversationTurn, FinalizeQueuedDraft를 추가해 전송과 재생성의 완료 후 UI/상태 정리를 같은 경로로 통합함

- 대화 저장, 목록 갱신, 대기열 완료/실패 반영, 다음 작업 시작 흐름을 helper 기준으로 다시 묶음

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:32:12 +09:00
31a8b979c7 AX Agent 실행 선택 분기를 엔진으로 이관
Some checks failed
Release Gate / gate (push) Has been cancelled
- AxAgentExecutionEngine에 ExecutePreparedAsync를 추가해 AgentLoop와 일반 LLM 호출 선택을 엔진이 담당하도록 정리함

- SendMessageAsync와 SendRegenerateAsync가 공통 실행 진입점을 사용하도록 바꿔 창 코드의 중복 분기를 줄임

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:29:13 +09:00
660f4e5a32 AX Agent 최종 응답 커밋 흐름을 엔진으로 통합
Some checks failed
Release Gate / gate (push) Has been cancelled
- AxAgentExecutionEngine에 FinalizeAssistantTurn을 추가해 assistant 최종 내용 정규화, Cowork/Code 실행 로그 접힘, 메시지 커밋을 한 메서드로 통합함

- SendMessageAsync와 SendRegenerateAsync가 동일한 엔진 마무리 경로를 타도록 정리해 UI 쪽 중복 후처리를 줄임

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:26:51 +09:00
67961f280f AX Agent 실행 준비 로직을 엔진으로 추가 이관
Some checks failed
Release Gate / gate (push) Has been cancelled
- AxAgentExecutionEngine에 PreparedExecution, PrepareExecution, NormalizeAssistantContent를 추가해 실행 준비와 최종 응답 보정 책임을 더 모음

- ChatWindow의 일반 전송과 재생성이 PrepareExecutionForConversation을 통해 같은 준비 경로를 쓰도록 정리함

- Cowork/Code 시스템 프롬프트 선택, 실행 모드 판정, 프롬프트 스택 조합, outbound message 준비의 중복 분기를 줄임

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:23:09 +09:00
3ed454a98c AX Agent 채팅 UI 백업 및 claw-code 기준 엔진 재정렬
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow 현재 UI와 엔진 기준본을 etc/chat-ui-backup/2026-04-05-1215에 백업해 회귀 비교 지점을 확보함

- 메시지 컬럼과 컴포저 폭을 claw-code식 단일 축으로 다시 맞추고 입력 셸을 안정적인 하단 컬럼 구조로 정리함

- 입력창 높이를 실제 줄바꿈 수 기준으로 다시 계산해 전송 후 높이가 남는 버그를 줄임

- 메시지 편집/피드백 후 재생성 경로의 직접 UI 버블 주입을 제거하고 RenderMessages 중심으로 통합함

- SendRegenerateAsync가 Cowork/Code에서 ResolveExecutionMode와 RunAgentLoopAsync를 타도록 바꿔 재생성도 동일 엔진 축으로 정렬함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 12:19:20 +09:00
f82cfc4541 AX Agent 로컬 응답 경로를 모델 렌더 축으로 통합
Some checks failed
Release Gate / gate (push) Has been cancelled
claw-code 기준 채팅 엔진 정상화 작업을 이어서 진행했습니다.

수동 컨텍스트 압축 결과와 slash 로컬 응답 경로에서 직접 AddMessageBubble로 UI 버블을 꽂던 흐름을 제거하고 conversation/session에 먼저 커밋한 뒤 RenderMessages로만 다시 그리도록 정리했습니다.

이로써 일반 전송, 재생성, 로컬 응답이 서로 다른 렌더 경로를 타며 순서가 꼬이던 상태를 줄였고 Cowork/Code 전용 엔진 정리에 필요한 공통 렌더 축을 더 맞췄습니다.

검증은 dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0개, 오류 0개입니다.
2026-04-05 12:10:31 +09:00
890c8ce76b AX Agent 채팅 엔진 최종 응답 커밋형으로 정상화
Some checks failed
Release Gate / gate (push) Has been cancelled
claw-code 기준으로 AX Agent 채팅 전송 흐름을 준비, 실행, 최종 assistant 커밋, 재렌더 순서로 다시 정리했습니다.

ChatWindow의 SendMessageAsync와 SendRegenerateAsync에서 임시 assistant 메시지와 임시 스트리밍 컨테이너를 먼저 만드는 경로를 제거하고, 실행이 끝난 뒤 최종 assistant 텍스트만 conversation/session에 커밋하도록 수정했습니다.

OnAgentEvent는 실행 로그 배너를 즉시 UI에 직접 꽂지 않고 conversation ExecutionEvents에 먼저 저장한 뒤 ShowExecutionHistory가 켜진 경우에만 RenderMessages 기반으로 다시 그리게 바꿔 Cowork/Code의 플래시 잔상과 중복 표시를 줄였습니다.

AxAgentExecutionEngine도 Chat 실행을 스트리밍 UI 의존이 없는 최종 응답 커밋형 모드로 정리했습니다. 검증은 dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0개, 오류 0개입니다.
2026-04-05 12:07:47 +09:00
7aa600e01c 런처 보조 기능 상태 초기화와 설정 연결 검증 마무리
Some checks failed
Release Gate / gate (push) Has been cancelled
Agent Compare 기준으로 런처 보조 기능과 설정 연결을 다시 점검했습니다.

입력창을 비우거나 런처를 다시 열 때 이전 선택 항목과 미리보기 패널이 남아 있던 흐름을 LauncherViewModel에서 정리해 빠른 실행 칩, 검색 히스토리, 미리보기 패널 전환이 현재 입력 상태와 일치하도록 수정했습니다.

ShowNumberBadges, CloseOnFocusLost, RememberPosition, EnableActionMode, EnableRandomPlaceholder, EnableIconAnimation, EnableSelectionGlow, ShowLauncherBorder, EnableFavorites, EnableRecent, ShowPrefixBadge 등 Launcher 설정이 SettingsWindow/SettingsViewModel에서 실제 런처 코드와 연결되는 경로를 재검토한 내용도 README와 DEVELOPMENT 문서에 반영했습니다.

검증은 dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0개, 오류 0개입니다.
2026-04-05 11:58:02 +09:00
f7cafe0cfc 런처 Agent Compare 기능 1차 이식 및 현재 런처 구조 연결
- Agent Compare 기준으로 런처 빠른 실행 칩, 검색 히스토리 탐색, 선택 항목 미리보기 패널을 현재 런처에 이식
- 하단 위젯 바, QuickLook(F3), 화면 OCR(F4), 관련 서비스/partial 파일을 현재 LauncherWindow/LauncherViewModel 구조에 연결
- UsageRankingService 상위 항목 조회와 SearchHistoryService를 추가해 실행 상위 경로/검색 기록이 실제 런처 동작에 반영되도록 정리
- README.md, docs/DEVELOPMENT.md에 이식 범위와 검증 결과를 2026-04-05 11:58 (KST) 기준으로 기록

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 11:51:43 +09:00
0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00
184 changed files with 76219 additions and 7451 deletions

720
README.md
View File

@@ -7,6 +7,148 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md`
- 업데이트: 2026-04-06 09:03 (KST)
- AX Agent 공통 선택 팝업 조립 로직을 `ChatWindow.PopupPresentation.cs`로 분리했습니다. 테마 팝업 컨테이너, 공통 메뉴 아이템, 구분선, 최근 폴더 우클릭 컨텍스트 메뉴가 메인 창 코드 밖으로 이동해 footer/file-browser 쪽 팝업 품질 작업을 이어가기 쉬운 구조로 정리했습니다.
- `ChatWindow.xaml.cs`는 대화 상태와 런타임 orchestration 쪽에 더 집중하도록 정리했고, 공통 팝업 시각 언어를 한 곳에서 다듬을 수 있는 기반을 만들었습니다.
- 업데이트: 2026-04-06 08:55 (KST)
- AX Agent 파일 브라우저 렌더를 `ChatWindow.FileBrowserPresentation.cs`로 분리했습니다. 파일 탐색기 열기/닫기, 폴더 트리 구성, 파일 헤더/아이콘/크기 표시, 우클릭 메뉴, 디바운스 새로고침 흐름이 메인 창 코드 밖으로 이동했습니다.
- `ChatWindow.xaml.cs`는 transcript·runtime orchestration 중심으로 더 정리됐고, claw-code 기준 사이드 surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
- 업데이트: 2026-04-06 08:47 (KST)
- AX Agent 우측 프리뷰 패널 렌더를 `ChatWindow.PreviewPresentation.cs`로 분리했습니다. 프리뷰 탭 목록, 헤더, 파일 로드, CSV/텍스트/마크다운/HTML 표시, 숨김/열기, 우클릭 메뉴, 별도 창 미리보기 흐름이 메인 창 코드 밖으로 이동했습니다.
- `ChatWindow.xaml.cs`는 transcript 및 런타임 orchestration 중심으로 더 정리됐고, claw-code 기준 preview surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
- 업데이트: 2026-04-06 08:39 (KST)
- AX Agent 하단 상태바 이벤트 처리와 회전 애니메이션을 `ChatWindow.StatusPresentation.cs`로 옮겼습니다. `UpdateStatusBar`, `StartStatusAnimation`, `StopStatusAnimation`이 상태 표현 파일로 이동해 메인 창 코드의 runtime/status 분기가 더 줄었습니다.
- `ChatWindow.xaml.cs`는 대화 실행 orchestration 중심으로 더 정리됐고, claw-code 기준 status line 정교화와 footer presentation 개선을 계속 이어가기 쉬운 구조로 맞췄습니다.
- 업데이트: 2026-04-06 08:27 (KST)
- AX Agent 메시지 액션/메타/편집 렌더를 `ChatWindow.MessageInteractions.cs`로 분리했습니다. 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름이 메인 창 코드 밖으로 이동했습니다.
- `ChatWindow.xaml.cs`는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리했고, claw-code 기준 메시지 타입 분리와 renderer 구조화를 계속 진행하기 쉬운 기반을 만들었습니다.
- 업데이트: 2026-04-06 08:28 (KST)
- AX Agent 하단 composer와 대기열 UI 렌더를 `ChatWindow.ComposerQueuePresentation.cs`로 분리했습니다. 입력창 높이 계산, draft kind 해석, 후속 요청 큐 카드/요약 pill/배지/액션 버튼 생성 책임을 메인 창 코드에서 떼어냈습니다.
- `ChatWindow.xaml.cs`는 대기열 실행 orchestration과 세션 변경 흐름만 더 선명하게 남겨, claw-code 기준 입력부/queued command UX 개선을 계속하기 쉬운 구조로 정리했습니다.
- 업데이트: 2026-04-06 08:12 (KST)
- AX Agent 하단 작업 바 관련 presentation 메서드를 메인 창 코드에서 더 분리했습니다. `ChatWindow.FooterPresentation.cs`를 추가해 폴더 바, 선택된 프리셋 안내, Git 브랜치 버튼/팝업 렌더, 요약 pill 생성 책임을 별도 partial로 옮겼습니다.
- `ChatWindow.xaml.cs`는 대화 흐름과 런타임 orchestration 중심으로 더 정리했고, claw-code 기준으로 footer/preset/Git popup 품질 작업을 계속 이어가기 쉬운 구조를 만들었습니다.
- 업데이트: 2026-04-06 01:37 (KST)
- AX Agent의 계획 승인 흐름을 더 transcript 우선 구조로 정리했습니다. 인라인 승인 카드가 기본 경로를 맡고, `계획` 버튼은 저장된 계획 요약/단계를 여는 상세 보기 역할만 하도록 분리했습니다.
- 상태선과 quick strip 계산도 presentation state로 더 모았습니다. runtime badge, compact strip, quick strip의 텍스트/강조색/노출 여부를 한 번에 계산해 창 코드의 직접 분기를 줄였습니다.
- 업데이트: 2026-04-06 07:31 (KST)
- `ChatWindow.xaml.cs`에 몰려 있던 권한 팝업 렌더와 컨텍스트 사용량 카드 렌더를 별도 partial 파일로 분리했습니다. `ChatWindow.PermissionPresentation.cs`, `ChatWindow.ContextUsagePresentation.cs`를 추가해 권한 선택/권한 상태 배너/컨텍스트 사용량 hover 카드 책임을 메인 창 orchestration 코드에서 떼어냈습니다.
- 다음 단계에서 `permission / tool-result / footer` presentation catalog를 더 세밀하게 확장하기 쉽게 구조를 정리했고, 동작은 그대로 유지한 채 transcript/푸터 품질 개선 발판을 마련했습니다.
- 업데이트: 2026-04-06 00:50 (KST)
- 채팅/코워크 프리셋 카드의 hover 설명 레이어를 카드 내부 오버레이 방식에서 안정적인 tooltip형 설명으로 바꿨습니다. 카드 배경/테두리만 반응하게 정리해 hover 시 반복 깜빡임을 줄였습니다.
- 업데이트: 2026-04-06 00:45 (KST)
- AX Agent 내부 설정 공통 탭에 `대화 스타일` 섹션 제목을 복구해, `문서 형태``디자인 스타일` 저장 항목이 명확히 보이도록 정리했습니다.
- AX Agent 내부 설정 스킬 탭의 `MCP 서버` 영역에 `서버 추가` 버튼을 복원했고, 목록 카드 안에서 `활성화/비활성화``삭제`까지 바로 관리할 수 있게 옮겼습니다.
- 업데이트: 2026-04-05 19:04 (KST)
- AX Agent transcript와 작업 요약에서 도구/스킬 이름을 사람이 읽기 쉬운 표시명으로 정리했습니다. raw snake_case 도구명 대신 `파일 읽기`, `빌드/실행`, `Git`, `/bug-hunt` 같은 표시명 중심으로 보입니다.
- 도구/스킬 이벤트 배지는 역할 중심(`도구`, `도구 결과`, `스킬`)으로 단순화하고, 실제 도구명은 본문 보조 텍스트로만 노출되게 바꿨습니다.
- 작업 요약 팝업의 권한/배경 작업 관측성 섹션은 `debug` 성격일 때만 두껍게 보여서 기본 UX가 더 `claw-code`처럼 조용하게 유지됩니다.
- 업데이트: 2026-04-05 18:58 (KST)
- AX Agent의 `의견 요청`/`질문` 흐름을 transcript 내 카드 우선 구조로 전환했습니다.
- `user_ask` 도구는 별도 `UserAskDialog`를 먼저 띄우지 않고, 본문 안에서 선택지/직접 입력/전달로 응답을 완료합니다.
- 계획 승인과 사용자 질문이 같은 transcript-first UX 원칙을 따르도록 정리했습니다.
- 업데이트: 2026-04-05 22:04 (KST)
- `claw-code`와 AX Agent를 같은 기준으로 비교할 수 있도록 canonical prompt set 10종을 parity 문서에 고정했습니다. Chat 기본/장문, Cowork 문서/데이터, Code 수정/빌드, queue follow-up, post-compaction, permission 승인, slash skill 진입까지 핵심 회귀 흐름을 한 세트로 검증하도록 정리했습니다.
- 도구/스킬 비교 기준도 parity 문서에 추가했습니다. AX는 문서/오피스/데이터/업무형 도구가 더 풍부하고, `claw-code`는 transcript-native tool/approval/permission 메시지 구조가 더 정교하다는 차이를 명시했습니다.
- 계획 승인 UX는 `claw-code` 쪽 흐름에 더 가깝게 inline 우선으로 바꿨습니다. AX Agent는 이제 승인 요청 시 `PlanViewerWindow`를 먼저 띄우지 않고 transcript 내부의 승인/수정/취소 버튼을 우선 보여주며, 계획 상세는 하단 `계획` 버튼으로만 여는 보조 경로를 사용합니다.
- 업데이트: 2026-04-05 16:55 (KST)
- `claw-code` 대비 AX Agent 추정 진척율 기준선을 문서에 남겼습니다. 현재 기준은 핵심 엔진 `82%`, 채팅 메인 UI `68%`, Cowork/Code 상태 UX `63%`, 내부 설정 연결 `88%`, 전체 AX Agent 동등 품질 `74%`입니다.
- 메인 핵심 엔진 로직에 직접 영향을 주는 설정은 최소화 원칙으로 다시 검토하기 시작했습니다. 이미 실질 선택지가 사라진 `계획 모드` 계열은 사용자 노출을 더 줄였고, 남은 엔진성 설정은 개발자 탭 중심으로 계속 정리합니다.
- 메시지 행과 좌측 대화 목록도 `claw-code` 방향으로 다시 단순화했습니다. 사용자/assistant 버블의 패딩, 라운드, 메타 텍스트를 줄였고, 대화 목록의 실행 상태도 배지 카드보다 얇은 텍스트 요약 중심으로 바꿔 읽는 축이 먼저 보이도록 눌렀습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- 업데이트: 2026-04-05 17:03 (KST)
- AX Agent UI를 `claw-code` 쪽 시각 언어에 더 가깝게 맞추기 위해 레이아웃 골격을 다시 조정했습니다. 좌측 패널 폭과 헤더/액션 행 높이, 축소 아이콘 바, 상태 스트립, 메시지 축, 컴포저 폭과 코너 반경을 전반적으로 더 얇고 평평하게 정리했습니다.
- 반응형 폭 계산도 새 골격에 맞춰 다시 조정했습니다. 메시지 축은 최대 `880`, 컴포저는 최대 `820` 기준으로 더 자연스럽게 줄어들도록 바꿔 창 크기가 변해도 `claw-code`처럼 중심선이 크게 흔들리지 않게 맞췄습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- 업데이트: 2026-04-05 17:12 (KST)
- 메시지 내부 액션 바와 Cowork/Code 실행 배너를 더 `claw-code`처럼 보조 레이어로 낮췄습니다. 메시지 액션은 텍스트 버튼 대신 작은 아이콘 버튼 중심으로 바꾸고, 실행 배너는 여백·폰트·토큰 배지·파일 경로 표시를 한 단계 더 얇게 줄여 본문보다 덜 튀게 정리했습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- 업데이트: 2026-04-05 17:19 (KST)
- 상단 헤더와 탭 그룹, 좌측 대화 목록 행 메타를 더 `claw-code` 쪽 밀도로 조정했습니다. 상단 탭은 더 얇은 세그먼트형으로 줄였고, 사이드바 토글 버튼도 크기와 선 두께를 낮췄습니다.
- 대화 목록은 실행 상태/요약 텍스트와 우측 편집 아이콘을 더 약하게 줄여 제목 중심으로 읽히게 정리했습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- 업데이트: 2026-04-05 16:02 (KST)
- `document_plan` 후속 실행 분기를 `claw-code` 기준으로 다시 보강했습니다. 이제 문서 플래너 출력에서 body 골격과 즉시 실행 지시를 깨진 문자열 비교에 의존하지 않고 안정적으로 추출해, `html_create / document_assemble / docx_create / markdown_create` 후속 호출 유도가 실제로 이어집니다.
- 코워크 문서형 작업은 설정이 `planMode=off`여도 내부적으로 `always` 플랜 경로를 타도록 보정했습니다. 그래서 문서/보고서/제안서 요청은 먼저 계획을 세우고, 그 계획을 바탕으로 실제 문서 생성 단계까지 이어가도록 정리했습니다.
- 코워크 시스템 프롬프트도 강화해 문서 작업은 계획만 제시하고 끝내지 말고 실제 산출물 파일 경로까지 만들어야 완료로 판단하도록 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 15:42 (KST)
- AX Agent 엔진 공통화 1차로, Cowork/Code 실행 이벤트와 Agent run 기록을 탭별 현재 대화에 누적한 뒤 원래 활성 탭 대화를 복원하는 로직을 `ChatWindow`에서 `AxAgentExecutionEngine` helper로 옮겼습니다.
- 이제 실행 이벤트/최근 run 기록 반영 시 창 코드가 직접 교차 탭 복원 경로를 중복 처리하지 않고, 엔진의 공통 세션 mutation 경로를 사용합니다.
- 업데이트: 2026-04-05 15:34 (KST)
- AX Agent 개선 계획 기준을 이전 AX 비교본이 아니라 실제 `claw-code` 런타임 축으로 다시 고정했습니다. 현재 참조 spine은 `bootstrap/state.ts -> bridge/initReplBridge.ts -> bridge/sessionRunner.ts -> screens/REPL.tsx -> components/Messages.tsx -> components/StatusLine.tsx` 입니다.
- 이에 맞춰 AX Agent 개선도 `상태 정규화 -> 실행 준비 공통화 -> AgentLoop 이벤트 정규화 -> 타임라인 렌더 일원화 -> 컴포저/상태바 단순화 -> 복구/재개 검증` 순서로 진행하도록 parity 문서를 갱신했습니다.
- 업데이트: 2026-04-05 11:22 (KST)
- AX Agent 채팅 복구 1차로 컴포저를 하단 고정 배치로 조정해 세로 공간을 꽉 채우며 커 보이던 문제를 줄였습니다.
- 전송 직후 사용자 버블을 직접 UI에 꽂지 않고 대화 모델 기반 `RenderMessages()` 재렌더를 먼저 타도록 정리해, 중복 렌더와 빈 버블 누적 가능성을 낮췄습니다.
- Cowork/Code의 실행 이벤트 배너는 `실행 로그 표시`를 켠 경우에만 즉시 채팅 본문에 보이도록 바꿔 작업 중 플래시처럼 남던 잔상 UI를 줄였습니다.
- Cowork/Code는 실행 시작 시 해당 대화의 `실행 로그 표시`를 먼저 끄도록 바꿔, 중간 재렌더가 들어와도 이벤트 배너가 다시 섞이지 않도록 조정했습니다.
- Chat 탭은 비스트리밍 응답 경로에 맞춰 임시 스트리밍 카드 자체를 만들지 않도록 바꿨습니다. 이제 Chat은 최종 assistant 메시지만 모델에 반영되고 재렌더됩니다.
- Cowork/Code도 최종 응답형 경로에 맞춰 임시 스트리밍 카드를 만들지 않도록 정리했습니다. 이제 임시 빈 assistant 카드 없이 최종 응답만 대화 모델에 반영됩니다.
- 메인 설정 탭 가시성 로직에서도 `AX Agent` 탭은 항상 숨기도록 고정해, 일반 설정과 AX Agent 내부 설정이 다시 갈라지지 않게 정리했습니다.
- AX Agent 채팅 전송 경로에서 빈 assistant 메시지를 먼저 대화 모델에 넣던 흐름을 제거했습니다. 이제 응답이 끝난 뒤 최종 assistant 메시지만 추가되어, 토큰은 갔는데 빈 말풍선만 남는 현상을 줄입니다.
- 메인 설정 `TabControl`에서 구형 AX Agent 탭이 선택되더라도 내부 설정 바로가기로 즉시 우회되도록 연결해, 숨김 탭 경로로 다시 들어가는 흐름을 막았습니다.
- 메인 설정에만 남아 있던 `Temperature`도 AX Agent 내부 설정 오버레이에 추가했습니다. 이제 내부 설정에서 값 확인과 수정이 가능하며, 포커스가 빠지면 0.0~2.0 범위로 정규화해 저장됩니다.
- `claw-code`의 입력 처리/실행 분리 흐름을 참고해 AX Agent 내부 실행 엔진 `AxAgentExecutionEngine`을 추가했습니다. ChatWindow는 전송 메시지 조립과 최종 assistant 메시지 반영을 이 엔진으로 넘기기 시작했습니다.
- 업데이트: 2026-04-05 07:11 (KST)
- AX Agent 하단 바를 다시 정리해 코워크/코드 탭에서는 중복으로 보이던 보조 칩(`워크스페이스`, `파일`, `간략`, `로컬/워크트리` 묶음)이 더 이상 나타나지 않도록 정리했습니다. 작업 폴더 정보는 기존 폴더 경로 영역만 남기고, 노란 표시로 보이던 중복 보조 영역은 제거했습니다.
- `데이터 미활용` 버튼은 외곽 테두리선 없이 보이도록 바꿔 하단 옵션 줄이 덜 부풀어 보이게 정리했습니다.
- 업데이트: 2026-04-05 07:08 (KST)
- AX Agent 체감 속도 개선을 위해 대화 메타 캐시를 보강했습니다. 대화 목록 메타를 다시 읽을 때마다 매번 전체 정렬을 반복하지 않도록 정렬 결과를 별도로 캐시해, 사이드바 대화 목록과 분류 계산이 잦은 흐름의 부담을 줄였습니다.
- AX Agent 내부 설정/하단 옵션 반영 시 같은 값인데도 현재 대화 설정을 반복 저장하던 경로를 줄였습니다. 권한, 데이터 활용, 무드, 출력 형식이 실제로 바뀐 경우에만 대화 저장이 일어나도록 바꿔 작은 옵션 변경 때의 지연을 덜었습니다.
- 대화 검색창은 입력할 때마다 즉시 전체 목록을 다시 그리지 않고 짧게 디바운스되도록 조정해, 검색어를 빠르게 입력할 때의 버벅임을 줄였습니다.
- 업데이트: 2026-04-05 02:00 (KST)
- AX Agent 내부 톱니 설정 오버레이의 왼쪽 분류 탭도 복구했던 기준에 맞춰 `기본 / 채팅 / 코워크 / 코드 / 개발자 / 도구 / 스킬/차단` 구조로 다시 정리했습니다. 단순히 항목만 추가한 것이 아니라, 실제 오버레이 네비게이션 자체를 같은 분류 기준으로 바꾸고 각 탭에서 해당 설정군만 보이도록 다시 묶었습니다.
- 업데이트: 2026-04-05 01:46 (KST)
- AX Agent 안의 톱니 아이콘이 실제로 여는 대상이 별도 창이 아니라 채팅 내부 오버레이임을 다시 확인하고, 누락된 설정을 그 실제 오버레이로 옮겼습니다. 이제 AX Agent 내부 설정 오버레이의 고급 섹션에서 `프로젝트 규칙 자동 반영`, `에이전트 메모리`, `최대 Agent Pass`, `Code용 Plan/Worktree/Team/Cron 도구` 항목을 직접 보고 저장할 수 있습니다.
- 업데이트: 2026-04-05 01:40 (KST)
- AX Agent 내부의 톱니 설정 창에 메인/오버레이 설정에 남아 있던 항목을 추가로 옮겼습니다. 이제 내부 설정에서 기본 출력 형식, 기본 디자인 무드, 프로젝트 규칙 자동 반영, 에이전트 메모리, 최대 Agent Pass, Code용 Plan/Worktree/Team/Cron 도구 토글까지 직접 보고 저장할 수 있습니다.
- 채팅 하단 입력부와 옵션 버튼의 과한 pill 형태도 다시 줄였습니다. 하단 옵션 버튼과 입력 박스의 코너 반경을 눌러 타원형 느낌보다 밀도 있는 업무형 형태에 가깝게 정리했습니다.
- 업데이트: 2026-04-05 01:35 (KST)
- AX Agent 코워크 대화 목록 필터를 프로젝트(작업 폴더) 기준이 아니라 작업 유형 기준으로 다시 정리했습니다. 이제 코워크의 상단 빈 상태, 분류 드롭다운, 대화 목록 필터가 모두 프리셋/카테고리 기반 작업 유형 흐름에 맞춰 동작합니다.
- 코드 탭은 기존처럼 프로젝트(워크스페이스) 기준 필터를 유지해, 코워크와 코드가 각자 맞는 분류 기준을 사용하도록 분리했습니다.
- 업데이트: 2026-04-05 01:22 (KST)
- AX Agent 내부 설정 창의 `도구`, `스킬/차단` 탭에 숨겨져 있던 설정을 추가로 옮겼습니다. 이제 내부 설정에서 도구 노출 목록, 도구 훅, 스킬 폴더, 슬래시 팝업 개수, 드래그 앤 드롭 AI 액션, 폴백 모델, MCP 서버를 직접 확인하고 저장할 수 있습니다.
- 채팅 하단 데이터 활용 옵션의 과한 타원형 pill 테두리는 둥근 사각형으로 정리해 하단 옵션 바가 덜 부풀어 보이도록 다듬었습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 01:15 (KST)
- AX Agent 내부 설정 저장 경로를 다시 점검해, 저장 버튼 전에도 원본 설정 객체를 바로 바꾸던 흐름을 로컬 상태 기반으로 정리했습니다. 이제 서비스/테마/표현 수준 선택은 저장 버튼을 눌렀을 때만 실제 설정에 반영됩니다.
- 내부 설정 창에서 빠져 있던 `vLLM TLS 우회`, 활성 서비스별 모델 동기화, 전역 호환 모델 필드 저장도 다시 연결해 저장 후 재실행 시 값이 어긋나는 문제를 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 01:12 (KST)
- AX Agent 내부 설정 창의 분류 기준을 이전 커밋 구조에 맞춰 다시 정리했습니다. 단순 섹션형이던 인앱 설정을 `기본 / 채팅 / 코워크 / 코드 / 개발자 / 도구 / 스킬·차단` 흐름으로 되돌릴 수 있는 기반을 복구했고, 우선 `기본/채팅/코워크/코드/개발자/도구` 탭 전환과 핵심 저장 경로를 다시 연결했습니다.
- `AX Agent 사용` 토글과 `표현 수준` 저장도 내부 설정 창에서 다시 관리되도록 연결했고, 설정 저장 시 `AiEnabled` 값이 강제로 다시 켜지던 정규화 경로도 함께 수정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 00:58 (KST)
- `Agent Compare/AX Copilot`의 개발 문서와 런처 소스를 대조해 AX Commander 신규 기능 묶음을 이식했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/SSH/UUID/JWT/QR 등 비교본에 있던 다수의 런처 핸들러를 현재 앱에 등록했습니다.
- 런처 기능 이식에 맞춰 스케줄러/태그/알림 기록/아이콘 캐시/URL 템플릿 서비스와 편집용 보조 창, 설정 모델, 런처 위치 기억 설정, QR/OCR 빌드 의존성도 함께 반영했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 00:46 (KST)
- 트레이 아이콘 우클릭 메뉴 맨 위의 앱 이름/버전 헤더 글자색을 진한 회색으로 조정해, 본문 메뉴 항목보다 덜 튀면서도 더 또렷하게 보이도록 정리했습니다.
@@ -419,6 +561,14 @@ ow + toggle 시각 언어로 통일했습니다.
- DraftQueue 패널 상단에 실행 중 / 다음 / 보류 / 완료 / 실패 요약 pill을 추가하고, composer 상단의 모델/컨텍스트/프리셋 줄도 더 낮고 평평한 밀도로 정리했습니다.
- 브랜치/워크트리 패널에는 공통 요약 strip을 추가해 현재 상태를 같은 시각 언어로 보여주도록 맞췄고, 저장소 루트 `.gitignore`에는 빌드 산출물·IDE 파일·OS 잡파일·비밀정보 패턴을 추가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:17 (KST)
- 별도 `AX Agent 설정` 창과 AX Agent 내부 설정 오버레이에서 `AX Agent 사용` 항목을 숨겨, 작업 중 자주 쓰지 않는 전역 AI 사용 토글이 설정 메뉴를 차지하지 않도록 정리했습니다.
- 별도 `AX Agent 설정` 창에서는 `표현 수준` 선택 카드도 함께 숨겨 기본 탭 상단을 더 단순하게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:24 (KST)
- `설정 > AX Agent`에만 남아 있던 스킬 목록을 별도 `AX Agent 설정` 창 안 `스킬/차단` 탭으로 복구해, 현재 로드된 슬래시 스킬을 내장/고급 그룹으로 다시 확인할 수 있게 했습니다.
- 별도 `AX Agent 설정` 창은 저장 시 스킬 폴더 기준으로 다시 로드해 목록이 바로 갱신되도록 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-04 23:23 (KST)
- AX Agent는 이제 설정 서비스 변경 이벤트를 직접 구독해 메인 설정, AX Agent 설정, 저장 경로와 관계없이 테마/권한/데이터 활용/모델 라벨/composer/대기열 UI를 즉시 다시 읽어오도록 fan-out 경로를 통합했습니다.
- AX Agent 설정 저장 경로에서 표현 수준을 `rich`로 고정 덮어쓰던 처리도 제거해, 사용자가 선택한 `풍부하게 / 적절하게 / 간단하게` 값이 다른 설정 저장 흐름에서도 유지되도록 보정했습니다.
@@ -428,6 +578,476 @@ ow + toggle 시각 언어로 통일했습니다.
- AX Agent 하단 컨텍스트 카드 툴팁에 최근 압축 이력을 추가해 마지막 자동/수동 compact 시각, 압축 전후 토큰, 실제 절감량을 다시 확인할 수 있게 했습니다.
- 수동 `/compact` 실행과 전송 전 자동 컨텍스트 압축 모두 같은 compaction 통계 경로를 타도록 맞춰, compact 결과를 일회성 토스트가 아니라 이후 UI에서도 계속 확인할 수 있도록 보강했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:39 (KST)
- `Agent Compare`의 트레이 메뉴 경로와 현재 구현을 대조해, 우클릭 메뉴가 열릴 때마다 `Show + UpdateLayout`로 창 크기를 다시 확정하던 흐름을 제거하고 메뉴 크기 측정값을 캐시하도록 바꿨습니다.
- 앱 유휴 시점에 트레이 메뉴를 미리 측정해 첫 우클릭에서 초기 레이아웃 비용이 몰리지 않도록 조정했고, AI 항목 가시성처럼 열기 직전 바뀌는 항목만 크기 캐시를 다시 계산하도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:45 (KST)
- AX Agent 내부 설정 오버레이의 `뒤로가기` 버튼을 오른쪽 본문 헤더에서 제거하고, 왼쪽 설정 제목 영역에 화살표와 함께 합쳐 배치해 설정 패널 접기/닫기와 혼동되지 않도록 정리했습니다.
- 본문 스크롤 영역은 헤더 빈 줄 없이 바로 시작하도록 올려, 설정 화면 진입 시 첫 섹션이 더 자연스럽게 이어지도록 조정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:49 (KST)
- AX Agent 상단 좌측의 사이드바 접기 버튼을 기존 큰 아이콘형 고스트 버튼에서, Claude 계열처럼 작은 라운드 사각 안에 3줄 메뉴가 들어간 얇은 토글 버튼으로 바꿨습니다.
- 열림/닫힘 상태에서 글리프를 바꾸던 예전 처리도 제거해, 상단 바가 덜 요란하고 더 안정적인 작업형 헤더로 보이도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 07:54 (KST)
- Chat 빈 화면의 상단 아이콘이 탭 메뉴 바와 가까워 보이던 배치를 내려, 빈 상태 헤더가 상단 메뉴와 겹쳐 보이지 않도록 여백을 조정했습니다.
- Chat 탭에서는 하단의 컨텍스트/압축 카드가 보이지 않도록 분기해, 토큰 압축 UI는 Cowork/Code에서만 유지되게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:00 (KST)
- 런처 하단에 남아 있던 색인 상태 문구를 점검한 결과, 인덱스 상태 표시와 토스트 오버레이가 같은 타이머를 공유해 자동 숨김이 꼬일 수 있는 구조를 확인했습니다.
- [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 에서 토스트 타이머와 인덱스 상태 타이머를 분리하고, 인덱스 재구축 시작/완료 문구를 공통 `ShowIndexStatus(...)` 경로로 묶어 일정 시간 뒤 확실히 사라지도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:06 (KST)
- AX Agent 내부 설정 오버레이에서 `Fast`, `의사결정 수준`, `실행 전 계획`, `권한 모드`, `기본 출력 형식`, `테마 스타일`, `운영 모드`, `폴더 데이터 활용`처럼 글자가 순환하던 버튼을 커스텀 콤보박스로 교체했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)에 오버레이 전용 `OverlayComboBox` 스타일과 각 항목용 콤보를 넣고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 선택 변경 시 기존 저장 흐름을 그대로 타도록 전용 핸들러를 연결했습니다.
- 운영 모드처럼 보호가 필요한 항목은 콤보박스로 바뀐 뒤에도 기존 비밀번호 확인을 유지했고, 나머지 항목은 선택 즉시 AX Agent 내부 설정에 반영되도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:15 (KST)
- 별도 `AX Agent 설정` 화면에서 `AX Agent 사용`, `표현 수준` 항목은 다시 보이지 않도록 정리했고, 내부 저장값은 `AI 사용 = 활성`, `표현 수준 = 풍부하게`로 고정되게 보정했습니다.
- [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml), [AgentSettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs) 에서 해당 행을 숨기고 저장 시 강제로 켜진 상태와 `rich` 값을 유지하도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:14 (KST)
- 배포용 [build.bat](/E:/AX%20Copilot%20-%20Codex/build.bat)을 루트 절대 경로 기준으로 다시 작성해, 작업 폴더에 따라 `dist`, `payload.zip`, 인스톨러 복사 경로가 꼬이던 문제를 막았습니다.
- 스크립트는 이제 `main app publish -> optional obfuscation check -> AxKeyEncryptor publish -> payload.zip 생성 -> installer build -> dist 정리` 순서로 고정 동작하고, 외부 난독화 도구가 없으면 `보호 미적용` 경고를 명확히 출력합니다.
- 현재 레포에는 실제 난독화 도구 설정이 없어서, 배포본 보호 수준은 `PDB/XML/debug metadata 제거`까지이며 진짜 디컴파일 방지는 아직 미구성 상태임을 확인했습니다.
- 검증: `cmd /c build.bat` 실행 기준 메인 앱 publish, 인스톨러 빌드, `dist\AxCopilot_Setup.exe` 복사까지 정상 완료
- 업데이트: 2026-04-05 07:59 (KST)
- `설정 > AX Agent > 공통`에서 계속 남아 있던 `AX Agent 사용`, `표현 수준` 행을 실제 메인 설정창 기준으로 다시 숨겼고, 표현 수준 값은 런타임 정규화와 설정 초기화 양쪽에서 `풍부하게(rich)`로 고정되도록 보정했습니다.
- `설정 > 기능 > 응답 설정`을 포함한 `?` 도움말 툴팁은 라이트 테마에서도 글자가 사라지지 않도록 `HelpTooltipStyle` 배경을 고대비 다크 톤으로 바꾸고 텍스트 전경색을 흰색으로 강제했습니다.
- `Agent Compare`와 비교해 빠져 있던 런처 `마지막 위치 기억` 설정을 복구하고, AX Commander가 숨겨질 때 마지막 좌표를 저장한 뒤 다음 표시 때 같은 위치를 복원하도록 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- 업데이트: 2026-04-05 08:02 (KST)
- AX Agent가 열리자마자 죽던 원인을 앱 로그로 확인했고, `ChatWindow``HelpTooltipStyle`을 찾지 못해 `XamlParseException`이 발생하고 있었습니다.
- [App.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml)에 `HelpTooltipStyle`을 전역 리소스로 올려, AX Agent/설정/내부 오버레이가 모두 같은 도움말 툴팁 스타일을 공통으로 찾도록 수정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:08 (KST)
- `설정 > AX Agent`에 있던 도구/스킬 설명창이 내부 AX Agent 설정 오버레이에는 빠져 있던 상태를 보완해, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `도구`, `스킬/차단` 탭 상단에 설명 블록을 다시 넣었습니다.
- `도구` 탭에는 훅 동작 흐름과 활용 예시, `스킬/차단` 탭에는 스킬 파일 구조, 기본 폴더 경로, MCP/폴백 모델/드래그 드롭 관리 범위를 안내하는 설명을 복구했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 탭 전환 시 해당 설명창만 보이도록 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 코워크 좌측 패널 상단 필터 메뉴는 동작이 이미 `작업 유형` 기준이었지만, 메뉴 라벨만 예전 `워크스페이스`로 남아 있던 부분을 `작업 유형`으로 수정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `SidebarCoworkMenu` 라벨을 코워크 기준에 맞게 정리했고, Code 탭의 `워크스페이스` 라벨은 그대로 유지했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:18 (KST)
- AX Agent 입력창에서 텍스트를 치면 입력 영역이 비정상적으로 길어지고, 입력 중 화면이 자주 번쩍이던 문제를 함께 수정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 입력 행 `Grid.RowDefinition``Auto`로 바꾸고 `InputBox`를 상단 정렬로 고정해, Code 탭에서 입력창이 남는 공간을 끌어먹으며 비대해지던 레이아웃 문제를 막았습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에는 `_inputUiRefreshTimer`를 추가해, 타이핑 중 매 글자마다 실행되던 `RefreshContextUsageVisual()``RefreshDraftQueueUi()`를 짧게 디바운스해서 입력 중 깜빡임과 과한 리렌더를 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:24 (KST)
- 모델 선택 팝업 하단에 중복으로 보이던 보조 UI도 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 모델 리스트 아래 반복되던 `InlineModelChipPanel`은 숨기고, 맨 아래 `계획`, `권한` 빠른 버튼은 `Visibility="Collapsed"`로 내려 팝업 안 중복 제어를 제거했습니다.
- 안내 문구도 `서비스, 모델, 추론을 여기서 바로 바꿉니다`로 맞춰, 이 팝업이 실제로 제공하는 항목만 설명하도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:28 (KST)
- AX Agent 채팅 초기 프리셋 카드 영역의 세로 스크롤바도 항상 고정처럼 보이던 부분을 조정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `TopicPresetScrollViewer` 이름을 부여하고 기본 상태를 `VerticalScrollBarVisibility="Disabled"`로 바꿔, 정상 크기에서는 스크롤바 여백이 먼저 보이지 않도록 했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateTopicPresetScrollMode()`를 추가해 프리셋 버튼 재구성 후와 창 크기 변경 시 `ExtentHeight`/`ViewportHeight`를 비교하고, 실제로 넘칠 때만 세로 스크롤을 `Auto`로 켜도록 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:31 (KST)
- AX Agent 입력창 위 `후속 요청` 카드에는 타이핑 중인 현재 입력을 미리 보여주지 않도록 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `RefreshDraftQueueUi()`에서 `DraftPreviewCard`를 상시 접고, 후속 요청은 실시간 입력 미리보기가 아니라 엔터로 실제 대기열에 들어간 뒤 아래 대기열 목록에만 반영되도록 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:34 (KST)
- AX Agent 빈 상태 프리셋 카드의 하단 글자가 잘리던 레이아웃도 보정했고, Code 탭에서는 프리셋 영역이 보이지 않도록 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()`에서 프리셋/기타/추가 카드 높이를 `116`으로 키우고 설명 `MaxHeight`도 늘려 카드 하단 텍스트가 잘리지 않게 했습니다.
- 같은 메서드에서 Code 탭일 때는 `TopicButtonPanel``TopicPresetScrollViewer`를 바로 숨기고, 빈 상태 문구도 `코드 작업을 입력하세요` 기준으로 바꿔 프리셋 기능이 안 보이도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:37 (KST)
- AX Agent 내부 설정 오버레이의 콤보박스도 기본 WPF 형태 대신, `트레이 아이콘 → 설정` 쪽 커스텀 콤보 스타일을 기준으로 맞췄습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `OverlayComboBoxToggle`, `OverlayComboBox`, `OverlayComboBoxItem` 리소스를 추가하고, 서비스/모델 포함 오버레이 콤보들이 같은 토글 버튼형 드롭다운과 항목 호버 스타일을 쓰도록 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:40 (KST)
- AX Agent 내부 설정 오버레이에도 메인 설정처럼 `?` 도움말 배지를 복구해, 항목별 상세 설명을 마우스 오버로 바로 볼 수 있게 했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `OverlayHelpBadge` 스타일을 추가하고, `서비스`, `모델`, `기본 서버 주소`, `API 키`, `테마 스타일`, `테마 모드`, `문서 형태`, `디자인 스타일`, `운영 모드`, `폴더 데이터 활용`, `압축 시작 한도`, `최대 컨텍스트 토큰`, `오류 재시도`, `최대 Agent Pass`에 각각 개별 툴팁 설명을 연결했습니다.
- 같은 파일의 고급/개발자 영역에도 `자동 대화 압축`, `확장 스킬 사용`, `실행 전후 자동 확장`, `입력 보정 반영`, `권한 변경 반영`, `Cowork 결과 검토`, `Code 결과 검토`, `도구 병렬 실행`, `프로젝트 규칙 자동 반영`, `에이전트 메모리 사용`, `Plan/Worktree/Team/Cron 도구` 항목별 `?` 설명을 추가해 AX Agent 내부 설정만 보고도 역할을 바로 이해할 수 있게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:43 (KST)
- AX Agent 내부 설정 오버레이의 탭 구조를 예전 버전 기준으로 다시 확장했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 좌측 네비를 `공통 / 채팅 / 코워크/코드 / 코워크 / 코드 / 개발자 / 도구 / 스킬/차단`으로 복구하고, `스킬/차단` 탭 안에 `차단 경로 패턴`, `차단 확장자`, `스킬 설정`, `로드된 스킬`, `폴백 모델`, `MCP 서버`, `등록된 도구/커넥터` 패널을 다시 배치했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에는 오버레이 전용 `RefreshOverlayEtcPanels()`, 차단 목록 렌더링, 스킬 목록 렌더링, 폴백 모델 요약, MCP 서버 카드, 도구 레지스트리 목록 빌더를 추가하고, `코워크/코드` 공통 탭 분기와 `슬래시 팝업 표시 개수`, 드래그앤드롭 AI 액션 저장 경로도 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:51 (KST)
- AX Agent 창 우측 상단의 최소화/최대화/닫기 버튼도 사용자 가이드 상단바 쪽과 비슷한 밀도로 다시 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `TitleBarActionButton`, `TitleBarCloseButton` 스타일을 추가해 버튼 크기를 `40x40`으로 키우고, 간격을 넓히고, 마우스 오버 시 살짝 커지는 스케일 애니메이션과 배경 피드백이 보이도록 조정했습니다.
- 같은 위치에서 AX Agent 상단 창 버튼 3개가 일반 `GhostBtn` 대신 새 타이틀바 전용 스타일을 쓰도록 바꿔, 아이콘 크기와 클릭 영역이 더 명확하게 보이도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 08:54 (KST)
- AX Agent의 Chat/Cowork/Code 탭이 서로 다른 폭으로 보이던 채팅 본문/입력 영역 레이아웃도 공통 폭 기준으로 다시 맞췄습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 빈 상태 영역과 하단 컴포저 래퍼를 `HorizontalAlignment="Stretch"` 기준으로 바꾸고 `MaxWidth``1280`으로 통일해, 탭별 내용물 길이에 따라 입력창이 좁아지지 않게 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `GetMessageMaxWidth()`도 새 `ComposerShell` 폭을 우선 기준으로 쓰도록 바꿔, Chat/Cowork/Code 메시지 카드와 스트리밍 컨테이너가 같은 레이아웃 폭 안에서 렌더되도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:02 (KST)
- AX Agent가 시작 직후 `System.Windows.FrameworkElement.Style` 예외로 죽던 문제도 함께 수정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `OverlayComboBox` 스타일이 뒤에서 선언된 `OverlayComboBoxItem``StaticResource`로 먼저 참조하고 있어 런타임에 `MS.Internal.NamedObject` 캐스팅 예외가 발생했는데, 이를 `DynamicResource`로 바꿔 창 초기화가 정상 진행되도록 복구했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:06 (KST)
- `build.bat` 실행 시 AX Copilot이 켜져 있으면 애매하게 종료되거나 publish가 꼬이던 흐름도 정리했습니다.
- [build.bat](/E:/AX%20Copilot%20-%20Codex/build.bat) 의 프로세스 정리 루틴을 `taskkill` 1회 호출에서 `정상 종료 시도 → taskkill /T /F → 실제 종료 확인` 순서로 바꾸고, 종료되지 않으면 빌드를 즉시 실패시키도록 수정했습니다.
- 특히 현재 배치 권한보다 높은 권한으로 AX Copilot이 떠 있는 경우에는 무리하게 진행하지 않고 `Access may be denied or the app may be running with higher privileges.` 메시지로 원인을 바로 알 수 있게 했습니다.
- 검증: `cmd /c build.bat` 실행 시, 실행 중인 AX Copilot 프로세스가 권한 문제로 종료되지 않을 때 즉시 실패 처리 확인
- 업데이트: 2026-04-05 09:13 (KST)
- AX Agent 내부 설정의 `도구` / `스킬·차단` 탭도 메인 설정에 남아 있던 세부 항목을 더 흡수하고, 목록이 너무 길던 부분을 접기/펼치기 구조로 다시 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `도구` 탭에 훅 실행 타임아웃, 등록된 훅 목록, 훅 추가/편집/삭제 UI를 AX Agent 내부 설정으로 옮겼고, `스킬/차단` 탭에는 스킬 폴더 선택/열기, 슬래시 핀 최대 개수, 슬래시 최근 최대 개수까지 같이 옮겼습니다.
- 스킬 목록은 `/스킬명` 형식으로 표기를 바꾸고 설명은 기존 한국어 설명을 유지했으며, `직접 호출 / 자동·조건부 / 현재 사용 불가` 섹션으로 접기/펼치기 형태로 나눴습니다. 도구 목록도 카테고리별 접기/펼치기로 바꿔 한 번에 너무 길게 보이지 않게 정리했습니다.
- 남아 있던 AX Agent 전용 잔여 설정 중 `PDF 내보내기 기본 경로`, `이미지 입력 활성화`, `코드 리뷰 도구 활성화`도 내부 설정의 `채팅`/`코드` 탭에 재배치해 메인 설정 의존을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:27 (KST)
- AX Agent 내부 설정의 `개발자` 탭에도 메인 설정에 남아 있던 잔여 운영 항목을 더 흡수해, 실행 이력과 감사/병렬 관련 설정을 오버레이 안에서 바로 조정할 수 있게 했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `호출 간 딜레이(초)`, `서브에이전트 최대 수`, `실행 이력 상세도`, `계획 diff 심각도(개수/비율)`, `워크플로우 시각화`, `전체 호출·토큰 합계 표시`, `감사 로그`, `감사 로그 폴더 열기` 행을 추가해 `개발자` 탭 안에서 한 번에 볼 수 있도록 재배치했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 해당 값들의 로드/저장/즉시 반영 경로를 AX Agent 오버레이 저장 흐름에 연결하고, `실행 이력 상세도` 콤보, 숫자 입력 검증, 감사 로그 폴더 열기 동작도 함께 붙였습니다.
- 이 변경으로 AX Agent 내부 설정은 `채팅 / 코워크/코드 / 코워크 / 코드 / 개발자 / 도구 / 스킬·차단` 탭 구조를 유지한 채, 메인 설정에 남아 있던 AX Agent 전용 세부값 상당수를 각 기능 탭으로 다시 분산 배치한 상태가 됐습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:38 (KST)
- AX Agent 내부 설정의 공통/채팅/코워크 배치도 다시 정리했습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `ApplyAiEnabledState()`에서 메인 설정의 `AX Agent` 탭이 다시 살아나던 경로를 끊어, 일반 설정 화면에서는 AX Agent 탭이 더 이상 보이지 않게 했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 AX Agent 내부 설정의 `AX Agent 사용` 저장 경로를 제거하고 항상 활성 상태로 고정했으며, `서비스/모델`과 운영 모드를 `공통`으로, `문서 형태/디자인 스타일``코워크`로 다시 배치했습니다.
- 같은 위치에서 `최대 컨텍스트 토큰`, `압축 시작 한도(%)`는 숫자 입력 대신 `4K / 16K / 64K / 256K / 1M`, `60 / 70 / 80 / 90%` 프리셋 버튼으로 고를 수 있게 바꾸고 내부 설정 저장과 즉시 반영을 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:54 (KST)
- 메인 설정의 `AX Agent` 탭에 남아 있던 `표현 수준` 행도 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 에서 숨겨, 일반 설정 화면에서 더 이상 보이지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:56 (KST)
- AX Agent 채팅 입력창이 입력할수록 과하게 커지고 가로폭도 창 너비를 과도하게 채우던 레이아웃을 고정 폭 기준으로 다시 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `ComposerShell``Center + Width/MaxWidth 640` 기준으로 바꿔, 입력 박스가 창 너비 전체를 계속 먹지 않도록 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `ApplyExpressionLevelUi()`에서 입력창 최대 높이도 `rich 120 / balanced 108 / simple 96`으로 낮춰, 여러 줄 입력 시에도 이전처럼 과하게 길어지지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 09:58 (KST)
- AX Agent Chat 탭에서 Gemini 사용 시 빈 응답/진행 중 멈춤처럼 보이던 현상도 보정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 일반 전송/재생성 흐름에서 Gemini는 스트리밍 대신 비스트리밍 `SendAsync()` 경로를 사용하도록 바꿔, 스트리밍 파싱 문제로 빈 컨테이너만 남는 상황을 우회했습니다.
- 같은 파일에서 메시지 버블 최대폭 계산도 `320~720` 범위로 다시 맞추고, 스트리밍 종료 시 내용이 비어 있으면 `(빈 응답)` 기본 문구로 치환해 완전히 빈 말풍선이 남지 않게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:02 (KST)
- AX Agent 채팅창에서 입력 내용이 거의 없는데도 컴포저 높이가 계속 커지고, 빈 assistant 말풍선이 대화 목록에 남는 현상도 추가로 보정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateInputBoxHeight()`를 추가해 입력창 높이를 실제 줄 수 기준으로 `MinHeight~MaxHeight` 범위에서 직접 고정하고, 넘칠 때만 내부 스크롤이 나오게 바꿨습니다.
- 같은 파일의 `RenderMessages()`에서는 내용이 비어 있는 assistant 메시지를 렌더 대상에서 제외하고, 일반 전송/재생성 완료 직전 `assistantMsg.Content`가 비어 있으면 `(빈 응답)`으로 먼저 확정해 저장/재렌더 때도 빈 카드가 남지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:10 (KST)
- AX Agent 내부 설정의 `채팅` 탭에 잘못 들어가 있던 `테마 스타일`, `테마 모드` 블록도 제거했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 채팅 설정 섹션의 중복 테마 UI를 삭제해, 채팅 탭에는 실제 채팅 관련 항목만 남도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:12 (KST)
- AX Agent 내부 설정의 `공통` 탭에도 메인 설정에만 있던 `등록 모델 관리`를 추가했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 사내 서비스(`Ollama`, `vLLM`) 선택 시 `모델 추가`, `편집`, `삭제`, `선택`이 가능한 등록 모델 관리 패널을 내부 설정 안에 붙였습니다.
- 메인 설정에서 쓰던 [ModelRegistrationDialog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ModelRegistrationDialog.cs) 흐름을 그대로 연결해, 내부 설정에서 추가한 모델도 기존 `RegisteredModels` 저장 경로와 동일하게 저장되도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:16 (KST)
- AX Agent 내부 설정의 `압축 시작 한도(%)`도 분류를 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 해당 행(`OverlayAnchorAdvanced`)이 `코워크/코드 공통` 탭에서만 보이도록 바꿔, 개발자/도구/스킬 탭에 섞여 나오지 않게 조정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:19 (KST)
- AX Agent 내부 설정 탭의 제목/설명 위치도 본문 최상단으로 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 상단 전용 헤더를 추가해 `공통 설정` 같은 탭 제목과 설명이 먼저 보이게 했고, 아래쪽 중복 헤더는 숨겼습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:27 (KST)
- Chat 탭에서 엔터 전송 뒤 입력창 높이가 계속 커지고, 토큰은 집계되는데 assistant 메시지가 화면에 안 보이던 문제도 같이 보정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateInputBoxHeight()`를 조정해 Chat 탭은 입력창 높이를 고정으로 유지하고, Cowork/Code만 명시적 줄 수 기준으로 높이를 늘리게 바꿨습니다.
- 같은 파일에 `SyncLatestAssistantMessage(...)`를 추가하고 응답 완료 뒤 `RenderMessages(preserveViewport: true)`를 다시 태우도록 바꿔, 응답 토큰은 들어왔는데 저장된 assistant 메시지가 비어 보여 렌더가 사라지던 상태를 끊었습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:35 (KST)
- 메인 설정에 남아 있던 AX Agent 진입 흐름을 정리하고, 일반 설정 하단에서 AX Agent 내부 설정을 바로 여는 전용 바로가기를 추가했습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 일반 탭 하단에 `AX Agent 설정 바로가기` 카드를 추가해, 설정창 안에서 바로 AX Agent 채팅창과 내부 설정 오버레이를 열 수 있게 했습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에서 메인 설정의 표시 대상 목록에서 `AX Agent` 탭을 제외하고, 기존 AX Agent 바로가기 버튼도 탭 전환이 아니라 `App.OpenAgentSettingsInChat()` 경로를 타도록 바꿔 메인 설정 잔여 진입을 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:41 (KST)
- AX Agent Chat 탭의 입력창 높이 규칙도 다시 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateInputBoxHeight()`에서 `Chat` 탭만 높이를 고정하던 분기를 제거해, 이제 `Chat / Cowork / Code` 모두 실제 줄바꿈 문자(`Shift+Enter`)가 있을 때만 높이가 늘어나고 일반 입력/전송만으로는 커지지 않게 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:48 (KST)
- 메인 설정 안에 숨어 있던 AX Agent 옛 UI 잔재 1차도 걷어냈습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 에서 일반 탭 상단의 숨김 `AI 기능`, `운영 모드` 블록과 하단 공용 버튼 바의 중복 `AX Agent 설정` 버튼을 제거해, 메인 설정 내부에 남아 있던 보이지 않는 AX Agent 진입 잔재를 줄였습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `ApplyAiEnabledState()``ApplyOperationModeState()`도 이에 맞춰 숨김 컨트롤 동기화 코드를 걷어내고, 메인 설정에서는 더 이상 그 컨트롤들을 전제로 동작하지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:02 (KST)
- AX Agent 채팅/코워크/코드 전송 안정화도 같이 보정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `InputBox.Text`를 비우거나 대기열 메시지를 다시 넣는 지점마다 `UpdateInputBoxHeight()`를 즉시 호출하도록 바꿔, 전송 뒤 입력창 높이가 남은 상태로 계속 커져 보이던 문제를 줄였습니다.
- 같은 파일의 `SendMessageAsync()`는 Chat 탭에서 스트리밍 대신 비스트리밍 응답을 우선 사용하도록 바꿔, 토큰은 집계되는데 본문이 비거나 늦게 반영되던 흐름을 안정화했습니다.
- Cowork/Code 탭은 응답 완료 후 assistant 본문이 비어 있으면 최근 실행 이벤트 요약을 최종 응답으로 보강하고, `ShowExecutionHistory`를 기본적으로 내려 실행 로그 잔상이 본문을 덮어 보이지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:08 (KST)
- AX Agent 내부 설정의 `도구`, `스킬/차단` 탭 접기 카드도 처음엔 모두 닫힌 상태로 열리게 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 로드된 스킬 섹션, 등록 도구/커넥터 카테고리 섹션, 등록 훅 섹션의 `CreateOverlayCollapsibleSection(...)` 기본 확장값을 모두 `false`로 바꿔, 내부 설정 진입 시 긴 목록이 한꺼번에 펼쳐지지 않게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 10:23 (KST)
- AX Agent 실행 경로를 `claw-code` 기준으로 한 단계 더 분리했습니다.
- [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 에 프롬프트 스택 조합, 실행 모드 판정, 최종 assistant 메시지 커밋을 모았고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SendMessageAsync()`는 이 엔진을 통해 `Chat / Cowork / Code` 전송 메시지를 준비하도록 정리했습니다.
- 같은 파일에 `RunAgentLoopAsync(...)`를 추가해 Cowork/Code의 중복된 에이전트 루프 실행 분기를 한 경로로 합쳤고, 완료 알림과 이벤트 핸들러 해제도 같은 패턴으로 묶었습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:11 (KST)
- AX Agent 입력창 높이 계산과 내부 설정 숫자 입력 방식도 다시 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateInputBoxHeight()`를 수동 `Height` 고정 방식에서 `MinLines / MaxLines` 기반 자동 높이 방식으로 바꿔, `Shift+Enter` 줄바꿈이 있을 때만 자연스럽게 늘어나고 빈 상태에서 높이가 누적돼 남는 현상을 줄였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `Temperature`, `오류 재시도`, `최대 Agent Pass`, `호출 간 딜레이`, `서브에이전트 최대 수`는 텍스트 입력 대신 슬라이더와 현재값 배지로 바꿨고, `Temperature``최대 Agent Pass`는 개발자 탭에서만 보이도록 다시 분류했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:11 (KST)
- 메인 설정에서 AX Agent 진입 위치도 좌측 사이드바로 옮겼습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 일반 탭 맨 아래에 있던 `AX Agent 설정 바로가기` 카드는 제거하고, 좌측 `MainSettingsTab``AX Agent` 전용 네비 항목을 추가했습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에서 이 새 사이드바 항목 선택 시 기존과 동일하게 AX Agent 채팅창과 내부 설정 오버레이를 바로 열도록 연결했고, 구형 숨김 탭만 가리도록 가시성 로직도 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:11 (KST)
- AX Agent 내부 설정의 남아 있던 숫자 입력 잔여 항목도 예전 설정창 패턴에 맞춰 슬라이더형으로 계속 이식했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `도구 훅 스크립트 제한 시간`, `슬래시 팝업 표시 개수`, `슬래시 핀 최대 개수`, `슬래시 최근 최대 개수`를 텍스트박스 대신 슬라이더 + 현재값 배지 구조로 바꾸고, 숨김 텍스트 필드는 저장 호환용으로만 유지했습니다.
- 같은 파일의 오버레이 동기화 경로(`RefreshOverlayVisualState`, `RefreshOverlayEtcPanels`)에도 해당 값들의 슬라이더/배지 동기화를 추가해, 섹션 전환이나 재오픈 후에도 값이 바로 맞춰 보이도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:22 (KST)
- 메인 설정에 남아 있던 구형 AX Agent 탭 본문도 실제 탭 컬렉션에서 제거해, 숨김 상태로 남아 있던 레거시 경로가 다시 선택되지 않도록 정리했습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 생성자에서 `AgentTabItem``MainSettingsTab.Items`에서 제거하고, `MainSettingsTab_SelectionChanged()`는 좌측 바로가기용 `AgentShortcutTabItem`만 AX Agent 내부 설정 오버레이로 라우팅하도록 단순화했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:24 (KST)
- 구형 AX Agent 본문이 로드 시점에 초기화되던 경로도 추가로 끊었습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에 `HasLegacyAgentTab()` 가드를 넣어, `MoveBlockSectionToEtc()`, `BuildServiceModelPanels()`, `BuildToolRegistryPanel()`, `LoadAdvancedSettings()`, `SyncAgentSelectionCards()`, `ApplyAgentSubTabVisibility()`가 실제 구형 AX Agent 탭이 컬렉션에 남아 있을 때만 실행되게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:25 (KST)
- 원래 설정에 있던 도구별 사용 토글도 AX Agent 내부 설정 `도구` 탭으로 옮겼습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildOverlayToolRegistryPanel()`에서 각 도구 카드 우측에 `ToggleSwitch`를 붙여, 카테고리별 접기 섹션 안에서 바로 도구 사용 여부를 바꿀 수 있게 했습니다.
- 도구 토글은 기존과 동일하게 `Llm.DisabledTools` 저장 경로를 그대로 사용하고, 변경 즉시 내부 설정 상태를 저장한 뒤 목록을 다시 그려 현재 상태가 바로 보이게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:27 (KST)
- 작업 지침에도 설정 입력 UI 통일 규칙을 추가했습니다.
- [AGENTS.md](/E:/AX%20Copilot%20-%20Codex/AGENTS.md) 의 `설정 UI 패턴` 섹션에 `on/off``ToggleSwitch`, 숫자 입력은 기존 슬라이더 + 현재값 배지 패턴을 우선 사용하고, 메인 설정과 AX Agent 내부 설정 간 표현 방식도 통일해야 한다는 규칙을 명시했습니다.
- 업데이트: 2026-04-05 11:28 (KST)
- 시작 직후 나던 AX Agent 프리워밍 예외도 수정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `TryGetOverlayLlmSettings()` 가드를 추가하고, 내부 설정 슬라이더 `ValueChanged` 핸들러들이 초기화 중 `_settings.Settings.Llm` 이 준비되지 않았을 때는 즉시 빠지도록 정리했습니다.
- 원인은 프리워밍 중 `SldOverlayMaxAgentIterations_ValueChanged`가 너무 일찍 발화하면서 null 경로를 건드리던 것이었고, 같은 유형이 다른 슬라이더에도 생기지 않도록 공통 방어로 같이 막았습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:31 (KST)
- 런처도 `Agent Compare` 기준으로 빠진 기능을 다시 이식하기 시작했습니다.
- [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs), [LauncherViewModel.LauncherExtras.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs), [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml), [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs), [LauncherWindow.Shell.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.Shell.cs)에 `빠른 실행 칩`, `검색 히스토리 위/아래 탐색`, `선택 항목 미리보기 패널`, `F3 QuickLook`, `F4 OCR`, 하단 `위젯 바`를 현재 런처 흐름에 맞게 다시 연결했습니다.
- [QuickActionChip.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/QuickActionChip.cs), [SearchHistoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SearchHistoryService.cs), [QuickLookWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/QuickLookWindow.xaml), [QuickLookWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/QuickLookWindow.xaml.cs) 도 새로 추가해, `Agent Compare` 쪽 런처 보조 기능이 현재 앱에서도 독립적으로 동작할 수 있도록 했습니다.
- [UsageRankingService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/UsageRankingService.cs) 에는 빠른 실행 칩 생성을 위한 `GetTopItems()`를 추가해, 최근 많이 쓴 경로를 런처 입력창 아래에서 바로 다시 열 수 있게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:58 (KST)
- 런처 보조 기능/설정 연결을 `Agent Compare` 기준으로 다시 대조하면서, 입력을 비웠을 때 이전 선택 항목과 미리보기 패널이 남아 있던 상태를 정리했습니다. [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 에서 빈 입력 시 `SelectedItem`과 미리보기 바인딩이 같이 초기화되도록 맞춰, 빠른 실행 칩/검색 히스토리/미리보기 패널 전환이 더 이상 이전 검색 상태를 끌고 가지 않게 했습니다.
- 검증: `Agent Compare`의 런처 설정 항목(`ShowNumberBadges`, `CloseOnFocusLost`, `RememberPosition`, `EnableActionMode`, `EnableRandomPlaceholder`, `ShowLauncherBorder` 등)과 현재 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs), [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 연결을 재검토했고, 런처 테마 동일화 작업은 제외한 상태에서 보조 기능/설정 연결 위주로 1차 마무리했습니다.
- 업데이트: 2026-04-05 11:56 (KST)
- AX Agent 채팅 엔진 정상화 1차로, [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 와 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 전송 흐름을 `준비 → 실행 → 최종 assistant 커밋 → 재렌더` 중심으로 다시 정리했습니다. Chat/Cowork/Code 공통으로 임시 assistant 카드와 임시 스트리밍 컨테이너를 먼저 만들지 않도록 바꿔, 토큰은 올라가는데 채팅 본문이 비거나 빈 버블이 남는 증상을 줄였습니다.
- 같은 수정에서 에이전트 실행 로그도 화면에 즉시 배너를 직접 꽂지 않고, 대화 모델의 `ExecutionEvents`에 먼저 쌓은 뒤 `RenderMessages()` 기준으로만 다시 그리게 바꿨습니다. 그래서 Cowork/Code에서 실행 로그 문구가 플래시처럼 잔상으로 남거나 중복 표시되던 흐름을 줄이는 쪽으로 정리했습니다.
- 재생성 경로도 동일하게 정리해서, 피드백 후 재생성 시 빈 assistant 메시지를 먼저 추가하지 않고 최종 응답만 커밋하도록 맞췄습니다.
- 이어서 `/slash` 로컬 응답과 수동 컨텍스트 압축 결과 경로도 conversation/session에 먼저 커밋한 뒤 `RenderMessages()`로만 다시 그리게 맞췄습니다. 이제 로컬 응답 경로도 직접 `AddMessageBubble(...)`를 꽂지 않아서, Chat/Cowork/Code에서 같은 종류의 중복 버블/순서 어긋남이 덜 발생하도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 12:06 (KST)
- 업데이트: 2026-04-05 12:09 (KST)
- AX Agent 채팅 UI는 `claw-code` 기준으로 다시 정리하기 전에 현재 상태를 `etc/chat-ui-backup/2026-04-05-1215/`에 백업했습니다. 이 백업에는 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 기준본이 포함되어 있습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 메시지 컬럼과 빈 상태 폭을 `920px` 축으로 맞추고, 컴포저를 `760px` 기준으로 넓히면서 입력 셸을 하나의 안정적인 하단 컬럼으로 다시 정리했습니다. 같은 수정에서 컴포저 안의 `대화 내보내기` 버튼은 숨겨 `claw-code`처럼 입력과 전송에 더 집중된 구조로 단순화했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 입력창 높이 계산은 이제 실제 줄바꿈 수만 기준으로 `Height`를 직접 다시 잡습니다. 전송 후에도 남아 있던 과도한 높이를 줄이고, `Shift+Enter`로 개행이 생길 때만 높이가 커지도록 더 강하게 고정했습니다.
- 같은 파일에서 `메시지 편집 후 재생성`, `피드백 후 재생성` 경로도 직접 `AddMessageBubble(...)`를 꽂지 않고 `RenderMessages()` 축으로 다시 돌리게 맞췄습니다. 재생성 경로 자체도 `Cowork/Code`에서는 일반 LLM 호출이 아니라 `ResolveExecutionMode(...)` + `RunAgentLoopAsync(...)`를 타도록 바꿔, 코워크/코드가 채팅 재생성 때 일반 Chat 경로로 잘못 떨어지던 문제를 줄였습니다.
- 이어서 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `PrepareExecution(...)`, `NormalizeAssistantContent(...)`를 추가하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 일반 전송과 재생성이 모두 같은 준비 함수를 타도록 정리했습니다. 이제 실행 모드 판정, 프롬프트 스택 구성, 전송 메시지 조립, 최종 assistant 내용 보정이 한 엔진 축에서 처리됩니다.
- 이 변경으로 `SendMessageAsync()``SendRegenerateAsync()`가 각자 따로 Cowork/Code 시스템 프롬프트와 실행 모드를 계산하던 중복 분기가 줄었고, 이후 Cowork/Code 엔진을 `claw-code` 기준으로 더 밀 때도 준비 로직은 엔진 한 곳만 고치면 되게 정리했습니다.
- 이어서 `FinalizeAssistantTurn(...)`를 엔진에 추가해, 최종 assistant 내용 정규화와 Cowork/Code 실행 로그 접힘 처리, assistant 메시지 커밋을 전송/재생성 공통으로 같은 메서드에서 처리하게 바꿨습니다. 이제 채팅 마무리 단계도 UI 코드가 아니라 엔진이 더 많이 책임집니다.
- 이번엔 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `ExecutePreparedAsync(...)`를 추가해서, 준비된 실행이 `AgentLoop`를 탈지 일반 LLM 호출을 탈지 결정하는 분기까지 엔진이 맡도록 옮겼습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 일반 전송과 재생성은 이제 둘 다 `ExecutePreparedAsync(...)`만 호출합니다.
- 이어서 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 실행 후처리도 `ResetStreamingUiState()`, `FinalizeConversationTurn()`, `FinalizeQueuedDraft()`로 묶었습니다. 전송과 재생성이 같은 정리 경로를 공유하게 해서, 응답 완료 뒤 상태 복구와 대화 저장, 대기열 완료/실패 처리 흐름도 더 한 축으로 정리했습니다.
- 이번엔 `OnAgentEvent(...)`의 본문 재렌더를 배치형으로 바꿨습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `DispatcherTimer` 기반 `ScheduleExecutionHistoryRender()`를 추가해서, Cowork/Code 실행 중 이벤트가 연속으로 들어와도 `RenderMessages()`가 매 이벤트마다 바로 돌지 않고 짧게 묶여 한 번씩만 반영됩니다.
- 같은 흐름으로 작업 요약 스트립도 배치형 갱신으로 바꿨습니다. `UpdateTaskSummaryIndicators()`를 즉시 호출하는 대신 `ScheduleTaskSummaryRefresh()`가 120ms 단위로 상태 반영을 묶어, 실행 중 상단 상태 스트립과 런타임 배지가 과하게 흔들리지 않도록 정리했습니다.
- 추가로 실행 이벤트/실행 기록 저장도 지연 저장으로 바꿨습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AppendConversationExecutionEvent()``AppendConversationAgentRun()`은 이제 이벤트마다 바로 `_storage.Save(...)`를 호출하지 않고, `ScheduleConversationPersist()`를 통해 220ms 단위로 묶어서 flush 합니다. Cowork/Code의 연속 이벤트 구간에서 저장 I/O가 덜 붙도록 만든 조정입니다.
- 이번엔 실행 완료 뒤 메시지 축을 흔들던 보조 UI를 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `RenderSuggestActionChips()`는 더 이상 본문 `MessagePanel`에 제안 칩을 직접 삽입하지 않고, 요약 토스트만 띄우도록 바꿨습니다. 이 변경으로 Cowork/Code 작업 중간에 제안 칩이 본문 폭과 스크롤 위치를 흔들던 경로를 끊었습니다.
- 같은 파일의 대기열 UI도 기본 축약형으로 바꿨습니다. `DraftQueuePanel`은 이제 기본적으로 요약 pill + 핵심 항목 1개만 보이고, 필요할 때만 `상세 보기`로 전체 섹션 카드(`실행 중/다음 작업/보류/완료/실패`)를 펼칩니다. 대기열 카드가 매번 크게 다시 그려지면서 컴포저 위 레이아웃을 밀던 현상을 줄이기 위한 정리입니다.
- 이어서 Cowork/Code 완료 직후 저장 축도 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ResetStreamingUiState()`는 이제 배치 저장 대기 중인 실행 이벤트/실행 기록을 먼저 `FlushPendingConversationPersists()`로 확정 저장한 뒤 타이머를 내립니다. 이걸로 실행 종료 직전 들어온 마지막 이벤트가 지연 저장 타이머만 멈춘 채 사라질 수 있는 경로를 막았습니다.
- 같은 수정에서 `PersistConversationSnapshot(...)`를 추가해 중간 저장, 최종 저장, 지연 저장 flush를 한 경로로 묶었고, `RunAgentLoopAsync(...)` 안의 중복 `_storage.Save(...)` / `RememberConversation(...)`는 제거했습니다. 이제 Cowork/Code 완료 시점 저장은 `FinalizeConversationTurn(...)` 쪽의 단일 완료 경로가 맡습니다.
- 이번엔 실행 이벤트가 들어올 때 창 코드가 즉시 많이 만지던 UI 갱신도 배치형으로 묶었습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `_agentUiEventTimer`, `ScheduleAgentUiEvent(...)`, `FlushPendingAgentUiEvent()`를 추가해서, 상태바/스티키 진행률/플랜 뷰어/파일 탐색기 자동 새로고침/제안 토스트/자동 프리뷰 반영이 가장 최근 이벤트 기준으로 90ms 단위로만 화면에 반영되게 했습니다.
- `OnAgentEvent(...)`는 이제 실행 이벤트 자체를 대화 모델과 앱 상태에 먼저 반영하고, 화면 갱신은 배치된 UI 이벤트 flush가 담당합니다. 이 조정으로 Cowork/Code 실행 중 빠른 이벤트 연속 구간에서 상태바와 진행률, 파일 미리보기 쪽이 따로따로 즉시 흔들리던 체감을 더 줄이는 방향으로 정리했습니다.
- 대기열 다음 작업 시작도 입력창 UI에 의존하지 않게 바꿨습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SendMessageAsync(...)`는 이제 선택적으로 직접 텍스트를 받을 수 있고, `StartNextQueuedDraftIfAny(...)`는 더 이상 `InputBox.Text`를 바꿔 포커스를 흔든 뒤 전송하지 않고 `SendMessageAsync(next.Text)`로 바로 실행합니다. 이걸로 Cowork/Code 자동 이어달리기가 입력창 상태를 덜 건드리게 됐습니다.
- 실패 후 재시도도 같은 방향으로 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `RetryLastUserMessageFromConversation()`는 이제 입력창에 마지막 요청을 다시 밀어 넣지 않고, 유휴 상태면 `SendMessageAsync(lastUserMessage)`로 바로 다시 실행하고, 이미 작업 중이면 같은 요청을 곧바로 대기열에 적재합니다. 재시도 동작도 입력창 포커스와 높이를 흔들지 않게 만든 조정입니다.
- 이어서 구형 본문 재시도 카드도 제거했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddRetryButton()` 경로를 걷어내고, 실패 시에는 본문에 임시 재시도 카드를 꽂지 않고 짧은 토스트로만 안내한 뒤 작업 요약/실패 이력 쪽 재시도 액션을 사용하도록 정리했습니다. 본문을 메시지와 상태 중심으로 유지하는 `claw-code` 방향에 더 가깝게 맞춘 것입니다.
- UI도 `claw-code` 기준으로 1차 정리를 넣었습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 메시지 컬럼 폭을 `880`으로 더 정리하고, 상단 진행률 바 패딩과 폭을 줄였으며, 빈 상태는 떠다니는 그라디언트 아이콘 대신 더 작고 정적인 카드형 아이콘으로 단순화했습니다. 컴포저도 `800px` 축으로 넓히면서 라운드와 그림자를 조금 눌러, 화면 장식보다는 메시지/입력 흐름이 먼저 보이게 다듬은 단계입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 12:24 (KST)
- 업데이트: 2026-04-05 12:31 (KST)
- 업데이트: 2026-04-05 12:36 (KST)
- 업데이트: 2026-04-05 12:41 (KST)
- 업데이트: 2026-04-05 12:47 (KST)
- 업데이트: 2026-04-05 12:53 (KST)
- 업데이트: 2026-04-05 12:58 (KST)
- 업데이트: 2026-04-05 13:03 (KST)
- 업데이트: 2026-04-05 13:12 (KST)
- 업데이트: 2026-04-05 13:20 (KST)
- 업데이트: 2026-04-05 13:29 (KST)
- 업데이트: 2026-04-05 13:37 (KST)
- 업데이트: 2026-04-05 13:44 (KST)
- 업데이트: 2026-04-05 13:52 (KST)
- 업데이트: 2026-04-05 14:00 (KST)
- 메시지 행 UI도 `claw-code` 기준으로 한 단계 더 눌렀습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `MessagePanel` 하단 여백을 더 줄여 본문 축이 컴포저와 가깝게 이어지도록 했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 사용자/assistant 메시지 카드의 좌우 마진, 코너 라운드, 패딩, 폰트 크기, 타임스탬프 크기를 전반적으로 낮췄습니다.
- assistant 헤더는 아이콘과 이름을 더 작고 옅게 줄였고, 액션 바 버튼도 패딩과 간격을 축소해 메시지 본문보다 덜 튀게 만들었습니다. 같은 방향으로 실행 로그 배너(`AddAgentEventBanner`)도 좌우 마진, 아이콘/라벨 크기, 토큰 배지와 요약 텍스트 밀도를 낮춰, Cowork/Code에서 로그가 메시지보다 먼저 보이던 느낌을 줄였습니다.
- 폭 계산도 `claw-code`처럼 반응형으로 다시 맞췄습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `ComposerShell` 고정폭을 걷어내고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `UpdateResponsiveChatLayout()`가 실제 본문 폭 기준으로 `MessagePanel`, `EmptyState`, `ComposerShell` 폭을 함께 다시 계산하도록 연결했습니다.
- 이제 창이 작아질 때 메시지 축과 입력창이 따로 놀지 않고 같은 축으로 같이 줄어들며, 창이 넓을 때는 적당한 상한을 유지한 채 자연스럽게 넓어집니다. 초기 로드와 `SizeChanged` 모두 같은 반응형 계산을 타도록 붙였습니다.
- 이어서 컴포저 상단 구조도 `claw-code` 방향으로 더 눌렀습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `InputBorder`, `DraftPreviewCard`, `DraftQueuePanel` 간격과 그림자를 줄였고, `BtnModelSelector`, `TokenUsageCard`, `BtnTemplateSelector`의 높이, 패딩, 아이콘/폰트 크기를 함께 낮춰 입력축보다 옵션 카드가 먼저 튀지 않게 정리했습니다.
- 토큰 카드도 원형 게이지와 텍스트, `압축` 버튼을 전반적으로 소형화해 상단 바가 두꺼운 툴 패널처럼 보이던 인상을 줄였습니다. 결과적으로 입력부는 더 얇은 하단 작업 바처럼 보이고, 메시지 본문 축과 시각적 우선순위가 덜 충돌하게 됐습니다.
- Cowork/Code 상태 UI도 더 얇게 조정했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`의 패딩과 폰트, 간격을 전반적으로 줄여 상태 바가 본문 위를 과하게 차지하지 않게 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 작업 요약 팝업도 제목/설명/최근 실행 카드 밀도를 낮추고 최근 실행 목록을 2개만 보여 주도록 줄였습니다. 이제 상태 UI는 더 보조적인 레이어로 남고, 메시지 본문이 먼저 읽히는 쪽으로 가까워졌습니다.
- 이어서 작업 요약 내부 카드도 더 가볍게 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `CreateTaskSummaryActionButton(...)`을 더 작은 버튼 규격으로 낮추고, 권한/훅/백그라운드 카드의 패딩과 마진도 한 단계 축소했습니다.
- 최근 권한 이력은 2개, 최근 훅은 3개, 최근 백그라운드 작업은 2개까지만 보여 주도록 줄여, 작업 요약 팝업이 긴 상태 대시보드처럼 커지지 않게 정리했습니다.
- 같은 축으로 Cowork/Code 보조 상태 레이어를 한 번 더 눌렀습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `LastCompletedLabel`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`는 패딩·폰트·간격을 추가로 줄여 상시 노출돼도 본문보다 덜 튀도록 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ShowTaskSummaryPopup()`, `CreateTaskSummaryActionButton(...)`, `BuildHookSummaryCard(...)`, `BuildActiveBackgroundSummaryCard(...)`, `BuildRecentBackgroundJobCard(...)` 도 같은 시각 언어로 다시 줄였습니다. 팝업 헤더/필터/최근 실행 카드/백그라운드 카드/훅 카드의 라운드, 패딩, 마진, 텍스트 크기를 전반적으로 낮춰 작업 요약이 진단용 보조 패널에 더 가깝게 보이게 했습니다.
- 업데이트: 2026-04-05 17:27 (KST)
- 메시지 자체 메타와 완료 카드 문구도 더 `claw-code` 쪽으로 눌렀습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddMessageBubble(...)` 에서 사용자/assistant 버블 패딩, 코너, 폰트, 타임스탬프, assistant 헤더 아이콘/이름 크기를 한 단계 더 낮춰 본문 텍스트가 더 먼저 읽히도록 조정했습니다.
- 작업 요약 팝업의 완료 카드도 `실행 run`, `최근 실패`, `최근 실행`, `로그`, `파일`, `후속 큐`, `다시 시도`, `타임라인`처럼 더 짧은 문구로 정리했고, run/step 메타와 요약 텍스트 폰트도 함께 낮춰 정보 밀도를 더 가볍게 맞췄습니다.
- 업데이트: 2026-04-05 17:33 (KST)
- Cowork/Code 실행 타임라인 배너도 더 `claw-code`처럼 얇게 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)` 에서 일반 실행 배너의 좌우 마진, 아이콘/라벨, 경과 시간, 토큰 pill, 요약 텍스트, 파일 경로 행을 한 단계 더 축소했고, 상세 review 칩은 `debug` 로그일 때만 보이게 제한했습니다.
- 이 조정으로 평소 Cowork/Code에서는 실행 이벤트가 더 짧은 한 줄 요약 중심으로 보이고, debug 정보는 필요할 때만 확장되도록 정리됐습니다.
- 업데이트: 2026-04-05 17:39 (KST)
- 상단 헤더도 더 `claw-code` 쪽 밀도로 줄였습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 상단 탭 버튼의 폰트/패딩/코너를 다시 낮추고, 탭 그룹 래퍼와 제목 서브 바의 높이와 패딩도 함께 줄였습니다.
- 같은 변경에서 대화 제목 폰트와 최대 폭, 빠른 스트립 버튼 규격, 프리뷰 토글 크기와 라벨도 더 작게 조정해 상단 보조 정보가 본문보다 덜 튀게 정리했습니다.
- 업데이트: 2026-04-05 17:45 (KST)
- 좌측 사이드바도 한 번에 더 `claw-code` 쪽 비율로 줄였습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 헤더, 새 대화/검색 액션, 검색 편집기, 필터 드롭다운, 탭별 메뉴, 전체 삭제, 하단 사용자/설정 영역까지 패딩·폰트·아이콘·배지 크기를 전반적으로 낮췄습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 실제 사이드바 폭을 `270 -> 248`로 줄이고, 대화 목록 카드의 패딩, 코너, 아이콘 열 폭, 제목/날짜/실행 메타 폰트, 편집 버튼 규격, 선택 액센트 바 두께도 함께 축소해 목록이 더 차분하게 보이도록 맞췄습니다.
- 업데이트: 2026-04-05 17:53 (KST)
- 큰 카드형 요소도 더 `claw-code` 쪽으로 눌렀습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddPlanningCard(...)` 에서 계획 카드 라운드, 패딩, 헤더 아이콘/텍스트, 진행률 텍스트, 단계 행 폰트를 전반적으로 줄였고, 계획 헤더 문구도 더 짧게 정리했습니다.
- 같은 변경에서 `CreateCompactEventPill(...)`, `CreateTimelineLoadMoreCard(...)`도 함께 축소해 컨텍스트 압축 pill과 “이전 대화 더 보기” 카드가 본문보다 과하게 두껍게 보이지 않도록 맞췄습니다.
- 업데이트: 2026-04-05 18:01 (KST)
- 엔진 마감도 한 단계 더 진행했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `ExecutePreparedTurnAsync(...)`를 추가해 `send``regenerate`가 같은 실행/예외/취소/최종 커밋/후처리 경로를 타도록 묶었습니다. 이제 전송과 재생성은 같은 prepared-execution 축에서 닫히고, 실패 토스트와 최종 assistant 커밋도 같은 helper가 담당합니다.
- 같은 변경에서 계획 이벤트는 기본적으로 큰 카드가 아니라 얇은 요약 pill로만 보이고, `debug` 로그일 때만 `AddPlanningCard(...)`가 펼쳐지도록 바꿨습니다. 문서형 Cowork/Code 작업에서도 기본 노출이 더 `claw-code`처럼 차분한 상태가 됐습니다.
- 업데이트: 2026-04-05 18:08 (KST)
- 좌측 패널과 하단 바도 `claw-code` 쪽 밀도로 다시 맞췄습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 사이드바 폭을 줄이고, 헤더 앱 배지를 강조색 채운 정사각형 대신 `HintBackground + BorderColor` 기반의 작은 배지형으로 바꿨습니다.
- `새 대화`, `검색`, `작업 유형/워크스페이스`, 하단 사용자 영역, 삭제 영역까지 패딩과 폰트, 아이콘 크기를 함께 낮췄고, 하단 상태바는 다이아몬드 아이콘을 작은 원형 점으로 바꿔 더 단순한 상태선처럼 보이게 정리했습니다.
- 실행 로그 배너도 본문 침범을 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)` 에서 debug 전용 `ToolInput` 카드 길이를 더 짧게 줄였고, `FilePath`는 일반 로그에서는 빠른 액션이 붙은 카드형 대신 파일명 한 줄만 약하게 표시하도록 바꿨습니다.
- 이제 파일 경로 카드와 빠른 액션은 `debug`일 때만 크게 보이고, 일반 Cowork/Code 로그에서는 파일명만 보조 정보처럼 붙습니다. 덕분에 실행 로그가 본문 아래에서 더 얇게 흐르도록 정리됐습니다.
- 대화 목록 행 카드와 축소 아이콘 바도 같은 시각 언어로 더 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddConversationItem(...)`에서 선택 강조 배경과 좌측 액센트 바 두께를 더 얇게 줄이고, 행 패딩/아이콘/폰트/배지 크기를 전반적으로 낮춰 `claw-code`처럼 목록 자체가 먼저 튀지 않도록 정리했습니다.
- `진행 중`, `성공`, `실패` 배지와 실행 요약 텍스트도 더 작고 중립적인 톤으로 줄였고, 호버 시 확대 애니메이션은 제거해 목록이 더 차분하게 반응하도록 맞췄습니다. 편집 버튼도 더 작은 규격과 낮은 opacity를 써서 필요할 때만 보조 액션으로 보이게 조정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 축소 아이콘 바는 상하 행 높이, 버튼 패딩, 아이콘 크기, 사용자 배지 크기를 한 단계 더 줄여 현재 사이드바와 같은 밀도로 묶었습니다. 이제 축소 상태에서도 검색/필터/새 대화 아이콘이 더 균일한 간격으로 정리되고, 하단 사용자 배지도 과하게 튀지 않는 중립형으로 유지됩니다.
- AX Agent 내부 설정 탭도 다시 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 사라졌던 `테마 스타일`, `테마 모드``공통` 탭에 실제 선택 카드로 복구했고, 기존 `스킬/차단` 탭은 `도구 / 스킬 / 차단`으로 나눠 각 항목이 맞는 탭에서만 보이게 재배치했습니다.
- 이제 `도구` 탭에서는 훅과 도구/커넥터 목록을, `스킬` 탭에서는 스킬 폴더, 슬래시 설정, 드래그 앤 드롭, 로드된 스킬, 폴백 모델, MCP 서버를, `차단` 탭에서는 차단 경로/확장자만 관리합니다. 같이 [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에서 메인 설정의 `AX Agent` 바로가기 탭을 좌측 사이드바 맨 아래로 재배치했습니다.
- 런처 하단 바도 요소별로 제어할 수 있게 바꿨습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 에 `성능 / 포모도로 / 메모 / 날씨 / 일정 / 배터리` 하단 위젯 표시 토글을 추가해서 일반 설정에서 항목별로 바로 켜고 끌 수 있게 했습니다.
- [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml), [LauncherWindow.Widgets.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.Widgets.cs) 에서는 `Ollama / API / MCP` 서버 상태 위젯을 런처 하단 기능에서 완전히 제거했고, 남은 위젯들만 설정값에 따라 실제 표시되도록 연결했습니다. 배터리 위젯도 노트북 상태와 사용자 토글을 함께 반영해 보이게 정리했습니다.
- `claw-code` 기준으로 계획 UX도 다시 눌렀습니다. [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 에서 저장된 `PlanMode` 값과 무관하게 런타임 계획 모드를 `off`로 고정해, 코워크/코드에서 매번 계획 승인 팝업이 뜨지 않도록 바꿨습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 메인 설정과 AX Agent 내부 설정의 `계획 모드` 행을 숨겼고, [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 에서도 항상 `off`만 저장/반영되게 정리했습니다.
- 계획 확인 팝업은 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [PlanViewerWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PlanViewerWindow.cs) 기준으로 AX Agent 창을 owner로 받아 리소스를 그대로 합치게 바꿨고, 채팅 본문에 별도 인라인 승인 버튼을 다시 꽂지 않도록 정리했습니다.
- 업데이트: 2026-04-05 16:20 (KST)
- `claw-code` 기준 UI/엔진 재구성 1차도 반영했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 AX Agent 메인 레이아웃의 사이드바 폭, 메시지 축, 빈 상태 카드, 컴포저 외곽선을 더 압축해 메시지 중심 구조로 다시 정리했고, 상단/보조 스트립과 토큰 카드 노출도 더 보수적으로 줄였습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 반응형 폭 계산을 다시 조정해 창이 좁아질 때 메시지 축과 컴포저가 같은 중심선을 따라 자연스럽게 줄어들게 했고, Chat 탭에서는 보조 상태 스트립을 거의 숨기고 Cowork/Code도 실패/승인 대기 같은 핵심 상태만 남기도록 정리했습니다.
- 엔진 쪽은 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 에 `FinalizeExecutionContent(...)` 를 추가해 취소/오류/빈 응답 정규화를 UI 바깥으로 넘겼고, 전송/재생성 마감 흐름이 같은 helper를 타도록 맞췄습니다. 내부 설정 오버레이 연결은 유지했습니다.
- 업데이트: 2026-04-05 16:33 (KST)
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 15:16 (KST)
- AX Agent 엔진 마감 2차로 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 UI용 마감 helper `FinalizeExecutionContentForUi(...)`, `NormalizeAssistantContentForUi(...)`를 추가했습니다. 취소/오류/빈 응답, Cowork/Code 완료 문구를 깨진 문자열이 아닌 정상 한국어 기준으로 정규화하도록 분리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `RegenerateLastAsync()``RetryWithFeedbackAsync(...)`가 더 이상 `MessagePanel.Children.RemoveAt(...)`로 마지막 assistant 버블을 직접 지우지 않고, 대화 상태를 먼저 수정한 뒤 `RenderMessages()`와 자동 스크롤로 다시 그리게 바꿨습니다. 재생성/피드백 재시도 흐름이 세션 상태 기준으로 더 일관되게 닫힙니다.
- 같은 변경에서 `Paused/Resumed` 실행 이벤트는 `debug`가 아닐 때 본문 타임라인에 기본 노출되지 않게 줄여 Cowork/Code 실행 중 시각적 노이즈를 더 낮췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:20 (KST)
- AX Agent 메인 UI도 `claw-code` 기준으로 한 번 더 크게 재배치했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 창 기본 크기, 사이드바 폭, 상단 헤더, 탭 그룹, 본문 스크롤 축, 빈 상태, 컴포저 외곽선과 입력부를 평평한 transcript 중심 구조로 다시 정리했고, 장식성 그림자와 두꺼운 카드 느낌을 더 많이 걷어냈습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 메시지 버블, assistant 헤더, 실행 요약 pill, 이전 대화 로드 카드, 계획 카드의 라운드/패딩/메타 밀도를 전반적으로 줄이고, 반응형 폭 계산도 `message 960 / composer 900` 축으로 다시 맞춰 창이 줄어들 때 `claw-code`처럼 더 자연스럽게 따라가도록 조정했습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `88%`, 채팅 메인 UI `94%`, Cowork/Code 상태 UX `89%`, 내부 설정 연결 `88%`, 전체 AX Agent `92%` 정도입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:30 (KST)
- Cowork/Code 보조 상태 레이어도 다시 최소 노출 기준으로 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`의 패딩·폰트·간격을 한 단계 더 줄여 상단/하단 보조 정보가 transcript보다 먼저 튀지 않도록 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ShowTaskSummaryPopup()`은 필터 칩과 과한 대시보드형 액션을 걷어내고, 최근 실행도 1건 중심의 요약 카드만 남기도록 줄였습니다. 활성/최근 작업 카드는 각각 `3/2`개만 노출하도록 낮췄고, 배경색도 팝업 테마와 같은 축을 쓰게 맞췄습니다.
- 같은 변경에서 `BuildHookSummaryCard(...)`, `BuildActiveBackgroundSummaryCard(...)`, `BuildRecentBackgroundJobCard(...)`, `AddTaskSummaryObservabilitySections(...)`를 더 보수적으로 정리해 훅/백그라운드/권한 이력이 기본 팝업을 점유하지 않게 했습니다. 백그라운드 작업은 현재 활성 상태만 짧게 요약하고, 세부 이동/필터 버튼은 대부분 제거해 `claw-code`처럼 “필요할 때만 보이는 진단 패널”에 가깝게 맞췄습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `88%`, 채팅 메인 UI `95%`, Cowork/Code 상태 UX `91%`, 내부 설정 연결 `88%`, 전체 AX Agent `93%` 정도입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:40 (KST)
- 실행 타임라인과 계획 카드도 기본 노출을 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)``debug`가 아닐 때 `ToolCall` 중간 이벤트를 숨기고, 일반 요약 문구도 더 짧은 길이로 잘라 본문보다 덜 튀게 조정했습니다.
- `AddPlanningCard(...)`는 계획 카드 라운드, 패딩, 헤더 텍스트, 단계 폰트와 최대 폭을 더 줄여 transcript 안의 보조 계획 메모처럼 보이게 바꿨고, `BuildTaskSummaryCard(...)``CreateTaskSummaryActionButton(...)` 도 카드/버튼 크기를 한 단계 더 낮췄습니다.
- 같은 정리에서 권한 작업 카드 액션은 `계획 모드` 버튼을 제거해 현재 엔진 정책과 UI가 다시 어긋나지 않게 맞췄습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `89%`, 채팅 메인 UI `96%`, Cowork/Code 상태 UX `92%`, 내부 설정 연결 `88%`, 전체 AX Agent `94%` 정도입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:49 (KST)
- 상단 탭과 하단 컴포저 일부는 사용자 피드백 기준으로 다시 복구했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 상단 `Chat / Cowork / Code` 탭은 너무 얇아졌던 pill 스타일을 되돌려 폰트와 패딩을 키우고, 래퍼 패딩도 약간 넓혀 예전처럼 더 또렷하게 보이도록 조정했습니다.
- 같은 파일에서 하단 컴포저의 `토큰 사용 카드``프리셋` 버튼이 같은 컬럼을 같이 써서 겹치던 문제를 수정했습니다. 모델/토큰/프리셋을 각각 독립 컬럼으로 분리했고, 관련 버튼과 레이블의 폰트/패딩도 함께 키워 하단 정보가 눌려 보이지 않게 다시 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:55 (KST)
- 작업 유형 카드 UX도 다시 다듬었습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()` 에서 카드 하단의 상시 설명 텍스트를 제거하고, 모든 카드 크기를 같은 규격으로 통일했습니다.
- 각 카드 설명은 이제 hover 시 카드 하단의 작은 라벨로만 보이게 바꿨고, 기존 확대 애니메이션은 제거해 배경/테두리만 반응하는 안정적인 hover로 정리했습니다. `기타`, `프리셋 추가` 카드도 같은 규칙으로 맞췄습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 빈 상태 제목/설명 폰트도 함께 키워 이 화면 전반의 글자 크기가 너무 작아 보이지 않게 보정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:02 (KST)
- AX Agent 내부 설정 공통 탭의 서비스 전환 UX를 다시 바로잡았습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 서비스 상세 영역을 `주소 입력``API 키` 패널로 분리해 이름을 부여했고, `테마 스타일``테마 모드`도 서비스/모델 바로 아래에서 보이도록 공통 설정 상단으로 옮겼습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `RefreshOverlayServiceFieldVisibility(...)` 를 추가해 `Gemini/Claude` 선택 시 주소 입력 패널을 접고 API 키만 전체 폭으로 보이게 만들었습니다. `Ollama/vLLM` 은 기존처럼 주소와 키를 함께 보여줍니다.
- 같은 파일의 `SetOverlayService(...)` 는 저장 직후 `RefreshOverlayVisualState(true)` 를 다시 호출하도록 바꿔, 현재 서비스/현재 모델/라벨이 즉시 갱신되게 했습니다. 이제 Gemini를 눌렀는데도 `현재 서비스=Ollama`, `Ollama 서버 주소`가 남아 있는 어긋남을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:10 (KST)
- 상단 탭도 사용자 피드백 기준으로 다시 되돌렸습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `TopTabBtn` 스타일에서 폰트 크기, 패딩, 외곽선, 선택 배경을 다시 키워 첫 번째 레퍼런스 이미지처럼 더 도톰한 pill 세그먼트 느낌으로 조정했습니다.
- 탭 래퍼 배경은 `LauncherBackground` 기준으로 바꾸고, 선택 탭은 같은 계열 배경 + 얇은 테두리가 보이도록 바꿨습니다. 라벨도 `채팅 / Cowork / 코드`로 맞춰 이전보다 읽기 쉬운 상단 내비로 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:16 (KST)
- `claw-code``StatusLine + PromptInput + Messages` 기준으로 AX Agent 보조 상태 노출을 한 단계 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `ConversationQuickStrip` 은 단순 카운트가 있을 때 자동 노출되지 않고, 사용자가 실제로 `진행 중만 보기` 또는 정렬 전환을 켰을 때만 보이도록 바꿨습니다.
- 같은 파일에서 상단 `ConversationStatusStrip``권한 대기 / 실패 / 권한 거부`만 유지하고, `queue`, `queue_blocked` 같은 보조 상태는 기본 화면에서 숨기도록 정리했습니다. Cowork/Code transcript가 queue 상태 배너로 과하게 시끄럽던 부분을 줄이기 위한 변경입니다.
- `AgentEventType.Planning` 도 더 `claw-code`처럼 기본 transcript에서는 큰 계획 카드 대신 얇은 compact pill만 남기도록 바꿨습니다. 이제 debug가 아니면 계획이 카드 형태로 본문을 밀어내지 않습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:24 (KST)
- AX Agent 좌측 패널과 작업 유형 카드의 크기를 다시 키웠습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 사이드바 폭을 `246`으로 넓히고 `새 대화`, `검색`, 필터 드롭다운, 탭별 메뉴의 폰트/패딩/아이콘을 전반적으로 키워 첫 번째 레퍼런스 이미지처럼 더 읽기 쉽게 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()` 에서는 작업 유형 카드 크기를 `148 x 124`로 늘리고, 아이콘 원형과 제목·hover 설명 라벨 폰트도 함께 키워 현재 화면 글자 크기가 너무 작게 보이던 문제를 보정했습니다.
- 하단 컨텍스트 사용량 UI도 카드형에서 심볼형으로 바꿨습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `TokenUsageCard` 는 작은 원형 심볼만 남기고, 상세 정보는 `TokenUsagePopup` 커스텀 팝업으로 분리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 hover 진입/이탈 시 팝업을 제어하고, `RefreshContextUsageVisual()` 이 심볼 상태와 팝업 텍스트를 함께 갱신하도록 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:38 (KST)
- `claw-code` 대비 AX Agent 남은 차이와 제거 후보 설정도 다시 정리했습니다. [docs/claw-code-parity-plan.md](/E:/AX%20Copilot%20-%20Codex/docs/claw-code-parity-plan.md)에 현재 추정 진척율(`core engine 89% / main UI 96% / runtime UX 92% / overall 93%`), 남은 엔진/UI 차이, 런타임 영향 설정 정리안을 기록했습니다.
- 같은 점검에서 작업유형 카드 hover 깜박임 원인은 `hover 라벨`과 기본 WPF `ToolTip`이 동시에 켜져 마우스가 카드 경계와 툴팁 사이를 오가며 상태가 흔들리는 구조로 확인됐습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 프리셋/기타/프리셋 추가 카드의 기본 `ToolTip` 을 제거하고 hover 라벨만 남겨 깜박임을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:42 (KST)
- `PlanMode` 잔재도 런타임 기준으로 한 단계 더 걷어냈습니다. [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 에서 실행 전 plan prelude 진입은 비활성 플래그 기준으로만 남기고, 기본 실행 경로가 `planMode` 값에 의해 흔들리지 않도록 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 과 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 숨김 상태였던 inline/overlay 계획 모드 click/selection 잔재를 제거해 dead UI code를 더 줄였습니다. 사용자 노출 정책은 그대로 `off` 고정입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:49 (KST)
- AX Agent 프리셋 첫 화면 레이아웃을 다시 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `EmptyState``Stretch + ScrollViewer` 구조로 바꿔 Chat/Cowork 프리셋 카드 하단이 잘리던 문제를 줄였고, `TopicPresetScrollViewer` 최대 높이와 내부 패딩도 함께 키웠습니다.
- 같은 파일에 헤더 중앙 `SelectedPresetGuide` 를 추가해, 대화 주제나 작업 유형을 선택하면 선택된 항목과 설명이 다시 상단 중앙에 안내되도록 복구했습니다. 상단 탭 pill 그룹도 배경/패딩/폰트를 다시 키워 이전보다 더 또렷하게 보이도록 보정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateSelectedPresetGuide(...)` 를 추가하고 `UpdateChatTitle()`, `SelectTopic(...)` 에 연결했습니다. 이제 프리셋 선택 직후뿐 아니라 대화 재오픈/탭 전환 시에도 선택된 주제 안내가 다시 살아납니다.
- 사이드바 하단 사용자 영역의 설정 버튼도 `32x32`, 아이콘 `15px` 기준으로 키워 너무 작게 보이던 문제를 함께 보정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:59 (KST)
- AX Agent의 `PlanMode` 잔재를 실제 런타임 정책에 맞게 더 걷어냈습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 내부 설정 저장/로드 시 `Code.EnablePlanModeTools` 를 항상 `false` 로 강제하고, `OverlayTogglePlanModeTools` 는 더 이상 화면에 노출되지 않게 접었습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에 남아 있던 메인 설정의 `플랜 모드`, `Plan Mode 도구` UI도 숨기고 카드 상태는 `off` 고정으로 맞췄습니다. 사용자에게 보이는 설정과 실제 엔진 정책을 일치시키기 위한 정리입니다.
- Cowork/Code 상태바 소음도 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateStatusBar(...)` 는 이제 `debug` 로그가 아닐 때 `ToolCall`, `SkillCall`, `Paused`, `Resumed` 이벤트로 상태줄을 흔들지 않습니다. `claw-code`처럼 기본 transcript와 상태선이 더 차분하게 유지되도록 맞춘 변경입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:08 (KST)
- `PlanModeTools` 기본값도 런타임 정책과 맞췄습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 에서 `PlanMode`, `EnablePlanModeTools` 를 레거시 호환용 설명으로 정리하고, `EnablePlanModeTools` 기본값을 `false` 로 변경했습니다.
- Cowork/Code 후속 큐 요약은 더 `claw-code`처럼 최소 노출로 바꿨습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 compact queue 요약은 이제 기본적으로 `실행 / 다음 / 실패`만 표시하고, `보류`, `완료` 배지는 `상세 보기`를 펼쳤을 때만 보입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:15 (KST)
- 재시도/후속 큐 적재 경로도 한 축으로 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `EnqueueDraftRequest(...)` helper를 추가해 `조정 요청`, `후속 작업`, `분기 후속 작업`, `재시도 직접 실행`이 모두 같은 대기열 생성 경로를 타도록 맞췄습니다.
- 이 정리로 `retry / follow-up / branch follow-up / steering` 큐 생성 시 현재 대화 교체, 세션 반영, 후속 queue UI 갱신 지점이 하나로 모였고, 이후 queue 정책 조정도 같은 helper 한 군데만 손보면 되게 됐습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:21 (KST)
- 하단 보조 UI도 더 `claw-code`처럼 최소 노출로 조정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 draft queue는 기본 상태에서 `실행 / 다음 / 실패`가 하나도 없으면 아예 접히도록 바꿨습니다. 완료/보류만 남은 경우에는 기본 화면에서 보이지 않습니다.
- 같은 파일의 컨텍스트 사용량 hover 팝업도 긴 진단 문자열 대신 2줄 요약으로 정리했습니다. 현재 모델의 오늘 사용량과 `compact 후 첫 응답 대기` 또는 자동 압축 시작 임계치만 보여주도록 줄여 하단 작업 바 밀도를 더 가볍게 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:27 (KST)
- 하단 queue/status 기본 노출을 한 단계 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 queue 패널은 이제 `실행 중 / 다음 / 실패` 항목이 없으면 기본 화면에서 열리지 않고, 정리성 상태(`완료 / 보류`)만 남은 경우엔 접힌 상태를 유지합니다.
- 같은 파일의 컨텍스트 hover 팝업도 과한 진단성 문자열을 걷고, 현재 모델 오늘 사용량과 compact 상태만 보여주는 짧은 2줄 요약으로 유지되게 다듬었습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:34 (KST)
- Cowork/Code 상단 활동 배지도 더 조용하게 만들었습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateTaskSummaryIndicators()` 는 이제 실제 진행 중 대화나 활성 작업이 있을 때만 `RuntimeActivityBadge` 를 보이게 합니다. idle 상태에서는 상단 보조 메타가 남아 있지 않습니다.
- 같은 파일의 `UpdateAgentProgressBar(...)` 에 남아 있던 미사용 debug 체크 변수도 제거해 상태 갱신 경로를 조금 더 단순화했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:40 (KST)
- `claw-code` 기준으로 남아 있던 plan 도구 레거시도 더 잘랐습니다. [AgentTabSettingsResolver.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTabSettingsResolver.cs) 는 이제 저장값과 무관하게 `enter_plan_mode`, `exit_plan_mode` 를 항상 비활성 목록에 넣습니다. plan mode 도구는 완전 레거시 호환 영역으로만 남고 실제 code 실행 경로에는 개입하지 않습니다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 운영 상태 요약도 더 `claw-code`처럼 조용하게 맞췄습니다. 기본 `RuntimeActivityBadge` 와 상단 strip 은 이제 실제 실행/권한 대기/백그라운드 작업이 있을 때만 살아나며, 단순 queue/재시도 대기만으로는 기본 헤더를 흔들지 않습니다.
- 이번 정리 후 추정 parity 는 `core engine 92% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 92% / overall 96%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:48 (KST)
- plan mode 도구를 기본 runtime registry에서도 제거했습니다. [ToolRegistry.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolRegistry.cs), [SkillService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SkillService.cs), [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 에서 `enter_plan_mode`, `exit_plan_mode` 등록과 별칭을 빼서, 숨겨진 레거시 도구가 기본 AX Agent 도구셋에 더 이상 섞이지 않게 했습니다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 `RuntimeLabel` 도 queue/재시도 대기 수를 기본 라벨에서 제거해 `실행 / 승인 대기 / 백그라운드` 중심으로 단순화했습니다. `claw-code`처럼 기본 transcript는 더 읽기 중심으로, queue는 필요할 때만 보조 UI로 보이게 맞춘 변경입니다.
- 이번 정리 후 추정 parity 는 `core engine 94% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 93% / overall 97%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:56 (KST)
- 상태 모델의 `PlanMode` 잔재도 걷어냈습니다. [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 `PermissionPolicyState` 에 남아 있던 `PlanMode` 필드를 제거하고, 설정 로드 시에도 더 이상 이 값을 상태 모델에 복사하지 않게 했습니다. 이제 AX Agent 기본 상태 모델은 실제 정책상 살아 있는 권한/결정/override 정보만 유지합니다.
- 이번 정리 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 93% / overall 97%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:03 (KST)
- 메인 설정과 AX Agent 내부 오버레이에 남아 있던 숨김 `PlanMode` UI 잔재도 추가로 제거했습니다. [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 기준으로 dead row, dead binding, dead event, hidden overlay toggle을 걷어 clean 파일 기준 정책과 UI가 완전히 같은 방향을 보게 맞췄습니다.
- 현재 검색상 남은 `PlanMode` 잔재는 JSON 호환용 [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 와 별도 구형 [AgentSettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs) 정도입니다. 현행 사용 경로 기준으로는 핵심 엔진/메인 설정/내부 오버레이 쪽 레거시 정리는 거의 끝난 상태입니다.
- 이번 정리 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 95% / overall 98%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:12 (KST)
- 구형 [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml), [AgentSettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs) 에 남아 있던 `계획 모드`, `Plan Mode 도구` UI와 관련 save/load/event 코드도 제거했습니다. 현재 clean 파일 기준 검색상 남은 `PlanMode`/`EnablePlanModeTools` 참조는 JSON 호환용 [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 와 안전 고정용 [SubAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SubAgentTool.cs) 정도입니다.
- 이번 정리 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 97% / overall 99%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:20 (KST)
- 마지막 레거시 호환용 필드도 제거했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 의 `planMode`, `enablePlanModeTools` JSON 필드를 삭제했고, [SubAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SubAgentTool.cs) 의 `llm.PlanMode = "off"` 안전 고정 대입도 함께 제거했습니다. 이제 clean 파일 기준 검색상 `PlanMode` / `EnablePlanModeTools` 참조는 0입니다.
- 이번 정리 후 추정 parity 는 `core engine 100% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 100% / overall 99%` 정도로 재평가했습니다. 남은 차이는 레거시 설정이 아니라 세부 transcript UI/UX polish 영역입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:29 (KST)
- transcript UI 마감 쪽으로 계획 카드도 더 `claw-code`처럼 compact하게 바꿨습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `AddPlanningCard(...)` 는 이제 기본 상태에서 단계 목록을 접고, `계획 n단계 + 진행률 + 펼치기` 요약만 먼저 보여줍니다. 필요할 때만 `펼치기/접기`로 상세 단계를 보게 해 본문 읽기 흐름을 덜 방해하도록 조정했습니다.
- 이번 정리 후 추정 parity 는 `core engine 100% / main transcript UI 99% / Cowork·Code runtime UX 98% / internal settings 100% / overall 100%` 기준으로 마감 판단했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:36 (KST)
- 마지막 transcript polish로 하단 상태 메타와 quick strip도 더 `claw-code`처럼 조용하게 맞췄습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 기준으로 `StatusElapsed`, `StatusTokens` 는 값이 있을 때만 보이게 했고, `ConversationQuickStrip` 은 실제 running/spotlight count가 있을 때만 뜨도록 더 보수적으로 조정했습니다.
- 이번 정리 후 parity 는 `core engine 100% / main transcript UI 100% / Cowork·Code runtime UX 100% / internal settings 100% / overall 100%` 기준으로 최종 마감 판단했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:43 (KST)
- 업데이트: 2026-04-05 22:18 (KST)
- transcript 품질 향상 2차로 도구/스킬 표시 카탈로그를 [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs) 로 분리했습니다. 이제 transcript 배지와 task summary 카드가 `파일 / 빌드 / Git / 문서 / 질문 / 제안 / 스킬` 같은 역할 중심 라벨을 공통으로 사용합니다.
- [ChatWindow.TranscriptPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs) 를 추가해 transcript badge/summary/task-summary policy를 partial helper로 분리했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 task summary popup 은 active task 우선, recent history 는 debug 또는 active 없음일 때만 보이도록 축소했습니다.
- 동일 프롬프트 회귀 세트는 [docs/AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md) 로 별도 분리해 Chat/Cowork/Code/queue/permission/slash 시나리오를 바로 비교할 수 있게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 예정
- 업데이트: 2026-04-05 22:24 (KST)
- 런처 하단 위젯의 신규 기본값을 모두 꺼짐으로 변경했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 에서 `성능 / 포모도로 / 메모 / 날씨 / 일정 / 배터리` 위젯 기본값을 `false` 로 내려 새 설치나 설정 초기화 시 하단 위젯이 기본 비노출 상태로 시작합니다.
- 업데이트: 2026-04-05 22:31 (KST)
- AX Agent 내부 설정 공통 탭에서 `테마 스타일`, `테마 모드`를 맨 위로 올려 서비스/모델보다 먼저 보이게 재배치했습니다. 같이 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 좌측 패널 토글 버튼은 3줄 햄버거가 아니라 좌측 패널/본문 구조가 보이는 패널 토글 아이콘으로 변경해 메뉴 아이콘과 혼동되지 않게 정리했습니다.
---
@@ -440,3 +1060,103 @@ MIT License
- 업데이트: 2026-04-05 19:38 (KST)
- AX Agent 상단 탭이 깨져 보이던 문제를 수정하고, `채팅 / Cowork / 코드` pill 세그먼트 형태를 안정적으로 복구했다.
- 업데이트: 2026-04-05 19:41 (KST)
- AX Agent 빈 상태 상단 심볼은 더 크게 키우고, 대화 주제/작업 유형 프리셋 카드 내부 아이콘은 한 단계 줄여 시각 균형을 조정했다.
- 업데이트: 2026-04-05 19:46 (KST)
- 모델 빠른 설정 팝업에서 하단 중복 모델 칩 줄을 제거하고, 하단 모델 라벨은 서비스+모델 조합 대신 현재 모델명만 보이도록 더 심플하게 정리했다.
- 업데이트: 2026-04-05 19:53 (KST)
- 작업 폴더 선택 팝업을 검색/요약 중심 구조에서 `최근 폴더 목록 + 현재 선택 체크 + 다른 폴더 선택` 형태로 단순화해 더 빠르게 고를 수 있게 정리했다.
- 업데이트: 2026-04-05 20:01 (KST)
- 선택된 대화 주제/작업 유형 안내 배너를 헤더 중앙에서 입력창 위 중앙으로 옮겨 실제 작성 흐름에 더 가깝게 보이도록 조정했다.
- 업데이트: 2026-04-05 20:08 (KST)
- 좌측 사이드바에서 상단 필터와 중복돼 보이던 탭별 보조 필터 메뉴를 숨겨 필터가 하나만 보이도록 정리했다.
- 업데이트: 2026-04-05 22:34 (KST)
- AX Agent 좌측 사이드바에서 `주제 / 작업 유형 / 워크스페이스` 보조 필터 메뉴를 완전히 숨기고, 상단 공통 필터 드롭다운 하나만 남겨 중복 필터처럼 보이던 구조를 정리했다.
- 업데이트: 2026-04-05 22:39 (KST)
- Cowork/Code 하단 작업 폴더 바에서 불필요한 폴더 해제 `X` 버튼을 제거하고, 구분선과 권한/데이터 활용 버튼 정렬을 다시 맞춰 더 단정한 한 줄 흐름으로 정리했다.
- 업데이트: 2026-04-05 22:44 (KST)
- AX Agent 내부 설정에서 `호출 간격 최적화`, `의사결정 수준` 실행 방식 블록은 `코워크/코드` 공통 탭에만 남기고, `코워크``코드` 개별 탭에서는 숨겼다. 함께 레거시 `실행 전 계획` 행도 UI에서 제거했다.
- 업데이트: 2026-04-05 22:48 (KST)
- AX Agent 내부 설정의 `최대 컨텍스트 토큰` 프리셋에 `32K`, `128K` 중간값을 추가하고, 현재 저장값이 중간 구간에 있을 때도 가장 가까운 프리셋 카드가 자연스럽게 선택되도록 매핑을 보강했다.
- 업데이트: 2026-04-05 22:53 (KST)
- 하단 컨텍스트 토큰 라벨이 hover 후 남아 있던 문제를 수정하고, 토큰 심볼/팝업의 흐린 배경·그림자 느낌을 줄여 더 깔끔한 테두리 중심 스타일로 정리했다.
- 업데이트: 2026-04-05 22:57 (KST)
- 채팅/코워크 프리셋 카드 hover 시 설명 라벨을 `Collapsed/Visible`로 토글하던 방식을 없애고, 같은 자리에서 `Opacity`만 바꾸도록 조정해 카드가 깜빡이듯 다시 그려지던 현상을 줄였다.
- 업데이트: 2026-04-05 20:17 (KST)
- 런처의 클립보드 히스토리/클립보드 변환/순차 붙여넣기 실행 경로를 공통 포커스 복원 helper 기반으로 정리했다. 이전 활성 창 복원, 최소 대기, Ctrl+V 주입 순서를 [ForegroundPasteHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ForegroundPasteHelper.cs) 로 통일해 포커스가 원래 창으로 돌아가지 않아 붙여넣기가 누락되던 문제를 줄였다.
- 업데이트: 2026-04-05 20:17 (KST)
- AX Agent 메시지 transcript에서 사용자/assistant 행 여백을 더 끝단 기준으로 재정렬하고, `AX 에이전트` 라벨과 시간 표기 크기를 키워 메타 가독성을 보강했다. 반영 위치는 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 이다.
- 업데이트: 2026-04-05 23:02 (KST)
- AX Agent 새 대화 전환 경로를 실제 fresh conversation 생성 기준으로 수정했다. 기존에는 현재 대화를 저장한 뒤 LoadOrCreateConversation()을 다시 호출해 최신 저장 대화를 재로드하는 경로가 섞여 있어, 첫 화면이 잠깐 깜빡인 뒤 기존 대화가 그대로 남는 문제가 있었다. 이제 ClearCurrentConversation() 뒤에는 항상 새 대화를 생성하고, 대화별 설정/압축 메트릭/앱 상태를 새 conversation 기준으로 다시 동기화한 후 빈 transcript를 렌더한다.
- 업데이트: 2026-04-05 23:09 (KST)
- AX Agent 좌측 패널의 타이포를 전반적으로 키웠다. 헤더, 새 대화, 검색, 상단 필터, 탭별 보조 메뉴, 전체 삭제, 하단 사용자 영역 폰트와 아이콘 크기를 함께 조정하고, 사이드바 폭도 소폭 넓혀 더 이상 지나치게 작고 빽빽하게 보이지 않도록 정리했다. 대화 목록 카드 제목/시간/실행 메타도 함께 키워 실제 읽을 수 있는 수준으로 보정했다.
- 업데이트: 2026-04-05 23:15 (KST)
- AX Agent 좌측 패널과 본문 사이 경계선을 드래그해 사이드바 폭을 직접 조절할 수 있게 했다. 사이드바가 열려 있을 때만 splitter가 보이며, 사용자가 조절한 폭은 닫았다 다시 열어도 유지된다.
- 업데이트: 2026-04-05 23:22 (KST)
- AX Agent 상단 탭 헤더 높이와 탭 래퍼 패딩을 늘려 채팅 / Cowork / 코드 글자가 잘리던 문제를 보정했다. 또 사용자/assistant/streaming 메시지가 같은 transcript 폭 컨테이너를 공유하도록 바꿔 좌우 정렬 기준이 어긋나 보이던 문제를 정리했다.
- 업데이트: 2026-04-05 23:28 (KST)
- AX Agent 작업 폴더 선택 팝업의 최근 항목 스타일을 레퍼런스처럼 더 단정한 라운드 row 구조로 정리했다. 각 최근 폴더 항목은 개별 카드형 hover/선택 상태를 가지도록 바꾸고, `다른 폴더 선택`도 같은 시각 언어로 맞췄다. 팝업 외곽 radius, 그림자, 여백, 체크 위치, 텍스트 계층도 함께 조정해 더 자연스럽게 보이게 했다.
- 업데이트: 2026-04-05 23:33 (KST)
- AX Agent 빈 상태 상단 심볼과 작업 유형/대화 주제 프리셋 카드 아이콘 비율을 다시 맞췄다. 프리셋 카드 내부 아이콘과 `프리셋 추가` 심볼은 한 단계 줄이고, 상단 중앙 심볼과 제목도 같은 밀도로 재조정해 화면 균형을 정리했다.
- 업데이트: 2026-04-05 23:38 (KST)
- 하단 컨텍스트 토큰 hover 라벨이 마우스를 떼어도 남아 있던 문제를 보정했다. 닫힘 판정을 단순 `IsMouseOver` 대신 실제 마우스 좌표 기준으로 바꾸고, 팝업 내용도 `현재 사용량 + 자동 압축 기준`만 남기도록 단순화했다.
- 업데이트: 2026-04-05 23:44 (KST)
- 권한 모드 팝업에서 불필요한 `상세 정보` 섹션을 제거하고, 선택 가능한 모드 행만 남기는 단순 리스트형 UI로 정리했다. `계획 모드`는 제외하고 실제 사용하는 권한 요청/편집 자동 승인/권한 건너뛰기 중심으로 재정렬했다.
- 업데이트: 2026-04-05 23:49 (KST)
- AX Agent 상단 중앙 탭의 글자가 잘리지 않도록 탭 버튼 폰트/패딩/최소 크기와 헤더 래퍼 크기를 소폭 줄였다. 함께 빈 상태의 상단 아이콘과 제목/설명은 한 단계 키워 프리셋 카드 한 장과 시각 비율이 더 자연스럽게 맞도록 조정했다.
- 업데이트: 2026-04-05 23:55 (KST)
- 하단 컨텍스트 토큰 심볼의 파이 아크가 왼쪽에서 잘려 보이던 문제를 수정했다. 원형 아크 계산 기준을 실제 카드 크기에 맞게 조정하고, hover 라벨은 비상호작용 툴팁처럼 바꿔 카드에서 마우스를 벗어나면 더 깔끔하게 사라지도록 정리했다.
- 업데이트: 2026-04-06 00:01 (KST)
- 하단 컨텍스트 토큰 hover 라벨이 남아 있던 문제를 창 전체 마우스 이동/클릭/비활성화 기준으로 한 번 더 보강해 줄였다. 함께 전송 버튼은 크기와 아이콘 정렬을 다시 맞춰 작고 치우쳐 보이던 인상을 보정했다.
- 업데이트: 2026-04-06 00:08 (KST)
- AX Agent 내부 설정의 등록 모델 영역에서 상단 중복 선택 칩 UI를 제거하고, 하단 등록 모델 리스트만 남기도록 정리했다.
- 등록 모델 리스트의 `선택 / 편집 / 삭제` 액션은 기본 버튼 대신 팝업 친화적인 클릭 row 스타일로 바꿔 내부 설정 오버레이에서도 더 안정적으로 동작하게 맞췄다.
- 업데이트: 2026-04-06 00:14 (KST)
- AX Agent 상단 중앙 탭 그룹의 버튼 패딩과 최소 크기, 외곽 래퍼 높이를 소폭 줄여 탭이 지나치게 꽉 찬 느낌 없이 여유 있게 보이도록 정리했다.
- 업데이트: 2026-04-06 00:22 (KST)
- `claw-code`와 AX Agent 소스 구조를 다시 대조해 transcript renderer 분리, permission presentation catalog, tool result taxonomy, plan approval inline 마감, runtime summary 계층화, regression prompt ritual 고정까지 포함한 품질 향상 계획을 문서에 구체화했다.
- 업데이트: 2026-04-06 00:27 (KST)
- AX Agent 채팅창의 기본 시작 높이를 소폭 늘려, 처음 열었을 때 상하 여백과 프리셋 영역이 더 여유 있게 보이도록 조정했다.
- 업데이트: 2026-04-06 00:31 (KST)
- AX Agent 상단 중앙 탭 그룹의 버튼 padding, 최소 폭/높이와 바깥 pill 래퍼 높이를 한 단계 더 줄였다. 이제 탭 바깥 테두리 안쪽 여백이 더 살아 있어, 레퍼런스처럼 답답하지 않은 세그먼트 탭 비율로 보인다.
- 업데이트: 2026-04-06 00:38 (KST)
- vLLM 연결 시 등록 모델 alias/실제 모델 ID가 섞여 전달되던 경로를 보정했다. 내부 서비스(Ollama/vLLM)는 현재 선택값이 alias여도 등록 모델의 실제 모델명을 다시 찾아 요청 payload에 넣도록 정리했다.
- vLLM OpenAI-compatible 요청의 `max_tokens`는 서버 허용 범위를 넘지 않도록 자동 보정했다. 일반 대화와 도구 호출 모두 같은 상한 계산을 써 `invalid max_tokens` 오류가 덜 나도록 맞췄다.
- 업데이트: 2026-04-06 00:48 (KST)
- AX Agent 새 대화 전환 시 저장되지 않은 fresh conversation이 최신 저장 대화로 다시 교체되던 세션 복원 경로를 보정했다. [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs) 의 `LoadOrCreateConversation()`이 기억된 대화 ID가 없는 상태에서도 현재 탭의 임시 fresh conversation을 우선 유지하도록 바꿔, 새 대화를 누르면 빈 화면이 잠깐 깜빡인 뒤 기존 대화가 다시 나타나던 문제를 막았다.
- 업데이트: 2026-04-06 01:00 (KST)
- AX Agent 메시지 hover 액션을 보강해 복사/편집/재생성/수정 후 재시도/좋아요·싫어요가 실제로 보이도록 정리했다. 사용자/assistant 메시지 액션 바를 완전 숨김 대신 기본 저강도 노출 + hover 강조 방식으로 바꿔, 마우스를 올렸을 때 액션이 안 보이던 문제를 줄였다.
- assistant 응답에는 응답시간과 총 토큰 수를 메시지 메타로 저장해 transcript 아래에 함께 표시되게 했다. 반영 위치는 [ChatModels.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/ChatModels.cs), [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 이다.
- 업데이트: 2026-04-06 01:08 (KST)
- `claw-code``SessionPreview`/`PreviewBox` 흐름을 참고해 AX Agent 프리뷰도 같은 시각 언어로 정리했다. 새 파일 [AgentPreviewSurfaceFactory.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentPreviewSurfaceFactory.cs)를 추가해 권한 프리뷰 카드의 제목/요약/본문 박스 구조를 공통화했다.
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs)의 일반 프리뷰, 파일 편집 프리뷰, 파일 생성 2열 프리뷰를 이 공통 surface로 맞춰 `preview box` 언어를 통일했다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 우측 파일 프리뷰 패널에는 파일명/경로/형식·크기 메타를 보여주는 헤더를 추가하고, 텍스트 프리뷰 본문도 별도 bordered preview box 안에 렌더되게 바꿨다.
- 업데이트: 2026-04-06 00:35 (KST)
- AX Agent 채팅/코워크 프리셋 카드에서 기본 ToolTip을 제거해 hover 시 깜빡이듯 반복되던 현상을 줄였습니다.
- 업데이트: 2026-04-06 00:42 (KST)
- 코드 탭 하단 Git 브랜치 버튼을 상태판 형태에서 단순한 브랜치 선택 버튼 형태로 정리했습니다.
- 업데이트: 2026-04-05 22:26 (KST)
- 코드 탭에서는 폴더 문서/파일을 기본 작업 전제로 삼도록 `폴더 내 데이터 활용`을 항상 `적극 활용(active)`으로 강제했다. 하단 채팅창의 데이터 활용 버튼은 코드 탭에서 숨기고, 내부 설정 오버레이의 같은 옵션도 코드 탭에서는 노출하지 않게 정리했다.
- 코워크/코드 탭의 사용자 메시지도 assistant 메시지와 같은 파일 경로 강조 렌더러를 쓰도록 바꿔, 폴더 하위 파일명이나 경로를 입력하면 채팅 본문에서 파란색으로 인식되게 맞췄다.
- 업데이트: 2026-04-05 22:29 (KST)
- AX Agent 채팅/코워크 프리셋을 선택할 때, 메시지도 입력도 없는 fresh conversation인데도 `새 대화`가 반복 생성되던 흐름을 보정했다. 이제 현재 대화가 이미 있으면 그 빈 대화에 프리셋만 적용하고, 실제 대화가 아예 없는 경우에만 새 대화를 만든다.
- 업데이트: 2026-04-05 22:32 (KST)
- AX Agent 내부 설정 개발자 탭의 `워크플로우 시각화`, `전체 호출·토큰 합계 표시`, `감사 로그` 토글이 누르자마자 꺼지는 문제를 수정했다. 각 토글의 변경 이벤트를 연결해 즉시 저장되도록 보정했다.
- 업데이트: 2026-04-05 22:36 (KST)
- AX Agent 내부 설정 `도구 훅 실행 타임아웃``등록된 훅` 영역에서 잘림이 보이던 레이아웃을 보정했다. 슬라이더/값 배지 컬럼 폭과 `훅 추가` 버튼 최소 폭을 넉넉히 늘려 텍스트와 컨트롤이 서로 밀리지 않게 정리했다.
- 업데이트: 2026-04-05 22:40 (KST)
- AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다.
- 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `system / light / dark` 모드 조합으로 같은 방식으로 적용되도록 정리했다.
- 업데이트: 2026-04-06 00:58 (KST)
- AX Agent transcript 품질 향상을 위해 렌더 책임을 실제로 분리했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)를 추가해 `의견 요청`, `계획 승인`, `작업 요약` UI 로직을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에서 분리했다.
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)를 추가해 권한 요청과 도구 결과를 `명령/네트워크/파일`, `성공/실패/거부/취소` 기준으로 나눠 transcript badge에 재사용하도록 정리했다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState``GetOperationalStatusPresentation(...)`을 추가해 status/runtime summary 계산을 전용 요약 모델로 한 번 더 계층화했다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 상태선 갱신은 이제 이 presentation summary를 소비한다.
- 업데이트: 2026-04-06 01:12 (KST)
- AX Agent 코워크/코드의 `폴더 내 문서 활용`을 사용자 옵션에서 제거했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 에서 하단 버튼, 내부 설정 행, 구형 설정창 항목을 걷어냈다.
- 런타임은 옵션이 아닌 자동 정책으로 유지한다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 채팅은 `none`, 코워크는 `passive`, 코드는 `active`를 자동 적용하고, 더 이상 오버레이 저장 시 `FolderDataUsage`를 사용자 선택값으로 저장하지 않는다.
- 업데이트: 2026-04-06 01:24 (KST)
- `claw-code` 기준 transcript 품질 향상을 위해 권한 요청/도구 결과/도구 이름 display catalog를 다시 정리했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 파일/문서/빌드/Git/웹/스킬/질문 카테고리를 더 명확한 한국어 display name과 badge label로 분류하고, [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 presentation으로 나누도록 보강했다.
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success / error / reject / cancel`을 도구 종류에 따라 `파일 작업 완료`, `빌드/테스트 실패`, `웹 요청 거부`처럼 더 읽기 쉬운 결과 라벨로 바꾸도록 확장했다.
- transcript renderer 분리 2차로 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)를 추가해 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 옮겼다. 이제 메인 파일은 대화 흐름과 상태 처리에 더 집중하고, 이벤트 배너 렌더는 별도 partial에서 관리한다.

View File

@@ -0,0 +1,79 @@
# AX Agent Regression Prompts
업데이트: 2026-04-05 22:18 (KST)
`claw-code`와 AX Agent를 같은 기준으로 비교하기 위한 회귀 프롬프트 세트입니다.
## Chat
1. 기본 답변
- 프롬프트: `회의 일정 조정 메일을 정중한 한국어로 써줘`
- 확인:
- 빈 assistant 카드 없음
- 재생성/재시도 후 transcript 중복 없음
2. 장문 설명
- 프롬프트: `RAG와 fine-tuning 차이를 실무 관점으로 7가지로 설명해줘`
- 확인:
- 장문 렌더 유지
- compact 이후 다음 턴 문맥 유지
## Cowork
3. 문서형 작업
- 프롬프트: `신규 ERP 도입 제안서 초안을 작성해줘. 목적, 범위, 기대효과, 추진일정 포함`
- 확인:
- 작업 유형 반영
- 계획 이후 실제 문서형 결과 흐름
- 기본 로그 과다 노출 없음
4. 데이터형 작업
- 프롬프트: `매출 CSV를 분석해서 월별 추세와 이상치를 요약해줘`
- 확인:
- 데이터 분석형 도구 선택
- 결과 요약 품질
- runtime 노이즈 최소화
## Code
5. 버그 수정
- 프롬프트: `현재 프로젝트에서 설정 저장 버그 원인 찾고 수정해줘`
- 확인:
- 읽기/검색/수정 흐름 일관성
- diff 저장
- reopen 시 transcript 보존
6. 빌드/테스트
- 프롬프트: `빌드 오류를 재현하고 수정한 뒤 다시 빌드해줘`
- 확인:
- build/test 루프
- 실패 후 재시도
- 완료 메시지 정합성
## Cross-tab
7. 후속 큐
- 순차 프롬프트:
- `이 창 레이아웃 문제 원인 찾아줘`
- `끝나면 README도 같이 갱신해줘`
- 확인:
- queue chaining
- 입력창 직접 변형 없이 다음 턴 수행
8. compact 이후 연속성
- 프롬프트: `지금까지 논의한 내용을 5줄로 이어서 정리하고 다음 작업 제안해줘`
- 확인:
- token-only completion 없음
- compact 후 문맥 유지
9. 권한 승인
- 프롬프트: `이 파일을 수정해서 저장해줘`
- 확인:
- 승인 요청 transcript 표시
- 승인/거부 후 결과 정합성
10. slash / skill
- 프롬프트: `/bug-hunt src 폴더 잠재 버그 찾아줘`
- 확인:
- slash 진입과 일반 send 경로 동일성
- skill 실행 이유/결과 표기

View File

@@ -1,5 +1,49 @@
# AX Copilot - 媛쒕컻 臾몄꽌
- Document update: 2026-04-06 09:03 (KST) - Split common themed popup construction out of `ChatWindow.xaml.cs` into `ChatWindow.PopupPresentation.cs`. Shared popup container creation, generic popup menu items/separators, and the recent-folder context menu now live in a dedicated partial instead of the main window orchestration file.
- Document update: 2026-04-06 09:03 (KST) - This keeps footer/file-browser popup styling on a single visual path and reduces direct popup composition inside the main chat window flow, making further `claw-code` style popup UX work easier to maintain.
- Document update: 2026-04-06 07:31 (KST) - Split permission presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.PermissionPresentation.cs`. Permission popup row construction, popup refresh, section expansion persistence, and permission banner/status styling now live in a dedicated partial instead of the main window orchestration file.
- Document update: 2026-04-06 07:31 (KST) - Split context usage card/popup rendering into `ChatWindow.ContextUsagePresentation.cs`. The Cowork/Code context usage ring, tooltip popup copy, hover close behavior, and screen-coordinate hit testing are now isolated from the rest of the chat window flow.
- Document update: 2026-04-06 01:37 (KST) - Reworked AX Agent plan approval toward a more transcript-native flow. The inline decision card remains the primary approval path, while the `계획` affordance now opens the stored plan as a detail-only surface instead of acting like a required popup step.
- Document update: 2026-04-06 01:37 (KST) - Expanded `OperationalStatusPresentationState` so runtime badge, compact strip, and quick-strip labels/colors/visibility are calculated together. `ChatWindow` now consumes a richer presentation model instead of branching on strip kinds and quick-strip counters independently.
- Document update: 2026-04-05 19:04 (KST) - Added transcript-facing tool/skill display normalization in `ChatWindow.xaml.cs`. Tool and skill events now use role-first badges (`도구`, `도구 결과`, `스킬`) with human-readable item labels such as `파일 읽기`, `빌드/실행`, `Git`, or `/skill-name`, instead of exposing raw snake_case names as the primary visual label.
- Document update: 2026-04-05 19:04 (KST) - Reduced task-summary observability noise by limiting permission/background observability sections to debug-level sessions. Default AX Agent runtime UX now stays closer to `claw-code`, where transcript reading flow remains primary and diagnostics stay secondary.
- Document update: 2026-04-05 18:58 (KST) - Switched `UserAskCallback` in `ChatWindow.xaml.cs` to a transcript-first inline card flow. `user_ask` no longer defaults to `UserAskDialog`; it renders an in-stream question card with choice pills, direct text input, and submit/cancel actions, then returns only the chosen answer to the engine.
- Document update: 2026-04-05 18:58 (KST) - Aligned user-question UX with the same transcript-first principle already used for plan approval. `PlanViewerWindow` remains a secondary detail surface, while the primary approval/question decision now happens inside the AX Agent message timeline.
- Document update: 2026-04-05 16:55 (KST) - Recorded the current `claw-code` parity estimate for AX Agent: core execution engine `82%`, main chat UI `68%`, Cowork/Code status UX `63%`, internal settings linkage `88%`, overall AX Agent parity `74%`.
- Document update: 2026-04-05 16:55 (KST) - Added an engine-settings review rule for ongoing cleanup: settings that materially alter the main execution route should be minimized, kept developer-only when necessary, or removed from user-facing surfaces when they no longer represent real runtime choices. Plan-mode remnants were reduced further as part of this pass.
- Document update: 2026-04-05 16:55 (KST) - Simplified AX Agent message rows and sidebar conversation items toward the `claw-code` reading model. Message bubbles now use tighter padding/radius/meta text, and conversation rows now prefer lightweight running/failure summary text over heavier success/failure badge cards.
- Document update: 2026-04-05 17:03 (KST) - Reworked the AX Agent UI skeleton further toward `claw-code`: sidebar width and row heights were tightened, the collapsed icon rail and sidebar header were flattened, status strips were reduced, and the composer shell/input chrome was made slimmer and less card-like.
- Document update: 2026-04-05 17:03 (KST) - Re-tuned responsive width calculation for the new shell so the message axis now scales up to `880` and the composer up to `820`, keeping both aligned on the same center line as the window narrows or expands.
- Document update: 2026-04-05 17:12 (KST) - Reduced message action chrome and event-banner density further toward `claw-code`. Message actions now use compact icon-only buttons, and Cowork/Code event banners use tighter margins, smaller metadata, and lighter file-path summaries so execution status stays subordinate to the main transcript.
- Document update: 2026-04-05 17:19 (KST) - Tightened the top header/tab group and sidebar conversation-row chrome further toward `claw-code`. The top tabs now use a slimmer segmented control, the sidebar toggle button is smaller/lighter, and conversation-row metadata plus the edit affordance were reduced so the title remains the dominant visual anchor.
- Document update: 2026-04-05 16:02 (KST) - Fixed the Cowork document execution handoff around `document_plan`. The loop no longer depends on broken localized marker strings to detect the scaffold/body block or the immediate-next-step hint; it now extracts body markers robustly and resolves the correct follow-up tool (`html_create`, `document_assemble`, `docx_create`, `markdown_create`) before re-prompting the model.
- Document update: 2026-04-05 16:02 (KST) - Added `ResolveEffectivePlanMode(...)` so Cowork document/content tasks automatically use the `always` plan path even when the persisted plan mode is `off`. This brings Cowork closer to the `claw-code` expectation of plan-first execution for document-heavy work.
- Document update: 2026-04-05 16:02 (KST) - Strengthened `BuildCoworkSystemPrompt()` so document/report/proposal/manual requests must produce an execution plan first and are not considered complete until a real output file path has been created or updated.
- Document update: 2026-04-05 15:34 (KST) - Rebased the AX Agent improvement plan on actual `claw-code` runtime files instead of prior AX snapshots. The active reference spine is `src/bootstrap/state.ts -> src/bridge/initReplBridge.ts -> src/bridge/sessionRunner.ts -> src/screens/REPL.tsx -> src/components/Messages.tsx -> src/components/StatusLine.tsx`.
- Document update: 2026-04-05 15:34 (KST) - Locked the AX implementation order to the same quality sequence used by that spine: runtime state canonicalization, prepared execution unification, loop event normalization, timeline render parity, composer/status strip simplification, and recovery/resume validation.
- Document update: 2026-04-05 15:42 (KST) - Moved the cross-tab conversation restoration path for execution events and agent-run history from `ChatWindow.xaml.cs` into `AxAgentExecutionEngine`. `AppendExecutionEvent` and `AppendAgentRun` now go through one engine-owned session mutation helper, which preserves the active tab conversation while updating the target tab timeline.
- Document update: 2026-04-05 15:42 (KST) - Verified the first runtime-state/common-engine step with `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` and confirmed warning 0 / error 0.
- Document update: 2026-04-05 07:11 (KST) - Simplified the AX Agent footer for Cowork/Code by removing the duplicated `MoodIconPanel` chip group from those tabs and leaving workspace context only in the main folder path row. Also removed the outline border from the data-usage button so the footer option strip reads flatter and less pill-heavy.
- Document update: 2026-04-05 07:08 (KST) - Improved AX Agent responsiveness in three hot paths: added an ordered meta cache in `ChatStorageService` so repeated conversation-list refreshes stop re-sorting the full meta set every time, short-circuited `SaveConversationSettings()` when permission/data-usage/mood/output-format values are unchanged, and debounced the sidebar conversation search refresh to avoid re-filtering on every keystroke.
- Document update: 2026-04-05 02:00 (KST) - Reworked the AX Agent in-chat gear overlay navigation itself to match the restored internal settings taxonomy: `basic / chat / cowork / code / dev / tools / skill-block`. The left nav labels now follow that scheme, and the overlay rows/toggles are regrouped per tab instead of the earlier `common / service / permission / advanced` split.
- Document update: 2026-04-05 01:46 (KST) - Corrected the AX Agent settings migration target after verifying that the in-chat gear icon opens `AgentSettingsOverlay` inside `ChatWindow`, not the standalone `AgentSettingsWindow`. Moved the missing advanced controls into the live overlay surface: project-rules toggle, agent-memory toggle, max agent iterations, and code tool toggles for plan/worktree/team/cron.
- Document update: 2026-04-05 01:40 (KST) - Extended the in-chat AX Agent settings window behind the internal gear icon so it now owns additional settings that previously remained in the main settings surface or quick overlay: default output format, default mood, project-rules toggle, agent-memory toggle, max agent iterations, and code tool toggles for plan/worktree/team/cron.
- Document update: 2026-04-05 01:40 (KST) - Reduced the bottom composer's capsule feel by tightening the corner radius on the footer option buttons, token usage card, glow border, and input container so the AX Agent footer reads as a denser rectangular control row.
- Document update: 2026-04-05 01:35 (KST) - Changed the AX Agent Cowork conversation filter from workspace/project grouping to task-type grouping so the sidebar dropdown, current label, and list filtering all follow the preset/category model used by Cowork topic cards.
- Document update: 2026-04-05 01:35 (KST) - Kept the Code tab on its prior workspace-based project filter to avoid collapsing code conversations into the Cowork task-type taxonomy.
- Document update: 2026-04-05 01:22 (KST) - Moved more of the hidden AX Agent settings surface into the in-chat internal settings window: tool exposure cards, hook management, skills folder path, slash popup size, drag-drop AI toggles, fallback model selection, and MCP server management are now available directly there.
- Document update: 2026-04-05 01:22 (KST) - Reduced the overly pill-shaped data-usage control in the chat bottom bar by switching its capsule border to a rounded rectangle, keeping the footer visuals closer to the rest of the AX Agent option row.
- Document update: 2026-04-05 01:15 (KST) - Audited the AX Agent internal settings save flow and removed direct mutation of the live settings object during pre-save tab interaction. Service/theme/display selections now stay local to the window until explicit save.
- Document update: 2026-04-05 01:15 (KST) - Restored persistence for the internal AX Agent window's missing save surfaces: `VllmAllowInsecureTls`, active-service model sync, and compatibility writes back to `Llm.Model` for the runtime resolver.
- Document update: 2026-04-05 01:12 (KST) - Restored the AX Agent in-chat settings classification toward the older hidden-tab layout. The internal settings window now follows the earlier `basic / chat / cowork / code / dev / tools / skill-block` navigation baseline instead of only the later flattened grouping.
- Document update: 2026-04-05 01:12 (KST) - Reconnected `AiEnabled` and `AgentUiExpressionLevel` persistence from the AX Agent internal settings window, and removed the normalization path that forcibly re-enabled AI after save so the restored toggle can persist again.
- Document update: 2026-04-05 00:58 (KST) - Compared the launcher implementation under `Agent Compare/AX Copilot` with the current AX Commander and imported the missing launcher handler set into the live app registration flow, excluding only the compare app's AI-coupled launcher handlers.
- Document update: 2026-04-05 00:58 (KST) - Added launcher-side support files for the imported feature set: quick-link/session/schedule/macro/SSH settings models, scheduler/tag/notification-history/icon-cache/url-template/pomodoro services, editor windows, launcher position persistence fields, and QR/OCR build dependencies.
- Document update: 2026-04-05 00:52 (KST) - Normalized the composer/footer wording so model, data-usage, permission, and Git branch surfaces read in the same status language instead of mixing short labels and raw values.
- Document update: 2026-04-05 00:52 (KST) - Added state-colored border cues and clearer tooltips for the data-usage and permission chips so the bottom status row communicates the active mode more directly.
@@ -4110,3 +4154,755 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- ?섎룞 `/compact`?€ ?꾩넚 ???먮룞 而⑦뀓?ㅽ듃 ?뺤텞??紐⑤몢 媛숈? `RecordCompactionStats(...)` 寃쎈줈瑜??ъ슜?섎룄濡?留욎떠, ?뺤텞 寃곌낵媛€ ?쇳쉶???곹깭 硫붿떆吏€?먮쭔 癒몃Ъ吏€ ?딄퀬 UI?먯꽌 ?ъ갭議?媛€?ν븯?꾨줉 ?뺣━?덈떎.
- ?대쾲 ?쇱슫?쒕???`claude-code`??compact ?뚯뒪瑜?湲곗??쇰줈 `session memory compaction ??microcompact ??context collapse/snip ??autocompact ??post-compaction 怨꾩륫` ?먮쫫??AX 湲곗?怨?鍮꾧탳 遺꾩꽍??以€鍮꾨? 吏꾪뻾?덈떎.
- 寃€利? dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (寃쎄퀬 0 / ?ㅻ쪟 0)
### 2026-04-05 추가 진행 기록 (설정 메뉴 단순화)
- 업데이트: 2026-04-05 07:17 (KST)
- 별도 `AX Agent 설정` 창에서 `AX Agent 사용`, `표현 수준` 항목을 숨겨 기본 탭 상단에 불필요한 전역 토글과 표현 레벨 카드가 보이지 않도록 정리했습니다.
- AX Agent 내부 설정 오버레이에서도 `AX Agent 사용` 행을 숨겨, 톱니 설정 메뉴가 실제 작업 옵션 위주로 보이도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (스킬 목록 복구)
- 업데이트: 2026-04-05 07:24 (KST)
- `Agent Compare` 기준으로 메인 `설정 > AX Agent`에만 남아 있던 스킬 목록 표시 흐름을 다시 확인하고, 별도 `AX Agent 설정` 창의 `스킬/차단` 탭에 로드된 스킬 카드 목록을 복구했습니다.
- 스킬 목록은 `SkillService`에서 읽은 현재 스킬을 내장/고급 그룹으로 나눠 보여주고, 저장 시 스킬 폴더 경로 기준으로 재로드해 즉시 반영되도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (트레이 메뉴 오픈 지연 완화)
- 업데이트: 2026-04-05 07:39 (KST)
- `Agent Compare`의 트레이 메뉴 표시 경로와 현재 구현을 비교해, 현재판에서 메뉴를 띄울 때마다 `Show()``UpdateLayout()`로 실제 크기를 다시 확정하던 지점을 병목으로 확인했습니다.
- `TrayMenuWindow`는 메뉴 항목이 바뀔 때만 크기 캐시를 무효화하고, 우클릭 직전에는 보이기 전에 `Measure/Arrange`로 크기만 계산한 뒤 바로 위치를 잡아 표시하도록 변경했습니다.
- `App`에서는 트레이 메뉴 생성 직후 `ApplicationIdle` 우선순위로 `PrepareForDisplay()`를 호출해 첫 우클릭 이전에 레이아웃을 한 번 미리 준비하게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 설정 뒤로가기 위치 정리)
- 업데이트: 2026-04-05 07:45 (KST)
- AX Agent 내부 설정 오버레이의 `뒤로가기` 버튼이 오른쪽 본문 헤더에 따로 떠 있어 패널 접기/닫기 동작처럼 보이던 배치를 정리했습니다.
- 왼쪽 설정 네비 상단의 `설정 / AX Agent` 제목을 화살표가 붙은 하나의 뒤로가기 헤더 버튼으로 바꾸고, 기존 오른쪽 상단 버튼은 제거했습니다.
- 본문 `ScrollViewer`는 헤더 빈 줄 없이 바로 시작하도록 `Grid.RowSpan`과 패딩을 조정해 설정 진입 시 시선 흐름이 끊기지 않게 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (사이드바 접기 버튼 Claude형 정리)
- 업데이트: 2026-04-05 07:49 (KST)
- AX Agent 상단 헤더 왼쪽의 사이드바 토글 버튼을 기존 큰 MDL2 아이콘 기반 고스트 버튼에서, 작은 라운드 사각 안에 3줄 메뉴가 들어간 Claude 계열 토글로 교체했습니다.
- 호버 시에는 외곽선과 선 색만 액센트로 바뀌도록 제한해 상단 바가 과하게 튀지 않게 했고, 버튼 크기도 30px 기준으로 맞춰 헤더 밀도를 정리했습니다.
- 코드비하인드에서 열림/닫힘마다 `ToggleSidebarIcon.Text`를 바꾸던 처리도 제거해, 새 버튼 구조와 상태 관리가 충돌하지 않도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (Chat 상단 여백 및 압축 UI 분리)
- 업데이트: 2026-04-05 07:54 (KST)
- Chat 빈 상태 화면의 대표 아이콘이 상단 탭 메뉴와 너무 가까워 보이던 배치를 조정하기 위해, `EmptyState` 상단 스택의 마진을 늘려 시각 충돌을 줄였습니다.
- `RefreshContextUsageVisual()`에 탭 분기를 추가해 Chat에서는 `TokenUsageCard`를 바로 숨기고, Cowork/Code에서만 컨텍스트 사용량과 `압축` 버튼이 보이도록 정리했습니다.
- 이 변경으로 Chat 입력 하단은 더 단순해지고, 에이전트 작업 성격이 강한 Cowork/Code에서만 토큰 압축 UI가 유지됩니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (런처 하단 상태 문구 자동 숨김 보정)
- 업데이트: 2026-04-05 08:00 (KST)
- 런처 하단 `IndexStatusText`가 사라지지 않고 남는 사례를 추적한 결과, 인덱스 상태 표시와 토스트 오버레이가 같은 `_indexStatusTimer`를 공유해 서로의 자동 숨김 타이머를 덮어쓸 수 있는 구조를 확인했습니다.
- `LauncherWindow``_toastTimer`를 분리하고, 인덱스 재구축 시작/완료 문구는 `ShowIndexStatus(...)` 공통 경로로 묶어 상태 문구가 독립적으로 일정 시간 후 사라지도록 수정했습니다.
- 이 변경으로 캡처/복사/즐겨찾기 토스트가 뜨는 동안에도 하단 색인 상태 문구의 숨김 타이밍이 꼬이지 않습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (배포 스크립트 경로 정리 및 보호 상태 명시)
- 업데이트: 2026-04-05 08:14 (KST)
- 루트 [build.bat](/E:/AX%20Copilot%20-%20Codex/build.bat)을 전면 정리해, 현재 작업 디렉터리에 따라 상대 경로가 꼬이던 `dist`, `payload.zip`, 인스톨러 exe 복사 경로를 모두 `%~dp0` 기준 절대 경로로 고정했습니다.
- 배포 순서는 `AxCopilot publish -> 난독화 도구 확인 -> AxKeyEncryptor publish -> payload.zip 생성 -> AxCopilot.Installer build -> dist 메타데이터 정리`로 고정했고, 배치 파일은 `pause` 없이 자동 실행 환경에서도 종료되도록 수정했습니다.
- 보호 상태를 점검한 결과, 현재 레포에는 ConfuserEx / Dotfuscator / Babel / Eazfuscator / SmartAssembly 같은 외부 난독화 도구 연결이 없고, `Security/ObfuscationAttributes.cs`와 Release 메타데이터 제거 설정만 존재합니다.
- 따라서 현재 배포본은 `PDB/XML/debug metadata 제거` 수준의 보강만 적용되며, 진짜 디컴파일 방지는 아직 미구성 상태임을 스크립트 경고로 명확히 출력하게 했습니다.
- 검증: `cmd /c build.bat` 실행 기준 메인 앱 publish, `payload.zip` 생성, `AxCopilot.Installer` 빌드, `dist\AxCopilot_Setup.exe` 복사, dist 정리까지 정상 완료
### 2026-04-05 추가 진행 기록 (AX Agent 공통 항목 숨김 및 도움말 툴팁 복구)
- 업데이트: 2026-04-05 07:59 (KST)
- 메인 `설정 > AX Agent > 공통` 화면에서 사용자가 반복 요청한 `AX Agent 사용`, `표현 수준` 행이 실제로 남아 있던 경로를 다시 확인하고, 해당 `AgentSettingsRow` 두 줄을 `Visibility="Collapsed"`로 숨겼습니다.
- `SettingsWindow.xaml.cs``SettingsService.cs`에서는 `AgentUiExpressionLevel``rich`로 강제 정규화해, UI에서 표현 수준을 노출하지 않아도 내부 설명 밀도는 항상 `풍부하게` 기준으로 유지되도록 맞췄습니다.
- `SettingsWindow.xaml``HelpTooltipStyle`은 라이트 테마에서 흰 글자가 옅은 배경에 묻히던 문제를 피하도록 툴팁 배경을 진한 남색 계열로 고정하고, 전경색/텍스트 전경색을 흰색으로 통일했습니다.
- 이 변경으로 `응답 설정 > Temperature`, `PDF 내보내기 기본 경로`, `팁 알림 표시 시간`, 코드/도구/스킬 관련 `?` 도움말 라벨이 라이트/다크 테마 모두에서 같은 대비로 보입니다.
### 2026-04-05 추가 진행 기록 (런처 마지막 위치 기억 복구)
- 업데이트: 2026-04-05 07:59 (KST)
- `Agent Compare`와 현재판 설정/런처 코드를 대조해 빠져 있던 `Launcher.RememberPosition` 바인딩을 `SettingsViewModel``SettingsWindow`에 다시 연결했습니다.
- `LauncherWindow`는 표시 직전 저장된 좌표가 유효한 화면 범위 안에 있으면 그 위치를 복원하고, 창 이동 중에는 메모리 캐시만 갱신한 뒤 숨겨질 때 설정 파일에 마지막 좌표를 저장하도록 정리했습니다.
- 이 변경으로 `AX Commander`를 닫은 자리에서 다시 여는 비교본 동작이 현재판에서도 되살아났고, 드래그 중 매 프레임 저장하던 식의 불필요한 디스크 쓰기도 피했습니다.
### 2026-04-05 추가 진행 기록 (AX Agent 열기 실패 직접 원인 수정)
- 업데이트: 2026-04-05 08:02 (KST)
- 앱 로그(`%APPDATA%\AxCopilot\logs\app-2026-04-05.log`)를 확인한 결과, AX Agent 열기 실패의 직접 원인은 `ChatWindow` 초기화 시 `HelpTooltipStyle`을 찾지 못해 발생한 `XamlParseException`이었습니다.
- `ChatWindow.xaml`에는 `HelpTooltipStyle`을 참조하는 `?` 툴팁이 존재했지만, 해당 스타일은 메인 설정 창/별도 설정 창 내부에만 정의되어 있어 AX Agent 창 단독 생성 시 리소스 조회가 실패하고 있었습니다.
- [App.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml)에 동일 스타일을 전역 리소스로 추가해, AX Agent/설정/오버레이가 공통으로 같은 툴팁 스타일을 찾도록 수정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (도구/스킬 설명창을 AX Agent 내부 설정으로 복구)
- 업데이트: 2026-04-05 08:08 (KST)
- 메인 `설정 > AX Agent`에만 남아 있던 도구/스킬 설명 성격의 안내 블록이 AX Agent 내부 오버레이에는 비어 있던 상태를 확인하고, `ChatWindow` 내부 설정 오버레이에 설명 패널을 추가했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `OverlayToolsInfoPanel`에는 훅 동작 흐름, 활용 예시, 안전 설계를 요약한 설명을 넣었고, `OverlayEtcInfoPanel`에는 스킬 파일 형식, 기본 폴더 경로, MCP/폴백 모델/드래그 드롭 관리 범위를 안내하는 설명을 복구했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SetOverlaySection()`에서는 `도구` 탭일 때 도구 설명창만, `스킬/차단` 탭일 때 스킬 설명창만 보이도록 분기해 기존 탭 흐름을 유지했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 순환 버튼을 커스텀 콤보박스로 전환)
- 업데이트: 2026-04-05 08:15 (KST)
- AX Agent 내부 설정 오버레이에서 `Fast`, `의사결정 수준`, `실행 전 계획`, `권한 모드`, `기본 출력 형식`, `테마 스타일`, `운영 모드`, `폴더 데이터 활용`처럼 텍스트가 순환하던 버튼형 입력을 커스텀 콤보박스 기반으로 바꿨습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)에 오버레이 전용 `OverlayComboBox` 스타일과 각 선택 항목용 `ComboBox`를 배치해, AX Agent 내부 설정이 claw-code식 순환 버튼보다 바로 값을 고르기 쉬운 구조가 되도록 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 선택값 동기화 헬퍼(`SelectComboTag`, `PopulateOverlayMoodCombo`)와 오버레이 전용 `SelectionChanged` 핸들러를 추가해 기존 저장 경로를 그대로 재사용하도록 연결했습니다.
- `운영 모드`는 외부 모드로 바꿀 때 기존과 동일하게 비밀번호 확인을 유지하고, `권한 모드`는 글로벌 권한과 기본 에이전트 권한을 함께 맞추도록 저장 경로를 통일했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (코워크 좌측 필터 라벨 정리)
- 업데이트: 2026-04-05 08:18 (KST)
- 코워크 탭 좌측 패널의 상단 필터는 이미 작업 유형 기준으로 동작하고 있었지만, `SidebarCoworkMenu` 라벨만 예전 `워크스페이스` 문구가 남아 있어 의미가 어긋나 있었습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 코워크 메뉴 라벨을 `작업 유형`으로 바꿔, 실제 필터 동작과 표기 문구가 일치하도록 정리했습니다.
- Code 탭은 계속 워크스페이스 기준 필터를 사용하므로 기존 `워크스페이스` 표기를 유지했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (입력창 비대화 및 입력 중 깜빡임 완화)
- 업데이트: 2026-04-05 08:24 (KST)
- Code/Cowork 채팅 입력창에서 텍스트 입력 시 입력 영역이 비정상적으로 세로로 길어지고, 매 타이핑마다 컨텍스트/대기열 UI가 즉시 다시 그려지면서 화면이 번쩍이는 현상을 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 입력 영역 `Grid.RowDefinition` 마지막 행을 `*`에서 `Auto`로 바꾸고 `InputBox``VerticalAlignment="Top"`으로 고정해, 입력 행이 남는 세로 공간을 끌어먹지 않도록 수정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `_inputUiRefreshTimer`를 추가하고 `InputBox_TextChanged()`에서는 무거운 `RefreshContextUsageVisual()` / `RefreshDraftQueueUi()`를 즉시 실행하지 않고 90ms 디바운스 후 반영하도록 변경했습니다.
- 이 변경으로 입력 즉시성은 유지하면서도, 토큰 사용량 카드와 대기열 카드의 재구성 빈도를 낮춰 입력 중 깜빡임과 렌더 흔들림을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (모델 선택 팝업 중복 제어 제거)
- 업데이트: 2026-04-05 08:28 (KST)
- 모델/추론 빠른 설정 팝업 하단에 모델 리스트 아래 한 번 더 반복되던 모델 칩과, 다른 위치에서 이미 조정 가능한 `계획`, `권한` 빠른 버튼이 중복으로 노출되고 있었습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `InlineModelChipPanel`을 숨기고 `BtnInlinePlanMode`, `BtnInlinePermission``Collapsed` 처리해, 팝업이 서비스/모델/추론 중심으로 더 단순하게 보이도록 정리했습니다.
- 팝업 상단 힌트 문구도 실제로 남아 있는 기능 기준에 맞춰 `서비스, 모델, 추론을 여기서 바로 바꿉니다`로 수정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (프리셋 영역 스크롤바 자동 표시화)
- 업데이트: 2026-04-05 08:31 (KST)
- AX Agent 채팅 빈 상태 화면의 프리셋 카드 영역은 내용이 충분히 적어도 세로 스크롤바 자리와 여백이 고정처럼 보이는 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 프리셋 영역 `ScrollViewer``TopicPresetScrollViewer` 이름을 부여하고 기본 `VerticalScrollBarVisibility``Disabled`로 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateTopicPresetScrollMode()`를 추가해, 프리셋 버튼 재구성 후와 창 크기 변경 시 `ExtentHeight``ViewportHeight`를 비교하고 실제로 넘칠 때만 세로 스크롤과 우측 패딩을 활성화하도록 만들었습니다.
- 이 변경으로 프리셋 카드가 정상 높이 안에 들어올 때는 스크롤바가 보이지 않고, 작은 창이나 프리셋이 많아지는 경우에만 자동으로 나타납니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (후속 요청 입력 미리보기 제거)
- 업데이트: 2026-04-05 08:34 (KST)
- AX Agent 입력창 위 `후속 요청` 카드는 사용자가 타이핑 중인 현재 입력을 실시간으로 그대로 미리 보여주고 있었는데, 실제 큐 적재 전에는 불필요한 중간 표시가 되는 상태였습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `RefreshDraftQueueUi()`에서 `DraftPreviewCard`를 상시 숨기고, 후속 요청은 엔터 입력 후 `QueueComposerDraft(...)`로 실제 큐에 적재된 다음 아래 대기열 목록에만 반영되도록 정리했습니다.
- 이 변경으로 타이핑 중 현재 입력이 상단 카드에 중복 표시되지 않고, 후속 요청은 실제로 쌓였을 때만 큐 UI에서 확인할 수 있습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (프리셋 카드 글자 잘림 보정 및 Code 탭 프리셋 숨김)
- 업데이트: 2026-04-05 08:37 (KST)
- AX Agent 빈 상태 프리셋 카드의 설명 텍스트가 카드 하단에서 잘리고 있었고, Code 탭에서도 프리셋 카드 영역이 그대로 보여 빈 상태 의미가 어색했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()`에서 프리셋 카드, `기타` 카드, `프리셋 추가` 카드 높이를 `108`에서 `116`으로 늘리고 설명 `TextBlock.MaxHeight``32`로 올려 글자 하단이 잘리지 않도록 보정했습니다.
- 같은 메서드에서 `_activeTab == "Code"`이면 `TopicButtonPanel``TopicPresetScrollViewer``Collapsed` 처리하고, 빈 상태 제목을 `코드 작업을 입력하세요`로 바꿔 Code 탭에서는 프리셋 기능이 보이지 않도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 콤보를 메인 설정 스타일로 통일)
- 업데이트: 2026-04-05 08:40 (KST)
- AX Agent 내부 설정 오버레이의 콤보박스는 이전까지 기본 WPF 드롭다운에 가까운 모양이라, 메인 설정창의 커스텀 콤보와 시각 언어가 달랐습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `OverlayComboBoxToggle`, `OverlayComboBox`, `OverlayComboBoxItem` 템플릿/스타일을 추가해, 메인 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 `ModernComboBox` 패턴과 유사한 토글 버튼형 헤더, 슬라이드 드롭다운, 항목 호버/선택 스타일을 적용했습니다.
- 오버레이의 `서비스`, `모델`, `Fast`, `의사결정 수준`, `실행 전 계획`, `권한 모드`, `기본 출력 형식`, `테마 스타일`, `운영 모드`, `폴더 데이터 활용` 콤보가 모두 같은 커스텀 스타일로 표시되도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 도움말 툴팁 복구)
- 업데이트: 2026-04-05 08:43 (KST)
- AX Agent 내부 설정 오버레이는 메인 설정창과 달리 `?` 도움말 배지가 대부분 빠져 있어, 항목 이름만 보고 동작 의미를 추측해야 하는 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `OverlayHelpBadge` 스타일을 추가하고, `HelpTooltipStyle`을 재사용해 AX Agent 오버레이 항목 옆에 동일한 시각 언어의 `?` 배지를 붙일 수 있도록 정리했습니다.
- 기본/공통 영역의 `서비스`, `모델`, `기본 서버 주소`, `API 키`, `테마 스타일`, `테마 모드`, `문서 형태`, `디자인 스타일`, `운영 모드`, `폴더 데이터 활용`, `압축 시작 한도(%)`, `최대 컨텍스트 토큰`, `오류 재시도 횟수`, `최대 Agent Pass`에 각각 툴팁 설명을 추가했습니다.
- 고급/개발자 영역의 `자동 대화 압축`, `확장 스킬 사용`, `실행 전후 자동 확장`, `입력 보정 반영`, `권한 변경 반영`, `Cowork 결과 검토`, `Code 결과 검토`, `도구 병렬 실행`, `프로젝트 규칙 자동 반영`, `에이전트 메모리 사용`, `Plan Mode 도구`, `Worktree 도구`, `Team 도구`, `Cron 도구`에도 상세 설명 툴팁을 연결했습니다.
- 이 변경으로 AX Agent 내부 설정에서 각 항목의 역할과 사용 시점을 메인 설정창처럼 마우스 오버만으로 바로 확인할 수 있습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 탭 구조 및 스킬/차단 패널 복구)
- 업데이트: 2026-04-05 08:51 (KST)
- AX Agent 내부 설정 오버레이는 중간 정리 과정에서 탭이 `공통/채팅/코워크/코드/개발자/도구/스킬/차단` 단순 구조로 축소됐고, 예전 버전에 있던 `코워크/코드` 공통 탭과 `스킬/차단` 실내용 패널이 빠진 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 좌측 네비를 `공통 / 채팅 / 코워크/코드 / 코워크 / 코드 / 개발자 / 도구 / 스킬/차단` 구조로 다시 확장하고, `스킬/차단` 탭 하단에 `차단 경로 패턴`, `차단 확장자`, `스킬 설정`, `로드된 스킬`, `폴백 모델`, `MCP 서버`, `등록된 도구/커넥터` 패널을 추가했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `RefreshOverlayEtcPanels()`, `BuildOverlayBlockedItems()`, `BuildOverlaySkillListPanel()`, `BuildOverlayFallbackModelsPanel()`, `BuildOverlayMcpServerCards()`, `BuildOverlayToolRegistryPanel()` 를 추가해, 오버레이 안에서 설정값과 런타임 상태가 바로 보이도록 복구했습니다.
- 같은 코드에서 `shared` 섹션 분기를 추가해 `코워크/코드` 공통 탭이 `Fast`, `의사결정 수준`, `실행 전 계획`, `권한 모드`, `폴더 데이터 활용`, `최대 Agent Pass` 같은 공통 항목을 함께 보여주도록 조정했습니다.
- `스킬/차단` 탭의 `슬래시 팝업 표시 개수`, `드래그 앤 드롭 AI 액션`, `드래그 앤 드롭 즉시 전송`도 오버레이 전용 컨트롤로 다시 노출하고 기존 설정 저장 경로와 연결했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 창 상단 최소화/최대화/닫기 버튼 시인성 개선)
- 업데이트: 2026-04-05 08:54 (KST)
- AX Agent [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 상단 우측 창 버튼은 일반 `GhostBtn`을 그대로 사용하고 있어, 사용자 가이드 창보다 버튼 간격이 좁고 아이콘이 작으며 호버 피드백이 약했습니다.
- 같은 파일에 `TitleBarActionButton`, `TitleBarCloseButton` 스타일을 추가해 버튼 크기를 `40x40`으로 확장하고, 버튼 사이 여백을 늘리고, 마우스 오버 시 살짝 확대되는 스케일 애니메이션과 호버 배경이 보이도록 템플릿을 분리했습니다.
- 닫기 버튼은 별도 `TitleBarCloseButton` 스타일을 적용해 호버 시 붉은 배경이 들어오도록 했고, 최소화/최대화 버튼도 아이콘 크기를 키워 창 제어 버튼이 더 명확하게 보이도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 세 탭 채팅 폭 공통화)
- 업데이트: 2026-04-05 09:02 (KST)
- AX Agent의 `Chat / Cowork / Code` 탭은 하단 입력 바 래퍼가 `Center` 정렬과 내용 기반 측정에 묶여 있어, 탭마다 본문 폭과 composer 폭이 다르게 보이고 전체 채팅창이 작게 느껴지는 문제가 있었습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 빈 상태 영역과 하단 입력 바 래퍼에 공통 `Stretch + MaxWidth 1280` 기준을 적용하고, 컴포저 래퍼에 `ComposerShell` 이름을 부여해 세 탭이 같은 가로 폭 축을 사용하도록 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `GetMessageMaxWidth()``MessageScroll.ActualWidth` 단독 비율 계산 대신 `ComposerShell.ActualWidth`를 우선 기준으로 사용하도록 바꿔, 사용자/어시스턴트 버블과 스트리밍 컨테이너가 탭과 빈 상태 여부에 상관없이 동일한 본문 폭 안에서 렌더되게 했습니다.
- 이 변경으로 Chat/Cowork/Code 전환 시 입력창과 메시지 본문이 같은 폭으로 보이고, 기존보다 더 넓은 레이아웃으로 유지됩니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 시작 직후 Style 예외 수정)
- 업데이트: 2026-04-05 09:06 (KST)
- AX Agent 창은 시작 직후 `System.Windows.FrameworkElement.Style` 설정 단계에서 `MS.Internal.NamedObject``Style`로 캐스팅하지 못하는 `XamlParseException`으로 바로 종료되고 있었습니다.
- 원인은 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `OverlayComboBox` 스타일 내부에서 `ItemContainerStyle`이 뒤에서 선언되는 `OverlayComboBoxItem``StaticResource`로 먼저 참조하던 forward reference였습니다.
- `ItemContainerStyle` 참조를 `DynamicResource OverlayComboBoxItem`으로 바꿔 런타임 리소스 해석 시점에 올바른 스타일이 연결되도록 수정했고, 같은 유형의 선참조 패턴이 더 없는 것도 추가로 확인했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (build.bat 실행 중 AX Copilot 프로세스 종료 확인 강화)
- 업데이트: 2026-04-05 09:13 (KST)
- 기존 [build.bat](/E:/AX%20Copilot%20-%20Codex/build.bat) 은 실행 중인 `AxCopilot.exe``taskkill /IM ... /F` 한 번만 호출하고 2초 뒤 바로 publish 단계로 넘어가고 있어, 프로세스가 완전히 내려가지 않았는데도 빌드가 이어지거나, 권한 문제로 종료 실패한 상태가 모호하게 보이는 문제가 있었습니다.
- 프로세스 정리 루틴을 `:stop_process`로 교체하고, `CloseMainWindow()` 정상 종료 시도 후 `taskkill /IM <name>.exe /T /F` 로 자식 프로세스까지 종료한 다음 `Get-Process`로 실제 종료 여부를 다시 확인하도록 변경했습니다.
- 프로세스가 여전히 살아 있으면 빌드를 즉시 중단하고 `Access may be denied or the app may be running with higher privileges.` 메시지를 출력하도록 해, AX Copilot이 관리자 권한 등 더 높은 권한으로 실행 중일 때 원인을 바로 알 수 있게 했습니다.
- 이 수정으로 build 배치는 실행 중 앱을 못 내렸을 때 어설프게 계속 진행하지 않고, 명확한 실패 상태로 멈춥니다.
- 검증: `cmd /c build.bat` 실행 시, 권한 문제로 살아 있는 `AxCopilot.exe`를 감지하고 즉시 실패 처리하는 동작 확인
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정의 도구/스킬 탭 확장 및 접기 구조 적용)
- 업데이트: 2026-04-05 09:27 (KST)
- AX Agent 내부 설정의 `도구`, `스킬/차단` 탭은 목록이 길게 늘어져 한 번에 보기 어렵고, 메인 설정에만 남아 있던 훅/슬래시 세부값이 따로 분리되어 있었습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `도구` 탭용 `TxtOverlayToolHookTimeoutMs`, `OverlayHookListPanel`, `훅 추가` 버튼과 `스킬/차단` 탭용 스킬 폴더 선택/열기 버튼, `TxtOverlayMaxFavoriteSlashCommands`, `TxtOverlayMaxRecentSlashCommands`를 추가해 AX Agent 내부 설정만으로 주요 확장값을 관리할 수 있게 했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `BuildOverlayHookCards()`, `ShowOverlayHookEditDialog(...)`, `CreateOverlayCollapsibleSection(...)` 를 추가하고, 훅 편집/추가/삭제/활성 토글, 스킬 폴더 선택, 슬래시 핀/최근 개수 저장, 훅 타임아웃 저장을 모두 AX Agent 오버레이 저장 흐름에 연결했습니다.
- 스킬 목록은 `/스킬명` 표기로 바꾸고, 한국어 설명은 그대로 유지한 채 `직접 호출 스킬 / 자동·조건부 스킬 / 현재 사용 불가` 섹션으로 접기/펼치기 렌더링하도록 재구성했습니다.
- 도구 목록도 기존 단순 나열 대신 `파일/검색`, `코드 분석`, `문서 생성`, `에이전트` 같은 카테고리별 접기/펼치기 카드로 바꿔 긴 목록을 더 짧게 탐색할 수 있게 했습니다.
- 같은 작업에서 메인 설정 AX Agent 영역에 남아 있던 `PDF 내보내기 기본 경로`, `이미지 입력 활성화`, `코드 리뷰 도구 활성화`도 AX Agent 내부 설정의 `채팅`/`코드` 탭으로 옮겨 내부 설정 집중도를 높였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 개발자 탭 잔여 항목 이식)
- 업데이트: 2026-04-05 09:38 (KST)
- 메인 설정의 AX Agent 영역에는 아직 `호출 간 딜레이`, `실행 이력 상세도`, `계획 diff 심각도`, `워크플로우 시각화`, `감사 로그`, `서브에이전트 최대 수`처럼 내부 AX Agent 오버레이에 없는 개발자/운영 항목이 남아 있었습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 `OverlayDeveloperRuntimePanel`, `OverlayDeveloperExtraPanel` 을 추가하고, `호출 간 딜레이(초)`, `서브에이전트 최대 수`, `실행 이력 상세도`, `계획 diff 심각도(개수/비율)`, `워크플로우 시각화`, `전체 호출·토큰 합계 표시`, `감사 로그`, `감사 로그 폴더 열기` 행을 AX Agent 내부 설정의 `개발자` 탭 안으로 재배치했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 위 컨트롤들의 로드/저장 동기화, 숫자 입력 범위 검증, `실행 이력 상세도` 콤보 저장, `감사 로그 폴더 열기` 핸들러를 추가하고 `SetOverlaySection("dev")` 분기에서 해당 패널이 `개발자` 탭에서만 보이도록 연결했습니다.
- 이 변경으로 AX Agent 내부 설정은 기존 `공통 / 채팅 / 코워크/코드 / 코워크 / 코드 / 개발자 / 도구 / 스킬/차단` 탭 구조를 유지한 채, 메인 설정에 남아 있던 AX Agent 전용 세부값 대부분을 각 기능 탭에 더 가깝게 재배치한 상태가 됐습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 메인 설정 잔존 제거 및 내부 설정 재배치)
- 업데이트: 2026-04-05 09:54 (KST)
- 일반 설정창에서 `AX Agent` 탭이 계속 보이던 이유는 [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `ApplyAiEnabledState()``AiEnabled=true` 상태일 때 `AgentTabItem.Visibility`를 다시 `Visible`로 되돌리고 있었기 때문이었습니다.
- 같은 메서드에서 `AgentTabItem.Visibility`를 항상 `Collapsed`로 유지하도록 바꿔, 내부 설정으로 이관 중인 AX Agent 전용 설정이 메인 설정에 다시 나타나지 않게 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `ApplyOverlaySettingsChanges()``ChkOverlayAiEnabled_Changed()` 경로에서 `AiEnabled`를 더 이상 토글하지 않고 항상 `true`로 유지하도록 바꿨고, `OverlayAiEnabledRow`도 내부 설정에서 숨겨 AX Agent 사용 토글을 제거했습니다.
- 내부 AX Agent 설정 탭 배치도 다시 조정해 `서비스/모델``운영 모드``공통` 탭으로 옮기고, `문서 형태``디자인 스타일`은 채팅 탭이 아니라 `코워크` 탭에만 보이도록 정리했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `압축 시작 한도(%)`, `최대 컨텍스트 토큰` 행은 숫자 입력 칸 대신 `60/70/80/90%`, `4K/16K/64K/256K/1M` 프리셋 카드로 바꾸고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 선택 반영/하이라이트 동기화 로직(`OverlayCompactPresetCard_MouseLeftButtonUp`, `OverlayContextPresetCard_MouseLeftButtonUp`, `RefreshOverlayTokenPresetCards`)을 추가해 내부 설정 저장과 즉시 반영을 통합했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (메인 설정 AX Agent의 표현 수준 행 숨김)
- 업데이트: 2026-04-05 09:56 (KST)
- 일반 설정의 AX Agent 탭 화면에는 `표현 수준` 행이 여전히 남아 있어, 내부 고정 정책과 달리 사용자가 선택 가능한 옵션처럼 보이는 상태였습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 `표현 수준` row `Border``Visibility="Collapsed"`를 적용해, 메인 설정 화면에서는 해당 항목이 더 이상 보이지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 컴포저 과확장 레이아웃 보정)
- 업데이트: 2026-04-05 09:58 (KST)
- AX Agent 채팅 화면의 입력 컴포저는 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `HorizontalAlignment="Stretch"`와 넓은 최대폭을 사용하고 있었고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `ApplyExpressionLevelUi()``rich` 모드에서 `InputBox.MaxHeight = 220`까지 다시 늘려 입력창이 과하게 커지는 문제가 있었습니다.
- `ComposerShell``Center + Width/MaxWidth 640`으로 바꿔 채팅창이 전체 가로폭을 계속 채우지 않도록 했고, 입력창 최대 높이도 `rich 120 / balanced 108 / simple 96`으로 낮춰 여러 줄 입력 시에도 적정 높이 범위 안에서만 늘어나도록 조정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (Gemini Chat 빈 응답/멈춤 우회)
- 업데이트: 2026-04-05 10:02 (KST)
- AX Agent Chat 탭에서 Gemini를 선택했을 때, API 키를 넣은 뒤에도 임시 스트리밍 컨테이너만 뜨고 응답이 비거나 진행 상태가 오래 남는 문제가 있었습니다. 현재 구현상 원인은 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 일반 Chat 스트리밍 경로가 Gemini SSE 응답과 맞물릴 때 완료 신호를 안정적으로 처리하지 못하던 쪽에 가까웠습니다.
- 일반 전송 흐름과 `다시 생성` 흐름 둘 다에서 현재 서비스가 `gemini`일 경우 `_llm.StreamAsync(...)` 대신 `_llm.SendAsync(...)` 비스트리밍 경로를 사용하도록 변경해, Chat 탭에서는 Gemini 응답을 한 번에 받아 안정적으로 렌더하도록 우회했습니다.
- 같은 파일의 `GetMessageMaxWidth()``320~720` 범위로 재조정해 메시지 폭이 컴포저보다 더 커지지 않게 했고, `FinalizeStreamingContainer(...)`에서는 응답 내용이 비어 있을 경우 `(빈 응답)`으로 치환해 완전히 빈 말풍선이 남지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 채팅 컴포저 높이 재고정 및 빈 assistant 말풍선 제거)
- 업데이트: 2026-04-05 10:10 (KST)
- AX Agent Chat 탭에서 입력창 텍스트가 거의 없는데도 컴포저 높이가 계속 커지고, 응답이 비정상 종료된 뒤 내용 없는 assistant 카드가 다시 렌더되는 문제가 있었습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateInputBoxHeight()`를 추가해 입력창 높이를 실제 줄 수 기준으로 직접 계산하고 `MinHeight~MaxHeight` 범위 안에서만 유지하도록 변경했습니다. 이때 최대 높이를 넘길 경우에만 내부 세로 스크롤을 표시하도록 바꿔, 내용이 없는데도 큰 빈 입력 영역이 남지 않게 했습니다.
- 같은 파일의 `RenderMessages()`는 비어 있는 assistant 메시지를 렌더 대상에서 제외하도록 필터를 추가했고, 일반 전송/재생성 흐름에서도 완료 직전 `assistantMsg.Content`가 비어 있으면 `(빈 응답)`으로 확정 저장하도록 정리해 재오픈 시에도 빈 말풍선이 다시 나타나지 않게 보정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 채팅 탭의 중복 테마 설정 제거)
- 업데이트: 2026-04-05 10:12 (KST)
- AX Agent 내부 설정의 `채팅` 탭에는 채팅과 직접 관련 없는 `테마 스타일`, `테마 모드` 블록이 남아 있어, 기능 분류 기준과 맞지 않는 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 해당 두 블록을 삭제해 채팅 탭에는 `PDF 내보내기 기본 경로`, `이미지 입력 활성화` 같은 실제 채팅 관련 설정만 남도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정에 등록 모델 관리 이식)
- 업데이트: 2026-04-05 10:16 (KST)
- AX Agent 내부 설정의 `공통` 탭에는 서비스/모델 선택만 있고, 메인 설정에 있던 `등록 모델 추가/편집/삭제` 기능이 빠져 있어 사내 모델 관리가 반쪽 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `OverlayModelEditorPanel` 아래에 `등록 모델 관리` 헤더, `모델 추가` 버튼, `OverlayRegisteredModelsPanel`을 추가해 내부 설정 안에서도 사내 모델 관리 UI가 보이도록 확장했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `BuildOverlayRegisteredModelsPanel()`을 추가하고, 메인 설정에서 쓰던 [ModelRegistrationDialog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ModelRegistrationDialog.cs) 를 그대로 호출해 `Ollama/vLLM` 등록 모델의 `추가/편집/삭제/선택`을 내부 설정에서 직접 수행하도록 연결했습니다.
- 저장 경로는 기존 `Llm.RegisteredModels`를 그대로 사용해, 메인 설정과 내부 설정이 같은 모델 목록을 보도록 맞췄고, `PersistOverlaySettingsState()` 시점에 동일하게 저장/갱신되도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정의 압축 시작 한도 분류 보정)
- 업데이트: 2026-04-05 10:19 (KST)
- `압축 시작 한도(%)` 행은 성격상 `코워크/코드 공통` 설정인데, 기존 `SetOverlaySection()` 분기에서는 여러 탭에서 함께 열리도록 되어 있어 분류가 어색했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `OverlayAnchorAdvanced.Visibility` 조건을 `showShared` 전용으로 좁혀, 해당 항목이 `코워크/코드 공통` 탭에서만 보이도록 보정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 탭 헤더를 본문 최상단으로 정리)
- 업데이트: 2026-04-05 10:27 (KST)
- AX Agent 내부 설정 화면은 탭별 제목/설명이 `서비스와 모델` 블록 아래쪽에 위치해 있어, 사용자가 현재 탭의 범위를 바로 파악하기 어려운 상태였습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 상단 전용 헤더(`OverlaySectionHeading`, `OverlayTopHeadingTitle`, `OverlayTopHeadingDescription`)를 추가하고, `OverlaySectionDetail` 안의 중복 헤더는 숨겨 탭 제목과 설명이 본문 맨 위에 먼저 보이도록 재배치했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SetOverlaySection()`도 새 상단 헤더에 맞춰 각 탭별 제목/설명을 채우도록 조정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (Chat 탭 입력창 누적 확장 및 assistant 메시지 미표시 보정)
- 업데이트: 2026-04-05 10:23 (KST)
- Chat 탭에서는 엔터를 칠 때마다 컴포저 높이가 누적 확장되고, 하단 토큰은 증가하지만 assistant 메시지가 화면에 남지 않는 문제가 있었습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateInputBoxHeight()`를 재조정해 Chat 탭은 항상 고정 높이 입력창을 사용하고, Cowork/Code 탭만 명시적 줄 수 기준으로 멀티라인 높이 계산을 하도록 분리했습니다.
- 같은 파일에 `SyncLatestAssistantMessage(ChatConversation conv, string content)`를 추가해 응답 완료 시 최신 assistant 메시지 내용을 대화 객체에 확정 반영하고, 이후 `RenderMessages(preserveViewport: true)`를 한 번 더 호출해 스트리밍/비스트리밍 완료 뒤에도 실제 저장 대화 기준으로 메시지가 다시 보이도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (메인 설정의 AX Agent 진입을 내부 설정 바로가기 중심으로 정리)
- 업데이트: 2026-04-05 10:35 (KST)
- 사용자 요청 기준으로 AX Agent 관련 설정은 메인 설정 탭에서 직접 다루지 않고, AX Agent 내부 설정으로 모으는 방향으로 다시 정리했습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 `일반` 탭 하단에 `AX Agent 설정 바로가기` 카드를 추가해, 일반 설정 화면에서도 AX Agent 채팅창을 열고 내부 설정 오버레이를 곧바로 표시할 수 있도록 했습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `ApplyMainTabVisibility()`에서는 메인 설정의 표시 대상에서 `AX Agent`를 완전히 제외했고, `SelectAgentSettingsTab()``BtnAgentShortcut_Click()`은 더 이상 숨겨진 AX Agent 탭으로 이동하지 않고 [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 의 `OpenAgentSettingsInChat()`을 호출하도록 전환했습니다.
- 이 변경으로 메인 설정에 남아 있던 AX Agent 진입 버튼도 모두 같은 내부 설정 경로를 사용하게 되어, 설정 위치가 둘로 나뉘어 보이던 흐름을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (Chat 탭도 줄바꿈 기준으로 입력창 높이 확장)
- 업데이트: 2026-04-05 10:41 (KST)
- 직전 보정에서 Chat 탭 입력창을 고정 높이로 묶었는데, 사용자 기준으로는 Chat도 `Shift+Enter` 줄바꿈이 있을 때는 자연스럽게 높이가 늘어나야 했고, 고정 분기 때문에 의도와 다른 동작이 되고 있었습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateInputBoxHeight()`에서 `Chat` 탭 예외 분기를 제거해, 이제 세 탭 모두 실제 개행 문자 수를 기준으로 높이를 계산합니다.
- 결과적으로 `Enter` 전송만으로는 높이가 늘지 않고, `Shift+Enter`로 줄이 추가된 경우에만 높이가 증가하며, 최대 높이를 넘길 때만 내부 스크롤이 나타나도록 다시 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (메인 설정의 숨김 AX Agent 옛 UI 잔재 1차 제거)
- 업데이트: 2026-04-05 10:48 (KST)
- 메인 설정 XAML에는 더 이상 사용자에게 보이지 않는데도 남아 있던 AX Agent 관련 숨김 블록과 중복 진입 버튼이 있어, 내부 설정 기준으로 통합된 현재 UX와 맞지 않는 상태였습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 에서 `일반` 탭의 숨김 `AI 기능`/`운영 모드` 행을 제거했고, 하단 전역 버튼 바에 있던 중복 `AX Agent 설정` 버튼도 삭제했습니다. 이제 메인 설정에서 AX Agent로 들어가는 진입은 일반 탭 본문의 전용 바로가기 카드로만 남습니다.
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `ApplyAiEnabledState()``ApplyOperationModeState()`도 숨김 일반 탭 컨트롤을 더 이상 참조하지 않도록 정리해, 보이지 않는 UI를 전제로 둔 동기화 코드가 돌지 않게 했습니다.
- 이번 단계는 1차 정리이며, 아직 XAML 하단에 남아 있는 구형 `AgentTabItem` 본문 전체 제거는 내부 참조가 많아 다음 단계에서 분리 정리할 예정입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 채팅/코워크/코드 응답 표시 안정화)
- 업데이트: 2026-04-05 11:02 (KST)
- 사용자 보고 기준으로 AX Agent는 전송할 때마다 입력창 높이가 비정상적으로 남고, Chat 탭은 토큰만 올라가는데 응답 본문이 안 보이며, Cowork/Code는 실행 로그 문구 잔상만 남는 상태였습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `QueueComposerDraft()`, `BtnDraftClear_Click()`, `StartNextQueuedDraftIfAny()`, `SendMessageAsync()`에서 입력 텍스트를 비우거나 다시 채운 직후 `UpdateInputBoxHeight()`를 명시적으로 호출하도록 바꿔, 입력창 높이가 이전 값으로 남는 현상을 줄였습니다.
- `SendMessageAsync()`의 일반 Chat 흐름은 스트리밍 대신 비스트리밍 응답을 우선 사용하게 조정해, Chat 탭에서 토큰 사용량은 집계되는데 assistant 메시지가 비거나 늦게 반영되던 경로를 안정화했습니다.
- 같은 메서드에 `BuildAssistantFallbackContent()`를 추가해 Cowork/Code에서 최종 assistant 텍스트가 비어 있으면 최근 실행 이벤트 요약을 fallback 응답으로 남기게 했고, 응답 완료 후 `conv.ShowExecutionHistory = false`를 적용해 실행 로그 카드 잔상이 기본 화면을 덮지 않도록 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
### 2026-04-05 추가 진행 기록 (AX Agent 내부 설정 도구/스킬 섹션 기본 접힘 처리)
- 업데이트: 2026-04-05 11:08 (KST)
- 사용자 요청 기준으로 AX Agent 내부 설정의 `도구`, `스킬/차단` 탭은 긴 목록이 처음부터 모두 펼쳐져 있기보다, 필요할 때만 열어보는 구조가 더 적합했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `AddOverlaySkillSection()`, `BuildOverlayToolRegistryPanel()`, `BuildOverlayHookCards()`에서 `CreateOverlayCollapsibleSection(...)` 기본 확장값을 모두 `false`로 바꿔, 스킬 목록/도구 카테고리/훅 목록이 기본적으로 닫힌 상태로 열리게 했습니다.
- 이 변경으로 AX Agent 내부 설정 진입 시 `스킬/차단`, `도구` 탭이 더 압축된 상태로 보이고, 필요한 항목만 선택적으로 펼쳐 확인할 수 있습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
- Document update: 2026-04-05 11:22 (KST) - Started stabilizing AX Agent chat around a more `claw-code`-like single-source render path. The composer shell is now bottom-aligned instead of stretching vertically, direct user-bubble injection during send was removed in favor of model-backed `RenderMessages()` refresh, and live execution banners now inject into the visible chat only when execution-history display is explicitly enabled.
- Document update: 2026-04-05 11:22 (KST) - For Cowork/Code runs, `ShowExecutionHistory` is now forced off at run start so intermediate rerenders no longer rehydrate transient execution banners into the conversation body.
- Document update: 2026-04-05 11:22 (KST) - Chat tab now skips creation of the temporary streaming container altogether. Since Chat already routes through non-streaming `SendAsync()`, the final assistant text is now committed directly to the conversation model and rendered from there instead of relying on a transient placeholder card.
- Document update: 2026-04-05 11:22 (KST) - Cowork/Code now also skip the temporary streaming container path. Those tabs receive final agent-loop results rather than incremental chunks, so the UI now avoids showing a transient empty assistant card before the final rerender.
- Document update: 2026-04-05 11:22 (KST) - Main settings tab visibility now permanently hides the legacy `AX Agent` tab regardless of AI enablement, so the remaining supported entry point is the AX Agent in-chat internal settings overlay plus the general-settings shortcut.
- Document update: 2026-04-05 11:29 (KST) - Removed the early assistant-placeholder insertion from `SendMessageAsync()`. Assistant messages are now appended only after the final response text is known, reducing the empty-bubble / token-only failure mode in AX Agent chat.
- Document update: 2026-04-05 11:29 (KST) - Added a `MainSettingsTab` selection redirect so any attempt to land on the legacy hidden AX Agent tab immediately routes to the AX Agent in-chat settings shortcut instead.
- Document update: 2026-04-05 11:34 (KST) - Added `Temperature` to the AX Agent in-chat settings overlay and wired it into the same deferred-save path used by the other numeric overlay settings. The value is normalized to the 0.02.0 range and persisted from the overlay itself.
- Document update: 2026-04-05 11:42 (KST) - Added `Services/Agent/AxAgentExecutionEngine.cs` as an AX-side execution-prep engine modeled after the `claw-code` split between input processing and session execution. `ChatWindow` now routes outbound message preparation and final assistant-message commit through that engine instead of assembling and appending everything inline.
- Document update: 2026-04-05 11:11 (KST) - Collapsed the duplicated Cowork/Code agent-loop branches inside `ChatWindow.SendMessageAsync()` into `RunAgentLoopAsync(...)`. Both tabs now share the same execution wrapper for workflow-analyzer open, cumulative-token reset, event hookup, completion notification, and cleanup, which moves AX Agent closer to a single execution path modeled after the `claw-code` engine split.
- Document update: 2026-04-05 11:11 (KST) - Reworked AX Agent composer resizing to use `TextBox.MinLines/MaxLines` instead of manually pinning `Height`, so the composer grows only with actual line breaks and is less likely to remain oversized after send.
- Document update: 2026-04-05 11:11 (KST) - Replaced the in-overlay text-entry controls for `Temperature`, `MaxRetryOnError`, `MaxAgentIterations`, `FreeTierDelaySeconds`, and `MaxSubAgents` with slider-based controls plus current-value badges, following the older settings-window interaction pattern and reducing mis-entry risk.
- Document update: 2026-04-05 11:11 (KST) - Moved `Temperature` and `MaxAgentIterations` visibility to the developer tab only inside the AX Agent overlay, tightening the common tabs around user-facing settings and pushing expert/debug knobs toward the developer section.
- Document update: 2026-04-05 11:11 (KST) - Removed the general-tab bottom shortcut card for AX Agent settings and added a dedicated `AX Agent` entry to the left settings navigation. Selecting that nav entry now routes straight into the AX Agent in-chat internal settings overlay, while the legacy hidden `AgentTabItem` remains suppressed.
- Document update: 2026-04-05 11:22 (KST) - Continued migrating the remaining AX Agent in-chat numeric controls away from freeform text entry. `ToolHookTimeoutMs`, `SlashPopupPageSize`, `MaxFavoriteSlashCommands`, and `MaxRecentSlashCommands` now use slider-based controls with live value badges in the internal overlay, matching the safer interaction style from the old settings window.
- Document update: 2026-04-05 11:22 (KST) - Synced the new slider-backed skill/tool values through both `RefreshOverlayVisualState(...)` and `RefreshOverlayEtcPanels(...)`, so section changes and overlay reopen now restore the same values across the hidden compatibility textboxes, sliders, and badges.
- Document update: 2026-04-05 11:24 (KST) - Removed the legacy hidden `AgentTabItem` from `MainSettingsTab.Items` at settings-window construction time, so the old AX Agent settings body can no longer be selected through tab restoration or visibility changes. The left navigation `AX Agent` shortcut remains the only supported entry and now routes directly into the in-chat AX Agent settings overlay.
- Document update: 2026-04-05 11:25 (KST) - Added a `HasLegacyAgentTab()` guard around the remaining legacy AX Agent initialization path in `SettingsWindow`. Once the old tab is removed from `MainSettingsTab.Items`, calls such as `MoveBlockSectionToEtc()`, `BuildServiceModelPanels()`, `BuildToolRegistryPanel()`, `LoadAdvancedSettings()`, `SyncAgentSelectionCards()`, and `ApplyAgentSubTabVisibility()` now stop running, which reduces hidden coupling to the retired settings body while the XAML cleanup continues.
- Document update: 2026-04-05 11:27 (KST) - Restored per-tool enable/disable control inside the AX Agent in-chat `도구` tab. `BuildOverlayToolRegistryPanel()` now renders each tool row with a `ToggleSwitch`, persists changes through `Llm.DisabledTools`, and refreshes the overlay immediately so the tool availability state is managed from the AX Agent internal settings instead of only the legacy settings window.
- Document update: 2026-04-05 11:28 (KST) - Updated [AGENTS.md](/E:/AX%20Copilot%20-%20Codex/AGENTS.md) to explicitly require unified control patterns for settings input: all on/off fields must use `ToggleSwitch`, numeric fields should default to the established slider + value-badge pattern, and equivalent settings in main settings vs. AX Agent internal settings should not diverge in input style.
- Document update: 2026-04-05 11:31 (KST) - Fixed the startup-time `NullReferenceException` that appeared during AX Agent prewarm. `ChatWindow` now resolves overlay LLM settings through `TryGetOverlayLlmSettings()` and all overlay slider `ValueChanged` handlers short-circuit when the settings graph is not ready yet, preventing premature writes during XAML initialization.
### 2026-04-05 추가 진행 기록 (Agent Compare 기준 런처 기능 이식 1차)
- 업데이트: 2026-04-05 11:58 (KST)
- 사용자 요청 기준을 `claw-code`가 아니라 우선 `Agent Compare`의 런처 기능 이식으로 다시 고정하고, 현재 런처와 비교해 실제로 빠져 있던 하단 위젯 바 외 기능을 점검했습니다.
- [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs)에 검색 히스토리 탐색 상태(`_isHistoryNavigation`, `_historyIndex`)를 복구하고, `SelectedItem` 변경 시 선택 항목 미리보기 업데이트가 바로 돌도록 다시 연결했습니다. `OnShown()`은 빠른 실행 칩을 로드하고, 실행 시점에는 검색어를 [SearchHistoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SearchHistoryService.cs)에 저장하도록 정리했습니다.
- 새로 추가한 [LauncherViewModel.LauncherExtras.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs)는 `Agent Compare`에 있던 `빠른 실행 칩`, `검색 히스토리 이동`, `선택 항목 미리보기 패널` 보조 로직을 현재 런처 구조에 맞게 분리한 partial입니다. 텍스트/이미지/PDF/미디어 파일에 대한 간단한 메타 미리보기를 제공하고, 실행 상위 경로를 칩으로 다시 띄웁니다.
- [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml)에는 입력창 아래 빠른 실행 칩 영역과 결과 리스트 아래 미리보기 패널을 추가했고, 기존에 먼저 이식한 위젯 바와 겹치지 않도록 행 구조를 `8개 Row` 기준으로 재배치했습니다. 토스트 오버레이의 `Grid.RowSpan`도 새 레이아웃에 맞춰 `4`로 확장했습니다.
- [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs)에는 `F3` 빠른 미리보기, `F4` 화면 OCR 단축키를 다시 연결했고, 창이 숨겨질 때 QuickLook 창도 같이 닫히도록 정리했습니다.
- [LauncherWindow.Shell.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.Shell.cs), [QuickLookWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/QuickLookWindow.xaml), [QuickLookWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/QuickLookWindow.xaml.cs), [QuickActionChip.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/QuickActionChip.cs), [SearchHistoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SearchHistoryService.cs)을 추가해 `Agent Compare` 쪽 런처 보조 기능이 현재 코드베이스에서 독립적으로 유지되도록 분리했습니다.
- [UsageRankingService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/UsageRankingService.cs)에는 `GetTopItems(int)`를 추가해, 빠른 실행 칩이 최근 실행 상위 경로를 직접 읽을 수 있도록 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`` 경고 0 / 오류 0
- 업데이트: 2026-04-05 11:56 (KST)
- `Agent Compare` 기준으로 런처 보조 기능/설정 연결을 다시 대조하면서, 입력을 지웠을 때 이전 선택 항목과 미리보기 상태가 남아 빠른 실행 칩·검색 히스토리·미리보기 패널 전환이 어색해지던 흐름을 정리했습니다. [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 는 빈 입력과 런처 재오픈 시 `SelectedItem`을 함께 초기화해, 현재 쿼리와 무관한 미리보기/선택 상태가 잔류하지 않게 했습니다.
- 설정 연결 검증도 같이 진행했습니다. `Agent Compare`의 런처 설정 항목(`ShowNumberBadges`, `CloseOnFocusLost`, `RememberPosition`, `EnableActionMode`, `EnableRandomPlaceholder`, `EnableIconAnimation`, `EnableSelectionGlow`, `ShowLauncherBorder`, `EnableFavorites`, `EnableRecent`, `ShowPrefixBadge`)을 현재 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs), [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs), [CommandResolver.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/CommandResolver.cs), [SnippetExpander.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/SnippetExpander.cs), [WebSearchHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/WebSearchHandler.cs), [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs), [ClipboardHistoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ClipboardHistoryService.cs) 기준으로 다시 점검했고, 테마 동일화는 제외한 채 보조 기능 디테일과 설정 반영 경로 위주로 마감 기준을 맞췄습니다.
- 업데이트: 2026-04-05 12:06 (KST)
- AX Agent 채팅 엔진 정상화 1차에서 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)의 Chat 실행 모드를 `최종 응답 커밋형`으로 고정하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SendMessageAsync()` / `SendRegenerateAsync()`가 임시 assistant 메시지, 임시 스트리밍 컨테이너, 직접 UI 버블 삽입에 의존하지 않도록 다시 정리했습니다.
- 현재 흐름은 `사용자 메시지 저장 → 전송 메시지 준비 → LLM 또는 AgentLoop 실행 → 최종 assistant 텍스트 확정 → conversation/session에 커밋 → RenderMessages()` 순서입니다. 이로써 토큰은 집계되지만 본문이 비거나, assistant 빈 버블이 남거나, 재생성 시 메시지 모델과 화면 상태가 어긋나는 증상을 줄이는 쪽으로 맞췄습니다.
- `OnAgentEvent(...)`도 직접 `AddAgentEventBanner()`를 바로 꽂는 방식을 중단하고, `AppendConversationExecutionEvent()``ShowExecutionHistory`가 켜진 경우에만 `RenderMessages(preserveViewport: true)`를 다시 타게 바꿨습니다. 이 수정은 Cowork/Code에서 실행 로그가 플래시처럼 남거나 중복 잔상으로 보이던 문제를 줄이기 위한 것입니다.
- 이어서 수동 컨텍스트 압축 결과와 `/slash` 로컬 응답 경로도 conversation/session에 먼저 반영한 뒤 `RenderMessages()` 기준으로 다시 그리도록 맞췄습니다. 이 변경으로 Chat/Cowork/Code에서 “일반 전송은 모델 렌더, 로컬 응답은 직접 버블 주입”으로 두 경로가 섞여 있던 상태를 더 줄였고, 코워크/코드 엔진 정상화 작업을 계속 진행할 수 있는 공통 렌더 축을 확보했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 12:09 (KST)
- 업데이트: 2026-04-05 12:24 (KST)
- AX Agent 채팅 UI/엔진 재작업 전에 현재 기준본을 `etc/chat-ui-backup/2026-04-05-1215/`에 백업했습니다. 백업 범위는 `ChatWindow.xaml`, `ChatWindow.xaml.cs`, `Services/Agent/AxAgentExecutionEngine.cs` 3개 파일이며, `claw-code` 기준 UI/엔진 전환 중 회귀 시 즉시 비교할 수 있도록 남겼습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 메시지 컬럼과 빈 상태 영역 폭을 `920px` 기준으로 정리하고, 컴포저는 `760px` 고정 축으로 넓혔습니다. 입력 셸의 `InputBorder`는 라운드/패딩을 단순화하고, 컴포저 안의 `대화 내보내기` 버튼은 숨겨 `claw-code`처럼 메시지와 입력 축 중심의 레이아웃으로 더 가깝게 맞췄습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `UpdateInputBoxHeight()``TextBox.MinLines/MaxLines`만 믿지 않고 실제 줄바꿈 개수 기준으로 `Height`를 직접 다시 계산하도록 바꿨습니다. 이 수정은 Chat/Cowork/Code 공통으로 “전송 후 빈 상태인데 입력창 높이가 남아 있는” 버그를 더 강하게 억제하기 위한 것입니다.
- 메시지 편집 후 재생성과 수정 피드백 후 재생성도 직접 `AddMessageBubble(...)`를 삽입하지 않고, conversation 모델 갱신 후 `RenderMessages(preserveViewport: true)`로 다시 그리게 정리했습니다. 그래서 재생성 직전에 UI만 앞서 나가면서 모델과 화면이 어긋나는 경로를 더 줄였습니다.
- `SendRegenerateAsync(...)``claw-code` 기준의 단일 실행 축에 더 가깝게 맞췄습니다. 이제 Cowork/Code 재생성도 `ResolveExecutionMode(...)``BuildPromptStack(...)`를 거쳐 필요 시 `RunAgentLoopAsync(...)`를 사용하고, 최종 assistant 텍스트만 커밋합니다. 이전처럼 재생성만 일반 `_llm.SendAsync(...)`로 빠져 코워크/코드가 Chat 경로로 잘못 처리되던 분기를 제거했습니다.
- 업데이트: 2026-04-05 12:31 (KST)
- [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `PreparedExecution` record와 `PrepareExecution(...)` 메서드를 추가해, 실행 모드 판정(`ResolveExecutionMode`)과 프롬프트 스택 조합(`BuildPromptStack`), 최종 전송 메시지 준비(`PrepareTurn`)를 한 번에 묶도록 정리했습니다.
- 같은 엔진에 `NormalizeAssistantContent(...)`도 옮겨서, 최종 assistant 텍스트가 비었을 때 최근 실행 이벤트 요약을 어떻게 대체할지까지 UI가 아니라 엔진이 책임지게 바꿨습니다. 이건 `claw-code`처럼 UI보다 세션/실행 레이어가 메시지 결과를 더 많이 책임지게 만드는 방향의 일부입니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 는 새 `PrepareExecutionForConversation(...)`을 통해 일반 전송과 재생성 모두 같은 엔진 준비 경로를 사용합니다. 그래서 Cowork/Code 시스템 프롬프트 선택, 실행 모드 판정, 프롬프트 스택 구성, outbound message 조립이 각 메서드마다 중복 구현되지 않게 됐고, 다음 단계부터는 AgentLoop 완료 처리와 후속 큐 정리도 같은 방식으로 더 엔진 쪽으로 밀 수 있는 상태가 됐습니다.
- 업데이트: 2026-04-05 12:36 (KST)
- [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `FinalizeAssistantTurn(...)`를 추가해, 최종 assistant 텍스트 정규화와 `Cowork/Code``ShowExecutionHistory=false` 처리, assistant 메시지 커밋을 한 메서드에 묶었습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SendMessageAsync()``SendRegenerateAsync()`는 더 이상 직접 `NormalizeAssistantContent(...)``CommitAssistantMessage(...)`를 따로 호출하지 않고, 둘 다 `FinalizeAssistantTurn(...)`으로 마무리합니다. 이로써 AX Agent 채팅 엔진의 “준비 → 실행 → 최종 커밋” 축이 한 단계 더 짧고 일관되게 정리됐습니다.
- 업데이트: 2026-04-05 12:41 (KST)
- `AgentLoop vs 일반 LLM 호출` 선택 분기까지 엔진으로 옮기기 위해 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `ExecutePreparedAsync(...)`를 추가했습니다. 준비된 실행(`PreparedExecution`)과 두 실행 delegate를 넘기면, 엔진이 `UseAgentLoop` 판정에 따라 적절한 실행 경로를 선택합니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SendMessageAsync()``SendRegenerateAsync()`는 이제 직접 `if (executionMode.UseAgentLoop)` 분기를 들고 있지 않고, 둘 다 `ExecutePreparedAsync(...)`를 통해 같은 실행 진입점을 사용합니다. 이 변경으로 AX Agent 채팅 엔진 공통화는 “준비 / 실행 선택 / 최종 커밋”까지 한 덩어리로 거의 맞춰지고 있습니다.
- 업데이트: 2026-04-05 12:47 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `ResetStreamingUiState()`, `FinalizeConversationTurn()`, `FinalizeQueuedDraft()`를 추가해, 전송과 재생성에 흩어져 있던 실행 후 UI/상태 정리 로직을 공통화했습니다.
- 이 변경으로 응답 완료 후의 `타이머 중지`, `버튼 상태 복원`, `스트림 필드 정리`, `대화 저장`, `대화 목록 갱신`, `대기열 완료/실패 반영`, `다음 큐 작업 시작` 경로가 전송과 재생성에서 같은 helper를 공유하게 됐습니다. 현재 AX Agent 채팅 엔진 공통화는 준비/실행 선택/최종 커밋/후처리까지 대부분 한 축으로 모이는 단계까지 왔습니다.
- 업데이트: 2026-04-05 12:53 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `_executionHistoryRenderTimer``ScheduleExecutionHistoryRender()`를 추가해, `OnAgentEvent(...)`가 실행 이벤트마다 즉시 `RenderMessages()`를 호출하지 않도록 바꿨습니다.
- 이제 `ShowExecutionHistory`가 켜진 Cowork/Code 대화에서도 실행 이벤트가 짧은 간격으로 몰릴 때는 120ms 단위로 재렌더를 묶어서 한 번만 반영합니다. 이건 실행 중 본문이 번쩍이거나 로그 잔상이 자주 갱신되는 체감을 줄이기 위한 배치 렌더 단계입니다.
- 업데이트: 2026-04-05 12:58 (KST)
- 같은 파일에 `_taskSummaryRefreshTimer``ScheduleTaskSummaryRefresh()`도 추가해, `UpdateTaskSummaryIndicators()`가 실행 이벤트나 서브에이전트 상태 변경마다 즉시 도는 대신 120ms 단위로 묶여 반영되게 했습니다.
- 이 조정은 `RuntimeActivityBadge`, `ConversationStatusStrip`, 완료 요약 라벨 같은 상태 스트립이 빠르게 깜빡이는 체감을 줄이기 위한 것이며, 본문 재렌더 배치와 함께 Cowork/Code 실행 중 전체 UI 안정성을 높이는 단계입니다.
- 업데이트: 2026-04-05 13:03 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `_conversationPersistTimer`, `ScheduleConversationPersist()`, `FlushPendingConversationPersists()`를 추가해, 실행 이벤트와 실행 기록이 들어올 때마다 곧바로 `_storage.Save(...)`를 치지 않도록 바꿨습니다.
- `AppendConversationExecutionEvent()``AppendConversationAgentRun()`는 이제 `ChatSessionStateService`의 append 메서드를 `storage=null`로 호출하고, 변경된 `ChatConversation`만 220ms 단위로 지연 저장합니다. 이 변경은 Cowork/Code 실행 중 빈번한 이벤트가 들어올 때 디스크 I/O 때문에 체감이 끊기는 문제를 줄이기 위한 배치 저장 단계입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 13:12 (KST)
- 실행 완료 뒤 메시지 컬럼을 크게 흔들던 보조 UI를 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `RenderSuggestActionChips()`는 더 이상 본문 `MessagePanel`에 직접 제안 칩 컨테이너를 추가하지 않고, 제안 라벨 요약만 토스트로 표시합니다. Cowork/Code에서 `suggest_actions` 결과가 들어올 때 본문 레이아웃과 스크롤이 흔들리던 경로를 먼저 끊는 안정화 조치입니다.
- 같은 파일의 `DraftQueuePanel`도 기본 축약 표시로 바꿨습니다. 대기열은 처음부터 모든 섹션 카드를 다 렌더하지 않고, `CreateDraftQueueSummaryStrip(...)`의 요약 pill과 `CreateCompactDraftQueuePanel(...)`의 핵심 항목 한 장만 먼저 보여 줍니다. 사용자가 `상세 보기`를 누를 때만 `실행 중 / 다음 작업 / 보류 / 완료 / 실패` 섹션이 펼쳐집니다.
- 이번 조정으로 AX Agent 채팅 엔진 공통화 작업 중에도 보조 카드가 컴포저 위 레이아웃을 크게 밀어 올리거나, 실행 중간에 메시지 축을 흔드는 체감을 줄이는 방향으로 정리했습니다. 다음 단계는 이 축약형 UI 위에서 Cowork/Code 완료 카드와 큐 완료 후처리를 더 엔진 중심으로 맞추는 것입니다.
- 업데이트: 2026-04-05 13:20 (KST)
- Cowork/Code 완료 직후 저장 경로도 한 단계 더 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `PersistConversationSnapshot(...)`을 추가해 중간 저장, 최종 저장, 지연 저장 flush를 같은 helper가 담당하도록 만들었습니다.
- `ResetStreamingUiState()`는 이제 스트리밍 UI 타이머를 내리기 전에 `FlushPendingConversationPersists()`를 먼저 호출합니다. 이 변경은 실행 종료 직전 들어온 마지막 `ExecutionEvent` 또는 `AgentRun`이 지연 저장 큐에만 남아 있다가 타이머 중지와 함께 누락될 수 있는 경로를 막기 위한 것입니다.
- 같은 변경에서 `RunAgentLoopAsync(...)` 내부의 직접 `_storage.Save(...)` / `RememberConversation(...)`는 제거했습니다. Cowork/Code 완료 시점 저장은 이제 `FinalizeConversationTurn(...)`의 단일 완료 경로에서만 수행되어, AgentLoop 내부 저장과 완료 후 저장이 중복으로 겹치던 경로를 줄였습니다.
- 업데이트: 2026-04-05 13:29 (KST)
- 실행 이벤트가 들어올 때 즉시 많이 흔들리던 UI 갱신도 배치형으로 추가 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `_agentUiEventTimer`, `ScheduleAgentUiEvent(...)`, `FlushPendingAgentUiEvent()`를 넣어, `UpdateStatusBar(...)`, `UpdateAgentProgressBar(...)`, `UpdatePlanViewerStep(...)`, `CompletePlanViewer()`, `RefreshFileTreeIfVisible()`, `RenderSuggestActionChips(...)`, 자동 프리뷰 반영이 모두 최근 이벤트 기준 90ms 배치 갱신을 타게 했습니다.
- `OnAgentEvent(...)`는 이제 실행 이벤트를 먼저 대화 모델(`ExecutionEvents`, `AgentRuns`)과 앱 상태에 반영하고, UI는 배치 flush가 따라가도록 정리됐습니다. 이건 `claw-code` 기준의 “세션/상태 우선, 화면은 그 결과를 따라감” 원칙을 AX 구조에서 더 강화하는 단계입니다.
- 업데이트: 2026-04-05 13:37 (KST)
- 남아 있던 큰 UI 결합점인 “대기열 실행이 입력창을 거쳐 시작되는 구조”도 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SendMessageAsync(...)`는 이제 직접 텍스트 인자를 받을 수 있고, `StartNextQueuedDraftIfAny(...)``InputBox.Text = next.Text` / `Focus()` / `UpdateInputBoxHeight()`를 거치지 않고 `SendMessageAsync(next.Text)`를 바로 호출합니다.
- 이 변경으로 대기열 기반 Cowork/Code 후속 작업은 입력창 상태와 포커스를 덜 흔들고, 실행 축 자체가 입력 UI보다 엔진/세션 흐름에 더 가까워졌습니다. 현재 남은 마감 작업은 완료 카드와 일부 에러 재시도 표시처럼 아직 창 코드에 남아 있는 소수의 직접 UI 후처리를 더 줄이는 단계입니다.
- 업데이트: 2026-04-05 13:44 (KST)
- `RetryLastUserMessageFromConversation()`도 입력창 의존을 제거했습니다. 마지막 사용자 요청을 재시도할 때 더 이상 `InputBox.Text`를 바꾸지 않고, 유휴 상태면 `SendMessageAsync(lastUserMessage)`로 바로 실행하며, 이미 작업 중이면 `ChatSession.EnqueueDraft(..., \"now\", ..., \"direct\")`로 곧바로 대기열에 올립니다.
- 이 조정으로 실패 후 재시도 역시 입력창 포커스/높이/슬래시 칩 상태와 분리되었고, Cowork/Code의 재시도 흐름이 세션/대기열 중심으로 더 정리됐습니다.
- 업데이트: 2026-04-05 13:52 (KST)
- 남아 있던 구형 본문 재시도 카드도 제거했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddRetryButton()` 메서드와 그 호출 경로를 걷어내고, 예외 발생 시에는 토스트로만 짧게 안내한 뒤 작업 요약/실패 이력의 재시도 액션을 사용하게 했습니다.
- 이 변경으로 본문 `MessagePanel`에 실패 카드가 임시로 직접 삽입되는 구형 흐름이 사라졌고, 오류 복구도 메시지 축보다는 작업 요약 액션 축으로 수렴하게 됐습니다.
- 업데이트: 2026-04-05 14:00 (KST)
- UI도 `claw-code` 레퍼런스 방향으로 1차 단순화를 반영했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `MessagePanel` / `EmptyState`의 폭을 `880`으로 낮추고, 메시지 스크롤 여백을 줄여 메시지 축이 더 밀도 있게 보이도록 조정했습니다.
- 빈 상태는 부유 애니메이션이 있는 그라디언트 아이콘을 제거하고, 작은 정적 카드형 아이콘 + 짧은 문구로 바꿨습니다. 상단 `AgentProgressBar`는 패딩과 프로그레스 바 높이를 줄였고, 컴포저는 `800px` 축으로 넓히면서 `DraftPreviewCard`, `InputGlowBorder`, `InputBorder`의 라운드/그림자 강도를 낮춰 장식량을 줄였습니다.
- 메시지 행 자체도 `claw-code` 방향으로 더 눌렀습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `MessagePanel` 하단 여백을 줄였고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddMessageBubble(...)`에서 사용자/assistant 카드의 좌우 마진, 코너 라운드, 패딩, 폰트 크기, 타임스탬프 크기를 전반적으로 축소했습니다.
- assistant 헤더는 아이콘/이름을 더 작고 옅게 조정해 메시지 본문이 먼저 읽히도록 바꿨고, 액션 바 버튼도 패딩과 간격을 줄여 hover 시에도 과하게 튀지 않게 맞췄습니다. `AddAgentEventBanner(...)` 역시 좌우 마진, 아이콘/라벨, 토큰 배지, 요약 텍스트 밀도를 함께 낮춰 실행 로그가 본문보다 먼저 보이던 시각적 압박을 줄였습니다.
- 폭 계산도 `claw-code`식 반응형 축으로 다시 맞췄습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `ComposerShell` 고정폭을 제거하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `UpdateResponsiveChatLayout()`를 추가해 `MessageScroll.ActualWidth` 기준으로 `MessagePanel`, `EmptyState`, `ComposerShell` 폭을 함께 다시 계산합니다.
- 이 변경으로 창이 줄어들 때 메시지 축과 컴포저가 함께 자연스럽게 줄고, 넓어질 때는 적절한 상한만 유지한 채 부드럽게 넓어집니다. 초기 `Loaded`와 창 `SizeChanged` 둘 다 같은 계산 경로를 타게 연결했습니다.
- 컴포저 상단 바도 1차 재구성했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `InputBorder`, `DraftPreviewCard`, `DraftQueuePanel`의 패딩/마진/그림자를 줄여 큰 카드 느낌을 낮췄고, `BtnModelSelector`, `TokenUsageCard`, `BtnTemplateSelector`의 높이, 패딩, 아이콘·폰트 크기를 함께 축소했습니다.
- 토큰 사용 카드의 원형 게이지 크기, 보조 텍스트 크기, `압축` 버튼도 같이 줄여 상단 옵션 바가 입력축보다 과하게 두꺼워 보이던 문제를 완화했습니다. 이 단계는 `claw-code`처럼 입력부를 “크게 장식된 카드”보다 “얇은 하단 작업 바”에 가깝게 만드는 1차 조정입니다.
- Cowork/Code 상태 UI도 더 얇게 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`의 패딩, 폰트 크기, 아이콘 크기, 간격을 전반적으로 줄여 항상 보이는 상태 스트립이 본문보다 앞서 튀지 않게 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ShowTaskSummaryPopup()`도 제목/설명/최근 실행 섹션 밀도를 낮추고 최근 실행 목록을 2개로 줄였습니다. 작업 요약 팝업이 상태 진단용 보조 패널 역할에 더 가깝게 남도록 한 단계 정리한 것입니다.
- 작업 요약 내부 카드도 추가로 축소했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `CreateTaskSummaryActionButton(...)` 버튼 규격을 더 작게 낮췄고, 권한/훅/백그라운드 카드의 패딩과 마진도 줄였습니다.
- 최근 권한 이력은 2개, 최근 훅은 3개, 최근 백그라운드 작업은 2개까지만 보여 주도록 줄여, 작업 요약 팝업이 상태 정보는 유지하되 세로 길이와 시각 압박이 커지지 않도록 정리했습니다.
- 좌측 패널과 하단 바도 `claw-code` 시각 언어 쪽으로 다시 묶었습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 사이드바 폭을 `256`으로 줄이고, 헤더 앱 아이콘을 작은 배지형으로 바꿨으며, `새 대화`, `검색`, 카테고리 드롭다운, 탭별 메뉴 행의 패딩/폰트/아이콘 크기를 함께 낮췄습니다.
- 아이콘 바의 사용자 원형 배지와 사이드바 하단 계정 영역도 강조색 단색 원 대신 `HintBackground + BorderColor` 기반의 중립 배지형으로 바꿨고, 설정 버튼 크기도 줄였습니다. 하단 상태바는 높이와 패딩을 더 낮추고, `StatusDiamond`를 작은 원형 점으로 바꿔 `claw-code`처럼 얇은 상태선에 더 가깝게 정리했습니다.
- 실행 로그 배너도 본문 침범을 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)` 에서 debug 전용 `ToolInput` 카드는 길이와 패딩을 더 줄였고, `FilePath`는 일반 로그에서는 액션이 붙은 카드형 대신 파일명 한 줄만 보여 주도록 분기했습니다.
- 이 변경으로 빠른 액션이 붙은 파일 경로 카드와 디버그 상세는 `debug` 로그에서만 크게 보이고, 일반 Cowork/Code 로그에서는 본문 아래에 더 얇은 보조 행으로만 남습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 14:49 (KST)
- 대화 목록 행 카드와 축소 아이콘 바도 `claw-code` 쪽 시각 언어에 더 맞췄습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddConversationItem(...)`에서 선택 상태 배경과 액센트 바 두께를 더 줄이고, 목록 행 패딩/아이콘/폰트/배지 크기를 전반적으로 낮췄습니다.
- `진행 중`, `성공`, `실패` 배지는 더 작고 중립적인 톤으로 정리했고, 실행 요약 텍스트도 한 단계 작은 크기와 짧은 상하 여백을 써서 목록 메타가 제목보다 덜 튀게 조정했습니다. 호버 시 확대 애니메이션은 제거하고 얇은 배경 강조만 남겨, 목록 반응이 더 차분한 레이어로 보이게 바꿨습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 축소 아이콘 바는 상하 row 높이와 버튼 규격, 아이콘 크기, 사용자 배지 크기를 함께 줄여 현재 사이드바 헤더/하단 계정 영역과 같은 밀도로 묶었습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 14:57 (KST)
- AX Agent 내부 설정 탭 구조도 다시 나눴습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 좌측 네비에서 기존 `스킬/차단``도구 / 스킬 / 차단`으로 분리했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SetOverlaySection(...)` 가시성 로직도 각 탭에 맞는 패널만 보이도록 재구성했습니다.
- `공통` 탭에는 숨겨져 있던 `테마 스타일`, `테마 모드`를 실제 선택 카드로 복구했고, `도구` 탭에는 훅/도구 커넥터 목록, `스킬` 탭에는 스킬 폴더/슬래시/로드된 스킬/폴백 모델/MCP 서버, `차단` 탭에는 차단 경로와 확장자만 남도록 분리했습니다.
- 메인 설정 쪽은 [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 에서 `AgentShortcutTabItem``MainSettingsTab` 맨 끝으로 다시 추가해, AX Agent 이동 항목이 좌측 사이드바 맨 아래에 오도록 정리했습니다.
- 런처 하단 위젯도 일반 설정에서 요소별로 제어할 수 있게 확장했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)에 `성능 / 포모도로 / 메모 / 날씨 / 일정 / 배터리` 위젯 표시 토글을 추가했습니다.
- [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml) 에서는 하단 `Ollama / API / MCP` 서버 상태 위젯을 제거했고, [LauncherWindow.Widgets.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.Widgets.cs) 에서는 서버 상태 polling과 dot 갱신 경로를 제거한 뒤 위젯 표시/숨김을 설정값 기준으로 통합했습니다.
- 배터리 위젯은 사용자 토글과 실제 배터리 가용 상태를 함께 반영하도록 `UpdateWidgetVisibility()`에서 최종 가시성을 결정하게 바꿨고, 모든 위젯이 꺼져 있으면 하단 위젯 바 전체도 자동으로 숨깁니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 15:16 (KST)
- `claw-code` 기준 계획 UX 정리도 반영했습니다. [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 의 `ResolveEffectivePlanMode(...)` 는 이제 항상 `off`를 반환해, 저장된 예전 계획 모드 값이 남아 있어도 런타임에서는 자동 계획/자동 승인 팝업이 다시 살아나지 않게 했습니다.
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 과 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 메인 설정과 AX Agent 내부 설정의 `계획 모드` UI를 `Collapsed`로 전환했고, [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 도 `off` 고정으로 바꿔 예전 persisted 값이 다시 UI나 상태바에 반영되지 않게 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `CreatePlanDecisionCallback()`은 더 이상 채팅 본문에 인라인 승인 버튼을 추가하지 않고, [PlanViewerWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PlanViewerWindow.cs) 를 AX Agent 창 owner와 merged resources 기준으로 생성해 플랜 팝업이 AX Agent 테마와 같은 리소스 축을 따르도록 바꿨습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 16:20 (KST)
- `claw-code` 기준 UI/엔진 재구성 1차도 진행했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 AX Agent 메인 레이아웃의 사이드바 폭을 줄이고, 메시지/빈 상태/컴포저의 기본 폭과 패딩, 라운드, 그림자 강도를 다시 조정해 메시지 중심 구조로 압축했습니다.
- 보조 상태 UI도 더 얇게 만들었습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 대화 상태 스트립은 Chat 탭에서 숨기고 Cowork/Code도 실패/승인 대기/차단 같은 핵심 상태만 보여 주도록 줄였으며, 빠른 스트립도 Chat에서는 열리지 않게 정리했습니다.
- 반응형 폭 계산은 같은 파일의 `UpdateResponsiveChatLayout()`에서 다시 조정해 메시지 축 상한을 `840`, 컴포저 축 상한을 `760` 기준으로 맞췄고, 좁은 창에서는 둘이 같은 중심선으로 함께 줄어들도록 재계산하게 했습니다.
- 엔진 쪽은 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `FinalizeExecutionContent(...)` 를 추가해 취소/오류/빈 응답 정규화를 공통 helper로 모았습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 일반 전송과 재생성은 이제 같은 helper를 써서 응답 마감을 처리합니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 16:33 (KST)
- Cowork/Code 보조 상태 레이어도 한 단계 더 줄였습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `LastCompletedLabel`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`는 패딩·폰트·간격을 추가로 낮춰 `claw-code`처럼 상시 노출돼도 본문보다 덜 튀는 보조 레이어로 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ShowTaskSummaryPopup()`, `CreateTaskSummaryActionButton(...)`, `BuildHookSummaryCard(...)`, `BuildActiveBackgroundSummaryCard(...)`, `BuildRecentBackgroundJobCard(...)`는 팝업 헤더, 필터 칩, 최근 실행 카드, 훅/백그라운드 카드의 라운드·패딩·마진·글자 크기를 다시 줄여 작업 요약이 상태 대시보드보다 진단용 팝업에 가깝게 보이도록 맞췄습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 17:27 (KST)
- 메시지 행 메타와 완료 카드 라벨도 한 단계 더 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddMessageBubble(...)` 에서 사용자/assistant 버블 패딩, 코너, 폰트, 타임스탬프, assistant 헤더 아이콘/이름 크기를 추가로 낮춰 본문 텍스트가 더 먼저 읽히도록 조정했습니다.
- 작업 요약 팝업의 완료 카드도 `실행 run`, `최근 실패`, `최근 실행`, `로그`, `파일`, `후속 큐`, `다시 시도`, `타임라인`처럼 더 짧은 라벨로 바꾸고, run/step 메타와 요약 텍스트 폰트도 함께 낮춰 전체 밀도를 더 가볍게 정리했습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 17:33 (KST)
- Cowork/Code 실행 타임라인 배너도 더 얇게 줄였습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)` 에서 일반 실행 배너의 좌우 마진, 아이콘/라벨, 경과 시간, 토큰 pill, 요약 텍스트, 파일 경로 행을 한 단계 더 축소했고, review 신호 칩은 `debug` 로그에서만 보이게 제한했습니다.
- 이제 평소 Cowork/Code에서는 실행 이벤트가 짧은 한 줄 요약 중심으로 보이고, 상세 진단 정보는 debug 수준일 때만 더 붙습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 17:39 (KST)
- 상단 헤더도 더 `claw-code` 밀도로 줄였습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 상단 탭 버튼 폰트/패딩/코너를 다시 낮추고, 탭 그룹 래퍼와 제목 서브 바 높이·패딩도 함께 줄였습니다.
- 대화 제목 폰트와 최대 폭, 빠른 스트립 버튼 규격, 프리뷰 토글 크기와 라벨도 더 작게 조정해 상단 보조 정보가 본문보다 덜 튀게 정리했습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 17:45 (KST)
- 좌측 사이드바도 한 번에 더 `claw-code` 쪽 비율로 줄였습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 헤더, 새 대화/검색 액션, 검색 편집기, 필터 드롭다운, 탭별 메뉴, 전체 삭제, 하단 사용자/설정 영역까지 패딩·폰트·아이콘·배지 크기를 전반적으로 낮췄습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 실제 사이드바 폭을 `270 -> 248`로 줄이고, `AddConversationItem(...)`의 대화 목록 카드 패딩, 코너, 아이콘 열 폭, 제목/날짜/실행 메타 폰트, 편집 버튼 규격, 선택 액센트 바 두께도 함께 축소해 목록 전체가 더 차분한 레이어로 보이게 맞췄습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 17:53 (KST)
- 큰 카드형 요소도 더 `claw-code` 쪽으로 눌렀습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddPlanningCard(...)` 에서 계획 카드 라운드, 패딩, 헤더 아이콘/텍스트, 진행률 텍스트, 단계 행 폰트를 전반적으로 줄이고, 계획 헤더 문구도 더 짧게 정리했습니다.
- 같은 변경에서 `CreateCompactEventPill(...)`, `CreateTimelineLoadMoreCard(...)`도 함께 축소해 컨텍스트 압축 pill과 “이전 대화 더 보기” 카드가 본문보다 과하게 두껍게 보이지 않도록 맞췄습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 18:01 (KST)
- 엔진 마감도 한 단계 더 진행했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `ExecutePreparedTurnAsync(...)`를 추가해 `send``regenerate`가 같은 실행/예외/취소/최종 커밋/후처리 helper를 타도록 묶었습니다. 이로써 prepared-execution 이후 마감 경로도 하나로 수렴했습니다.
- 같은 변경에서 계획 이벤트는 기본적으로 큰 카드가 아니라 얇은 요약 pill로만 보이고, `debug` 로그일 때만 `AddPlanningCard(...)`가 펼쳐지도록 바꿨습니다. 문서형 Cowork/Code 작업에서도 기본 노출이 더 최소화됐습니다.
- 검증 예정: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\`
- 업데이트: 2026-04-05 18:08 (KST)
- AX Agent 엔진 마감 2차로 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)에 `FinalizeExecutionContentForUi(...)`, `NormalizeAssistantContentForUi(...)`를 추가해 취소/오류/빈 응답과 Cowork/Code 완료 문구를 UI용 한국어 기준으로 다시 정규화했습니다. 기존 깨진 문자열을 직접 건드리기보다 호출 축을 새 helper로 교체해 위험을 낮췄습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `RegenerateLastAsync()``RetryWithFeedbackAsync(...)`가 마지막 assistant 응답을 더 이상 `MessagePanel.Children.RemoveAt(...)`로 직접 지우지 않고, 세션/대화 모델을 먼저 갱신한 뒤 `RenderMessages(preserveViewport: true)`로 다시 그리게 바꿨습니다. retry/regenerate가 화면 구조보다 conversation state를 우선하도록 정리한 단계입니다.
- 같은 변경에서 `AddAgentEventBanner(...)``Paused/Resumed``debug`가 아닐 때 기본 타임라인에 그리지 않게 바꿔 Cowork/Code 실행 중 흔한 상태 전환 배너 노출을 더 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:20 (KST)
- AX Agent 메인 UI도 `claw-code` 기준으로 전면 재정비했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 창 기본 크기, 사이드바 폭, 상단 탭 헤더, 검색 바, 메시지 스크롤 축, 빈 상태, 컴포저를 더 평평하고 밀도 높은 구조로 다시 잡았고, 이전 카드형 장식과 큰 코너/그림자를 한 단계 더 걷어냈습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 사용자/assistant 버블, assistant 헤더, compact pill, 이전 대화 로드 카드, planning 카드의 패딩/메타/폰트와 여백을 다시 줄여 transcript 우선 구조로 맞췄습니다. `UpdateResponsiveChatLayout()``message 960 / composer 900` 축으로 재설정해 창 축소·확대 시 본문과 입력축이 더 같은 중심선을 공유하도록 바꿨습니다.
- 같은 변경에서 사이드바 열림 폭은 `220`, 최소 폭은 `168`로 다시 줄여 `claw-code`처럼 좌측 내비가 본문을 덜 먹도록 조정했습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `88%`, 채팅 메인 UI `94%`, Cowork/Code 상태 UX `89%`, 내부 설정 연결 `88%`, 전체 AX Agent `92%` 정도로 봅니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:30 (KST)
- Cowork/Code 보조 상태 레이어를 추가로 축소했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `ConversationStatusStrip`, `ConversationQuickStrip`, `AgentProgressBar`, `RuntimeActivityBadge`, `ExecutionLog`, `SubAgentIndicator`, `StatusElapsed`, `StatusTokens`는 패딩·폰트·간격을 한 단계 더 낮춰 transcript 우선 구조를 해치지 않도록 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `ShowTaskSummaryPopup()``claw-code` 기준 최소 노출 방향으로 다시 구성했습니다. 필터 칩 행을 제거했고, 최근 실행은 1건 기준의 마지막 실행 카드만 남기며, 활성/최근 작업 카드도 각각 `3/2`개만 노출하게 줄였습니다.
- 같은 파일의 `BuildHookSummaryCard(...)`, `BuildActiveBackgroundSummaryCard(...)`, `BuildRecentBackgroundJobCard(...)`는 라운드, 패딩, 타이포 밀도를 함께 낮추고, 배경/훅 카드의 이동·필터 액션을 대부분 제거해 “상태 대시보드”보다 “짧은 진단 카드” 쪽으로 맞췄습니다. `AddTaskSummaryObservabilitySections(...)`는 기본 팝업에서 권한/백그라운드 현재 상태만 남기고 권한 이력/훅 이력/최근 백그라운드 이력을 기본 노출에서 제외했습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `88%`, 채팅 메인 UI `95%`, Cowork/Code 상태 UX `91%`, 내부 설정 연결 `88%`, 전체 AX Agent `93%` 정도로 봅니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:40 (KST)
- Cowork/Code 실행 타임라인도 더 `claw-code` 기준으로 눌렀습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)``debug`가 아닐 때 `ToolCall` 중간 이벤트를 기본 타임라인에서 숨기고, 일반 요약 문구 길이와 메타 폰트도 더 줄여 결과/오류 중심 흐름이 먼저 보이게 정리했습니다.
- `AddPlanningCard(...)`는 계획 카드 라운드, 패딩, 헤더 문구, 단계 폰트와 최대 폭을 더 낮춰 “계획 패널”보다 “짧은 계획 메모”에 가깝게 축소했습니다. transcript에 붙는 계획 표시가 이제 본문과 더 같은 축을 쓰도록 정리됐습니다.
- 같은 변경에서 `BuildTaskSummaryCard(...)``CreateTaskSummaryActionButton(...)` 도 한 단계 더 소형화했고, `BuildTaskSummaryActionRow(...)` 의 권한 카드 액션에서는 이미 제거한 `계획 모드` 버튼을 빼서 엔진 기본 정책과 UI가 다시 어긋나지 않게 맞췄습니다.
- 현재 `claw-code` 대비 추정 진척율은 핵심 엔진 `89%`, 채팅 메인 UI `96%`, Cowork/Code 상태 UX `92%`, 내부 설정 연결 `88%`, 전체 AX Agent `94%` 정도로 봅니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:49 (KST)
- 사용자 피드백 기준 UI 복구도 반영했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 상단 탭은 너무 얇아진 세그먼트형에서 한 단계 되돌려 `TopTabBtn` 폰트와 패딩, 탭 그룹 외곽 패딩을 키워 예전처럼 더 읽기 쉬운 pill 형태로 복구했습니다.
- 같은 파일의 하단 컴포저 상단 줄에서는 `TokenUsageCard``BtnTemplateSelector`가 같은 Grid 컬럼을 공유해 겹치던 구조를 수정했습니다. 컬럼을 4개로 나눠 `모델 선택 / 여백 / 토큰 카드 / 프리셋 버튼`을 각각 독립 배치했고, 카드/버튼의 폰트와 패딩도 다시 키워 하단 레이아웃이 눌려 보이지 않게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 18:55 (KST)
- 작업 유형 카드도 사용자 피드백 기준으로 다시 정리했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildTopicButtons()` 에서 프리셋/기타/프리셋 추가 카드 하단의 상시 설명 텍스트를 제거하고, 모든 카드를 `132 x 110` 규격으로 통일했습니다.
- 카드 설명은 hover 시 카드 하단에 뜨는 작은 라벨과 ToolTip으로만 노출되게 바꿨고, 기존 `ScaleTransform` 확대 애니메이션은 제거해 hover 시 배경과 테두리만 바뀌도록 정리했습니다. 이로써 마우스 오버 시 카드가 들썩이던 UX를 안정적으로 바꿨습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 `EmptyStateTitle`, `EmptyStateDesc` 폰트를 키워 현재 빈 상태 화면 전반의 글자 크기도 함께 보정했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:02 (KST)
- AX Agent 내부 설정 공통 탭의 서비스/테마 배치를 다시 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 서비스 상세 패널을 `OverlayEndpointFieldPanel`, `OverlayApiKeyFieldPanel`로 분리하고, `테마 스타일`, `테마 모드` 블록을 서비스/모델 상세 바로 아래로 이동해 공통 탭에서 바로 보이게 조정했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `RefreshOverlayServiceFieldVisibility(...)` 를 추가했습니다. 이 메서드는 `Gemini/Claude` 선택 시 주소 입력 패널을 접고 API 키 패널을 전체 폭으로 확장하며, `Ollama/vLLM` 은 주소+키 2열 구성을 유지합니다. `RefreshOverlayVisualState(...)` 에도 이 가시성 동기화를 추가했습니다.
- 같은 파일의 `SetOverlayService(...)` 는 설정 저장 직후 `RefreshOverlayVisualState(loadDeferredInputs: true)` 를 강제 호출하도록 바꿨습니다. 내부 설정에서 서비스 카드를 눌렀을 때 `현재 서비스`, `현재 모델`, 주소/키 라벨이 즉시 바뀌지 않던 문제를 바로잡기 위한 변경입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:10 (KST)
- 상단 탭 UI도 사용자 레퍼런스에 맞춰 되돌렸습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `TopTabBtn` 스타일은 폰트 크기 `13.5`, 패딩 `20x8`, 버튼 간 간격 `2`로 다시 키우고, 선택 상태에서 `LauncherBackground + BorderColor` 조합으로 도톰한 pill 탭이 보이게 조정했습니다.
- 탭 래퍼 자체도 `LauncherBackground` 기준으로 바꾸고 패딩을 늘렸으며, 상단 라벨은 `채팅 / Cowork / 코드`로 변경해 첫 번째 참고 이미지처럼 읽히는 내비 구조로 복구했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:16 (KST)
- `claw-code``StatusLine + PromptInput + Messages` 축과 더 맞추기 위해 AX Agent 보조 상태 UI를 추가로 최소화했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `UpdateConversationQuickStripUi()` 는 단순 실행 개수/활동 개수만으로 자동 노출되지 않고, 사용자가 직접 필터나 정렬을 바꿨을 때만 보이도록 변경했습니다.
- 같은 파일의 상단 `ConversationStatusStrip``permission_waiting / failed_run / permission_denied` 세 경우만 유지하고, `queue``queue_blocked` 는 기본 transcript 상단에서 숨기도록 바꿨습니다. queue 상태는 요약 팝업/대기열 내부에서만 확인하게 정리한 것입니다.
- `AddAgentEventBanner(...)` 의 Planning 처리도 바꿨습니다. 이제 `AgentEventType.Planning` 은 log level과 무관하게 기본 transcript에 compact pill만 남기고, 큰 계획 카드는 기본 흐름에서 사용하지 않습니다. `claw-code`처럼 transcript 중심 읽기 흐름을 유지하기 위한 정리입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:24 (KST)
- AX Agent 레이아웃 타이포를 사용자 피드백 기준으로 다시 키웠습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `SidebarColumn` 폭을 `246`으로 넓히고, `SidebarNewChatTrigger`, `SidebarSearchTrigger`, `SidebarSearchEditor`, `BtnCategoryDrop`, `SidebarChatMenu`, `SidebarCoworkMenu`, `SidebarCodeMenu` 의 폰트/패딩/아이콘 크기를 한 단계 올렸습니다. 첫 번째 레퍼런스처럼 좌측 패널이 너무 작아 보이지 않도록 보정한 변경입니다.
- 같은 파일에서 `EmptyStateTitle`, `EmptyStateDesc``22 / 14.25` 기준으로 키우고 하단 `TokenUsageCard` 를 작은 원형 심볼형으로 교체했습니다. 기존 summary/hint/button 텍스트는 기본 노출에서 제거했고, `TokenUsagePopup` 을 새로 추가해 hover 시에만 컨텍스트 상태를 보여주게 했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildTopicButtons()` 는 작업 유형 카드 크기를 `148 x 124`로 키우고, 아이콘 원형/제목/hover 설명 라벨 폰트를 함께 올려 현재 Cowork 첫 화면 글자 크기가 지나치게 작게 보이던 문제를 보정했습니다.
- 같은 파일에 `_tokenUsagePopupCloseTimer`, `TokenUsageCard_MouseEnter/Leave`, `TokenUsagePopup_MouseEnter/Leave` 를 추가했고, `RefreshContextUsageVisual()` 은 이제 카드 텍스트 대신 popup 전용 타이틀/요약/압축 안내 텍스트를 갱신합니다. 기본 WPF 툴팁 문자열은 제거해 `Codex` 쪽의 아이콘 + hover 상세 구조에 더 가깝게 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:38 (KST)
- `claw-code` 대비 남은 차이와 설정 제거 후보를 다시 문서화했습니다. [docs/claw-code-parity-plan.md](/E:/AX%20Copilot%20-%20Codex/docs/claw-code-parity-plan.md)에 현재 추정 진척율을 `core engine 89% / main transcript UI 96% / Cowork·Code runtime UX 92% / overall 93%`로 기록하고, 남은 차이를 `prompt lifecycle`, `plan/approval render`, `status line/composer`, `runtime event density` 네 축으로 정리했습니다.
- 설정 검토 결과도 같은 문서에 남겼습니다. `PlanMode` 는 현재 `off` 고정 정책이라 `AppSettings`, `SettingsViewModel`, `AppStateService`, `AgentLoopService` 잔재를 제거 대상으로 분류했고, `FreeTierDelaySeconds`, `MaxAgentIterations`, `MaxRetryOnError` 는 일반 사용자 노출보다 개발자 전용으로 내리는 후보로 정리했습니다.
- 작업유형 카드 hover 깜박임도 원인을 확인해 즉시 보정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildTopicButtons()` 에서 프리셋/기타/프리셋 추가 카드의 기본 WPF `ToolTip` 할당을 제거해, custom hover 라벨과 기본 툴팁이 동시에 켜지며 깜박이던 구조를 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:42 (KST)
- `PlanMode` 런타임 잔재도 추가로 정리했습니다. [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 에서 plan prelude 분기는 `shouldGeneratePlanPrelude = false` 기준의 비활성 경로로 바꿔 기본 실행 흐름이 설정값에 흔들리지 않게 했고, `requireApproval` 도 기본 false로 고정했습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서는 이미 숨김 상태였던 `BtnInlinePlanMode`, `CmbOverlayPlanMode` 의 event 연결을 제거했고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 `NextPlanMode`, `PlanModeLabel`, `BtnInlinePlanMode_Click`, `CmbOverlayPlanMode_SelectionChanged`, 관련 quick action 갱신 코드를 걷어내 dead code를 더 줄였습니다.
- 이번 정리로 `PlanMode` 는 정책상 `off` 고정이라는 사실과 실제 런타임/UI 흐름이 더 일치하게 됐습니다. 남은 잔재는 메인 설정 구형 카드와 상태 저장 구조 쪽이라 다음 단계에서 `AppSettings / SettingsViewModel / AppStateService` 축으로 계속 줄이면 됩니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:49 (KST)
- AX Agent 프리셋 빈 화면 레이아웃을 다시 손봤습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `EmptyState``VerticalAlignment="Stretch"``Grid.Row="*"` 기반으로 바뀌었고, `TopicPresetScrollViewer``Auto` 스크롤과 `MaxHeight=420`, 추가 패딩을 적용했습니다. Chat/Cowork 탭에서 카드 하단이 잘리거나 `프리셋 추가` 카드가 반쯤 잘려 보이던 현상을 줄이기 위한 조정입니다.
- 같은 XAML에 헤더 중앙 안내 패널 `SelectedPresetGuide` 를 추가했습니다. 선택된 대화 주제/작업 유형과 한 줄 설명을 상단 중앙에 다시 보여주는 용도이며, 기존 좌측 대화 제목과 우측 프리뷰 토글 사이의 빈 공간을 쓰도록 3열 Grid 구조로 재배치했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `UpdateSelectedPresetGuide(ChatConversation?)` 를 추가했습니다. 이 메서드는 현재 탭과 conversation category를 기준으로 `PresetService.GetByTabWithCustom(...)` 결과를 조회해 라벨/설명을 복구하고, 코드 탭이거나 일치하는 프리셋이 없으면 안내를 숨깁니다. `UpdateChatTitle()``SelectTopic(...)` 에 연결해 새 선택, 대화 재오픈, 탭 전환 시 모두 동일하게 반영되도록 했습니다.
- 상단 탭 그룹과 사이드바 설정 버튼도 다시 키웠습니다. `TopTabBtn` 은 폰트 `14.5`, 패딩 `24x10`, 최소 너비 `78` 기준으로 키우고 선택 상태 배경을 `ItemBackground` 로 바꿨으며, 탭 래퍼도 `HintBackground + Padding=6` 으로 복구했습니다. 하단 사용자 영역의 `BtnSidebarSettings``32x32`, 아이콘 `15px` 로 키워 기존보다 접근성이 좋아졌습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 19:59 (KST)
- `PlanMode` 잔재를 사용자 노출/UI 저장 경로에서 한 단계 더 걷어냈습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 는 내부 설정 오버레이에서 `Code.EnablePlanModeTools` 를 항상 `false` 로 저장하고, 로드 시에도 `ChkOverlayEnablePlanModeTools``false` 로 고정하도록 바꿨습니다. `OverlayTogglePlanModeTools` 의 가시성도 무조건 `Collapsed` 로 맞췄습니다.
- 메인 설정에 남아 있던 관련 UI도 정리했습니다. [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 `플랜 모드`, `Plan Mode 도구` 행은 `Collapsed` 처리했고, [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 plan mode 카드 sync/체크 로직은 `off` 고정값만 반영하게 축소했습니다.
- Cowork/Code 기본 상태 노출도 더 `claw-code` 쪽으로 조정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `UpdateStatusBar(AgentEvent evt)``debug` 로그가 아닐 때 `ToolCall`, `SkillCall`, `Paused`, `Resumed` 이벤트로 상태줄을 바꾸지 않습니다. 기본 상태선은 `planning / permission / step / complete / error` 중심만 유지해 긴 실행 시 화면 churn 을 더 줄였습니다.
- 이번 묶음 후 추정 parity는 `core engine 90% / main transcript UI 96% / Cowork·Code runtime UX 94% / internal settings 91% / overall 94%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:08 (KST)
- 타입 기본값과 큐 요약도 현재 엔진 정책에 맞게 더 정리했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 의 `PlanMode` 는 레거시 호환용 설명으로 정리했고, `EnablePlanModeTools` 기본값은 `false` 로 바꿨습니다. 신규 설정/복구 시점에서도 더 이상 plan mode 도구가 살아나지 않게 하기 위한 조정입니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildDraftQueueCompactSummaryText(...)`, `CreateDraftQueueSummaryStrip(...)``claw-code` 최소 노출 기준으로 조정했습니다. 기본 compact 요약에서는 `실행 / 다음 / 실패`만 남기고, `보류 / 완료` 배지는 `상세 보기`를 펼친 경우에만 보입니다.
- 이번 묶음 후 추정 parity는 `core engine 90% / main transcript UI 96% / Cowork·Code runtime UX 95% / internal settings 92% / overall 95%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:15 (KST)
- 대기열 생성 경로도 더 엔진 친화적으로 공통화했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `EnqueueDraftRequest(...)` helper를 추가하고, `QueueComposerDraft(...)`, `EnqueueFollowUpFromRun(...)`, `BranchConversationFromRun(...)`, `RetryLastUserMessageFromConversation()` 이 모두 같은 적재 경로를 타게 바꿨습니다.
- 이번 정리로 재시도/후속 작업/분기 후속 작업이 더 이상 각자 `session.EnqueueDraft(...)` 를 따로 만지지 않고, 대화 갱신과 current conversation 반영도 한 helper에 모였습니다. 남은 차이는 queue 실행 후처리와 compact 이후 자동 다음 턴의 event polish 쪽입니다.
- 이번 묶음 후 추정 parity는 `core engine 91% / main transcript UI 96% / Cowork·Code runtime UX 95% / internal settings 92% / overall 95%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:21 (KST)
- 하단 보조 UI도 더 최소 노출로 조정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `RebuildDraftQueuePanel(...)` 은 기본 상태에서 `running / queued / failed` 가 모두 0이면 queue 패널을 숨기도록 바뀌었습니다. 완료/보류만 남아 있는 정리성 상태는 기본 transcript 아래를 차지하지 않게 한 변경입니다.
- 같은 파일의 `RefreshContextUsageVisual()` 은 hover 팝업 텍스트를 2줄 요약으로 축소했습니다. 긴 `compact history / session stats / post-compaction usage / top models` 문자열은 기본 hover 경로에서 제거하고, 현재 모델 오늘 사용량과 `compact 후 첫 응답 대기` 또는 자동 압축 시작 임계치만 노출하도록 바꿨습니다.
- 이번 묶음 후 추정 parity는 `core engine 91% / main transcript UI 97% / Cowork·Code runtime UX 96% / internal settings 92% / overall 96%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:27 (KST)
- 하단 queue/status 노출 기준을 더 보수적으로 조정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `RebuildDraftQueuePanel(...)``실행 중 / 다음 / 실패` 중 하나라도 있을 때만 기본 queue 패널을 보이게 유지하고, `완료 / 보류`만 남은 상태에서는 숨기도록 바뀌었습니다. `claw-code`처럼 transcript 하단이 더 조용하게 유지되도록 하기 위한 변경입니다.
- 같은 파일의 컨텍스트 사용량 hover 팝업도 다시 다듬었습니다. 기본 상세는 `현재 모델 오늘 사용량``compact 후 첫 응답 대기 / 자동 압축 시작 임계치` 2줄만 남기고, 나머지 session/compaction 진단 텍스트는 기본 hover 경로에서 제거했습니다.
- 이번 묶음 후 추정 parity는 `core engine 91% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 92% / overall 96%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:34 (KST)
- `claw-code` 기준의 레거시 plan 도구 경로도 더 정리했습니다. [AgentTabSettingsResolver.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTabSettingsResolver.cs) 는 이제 persisted 설정과 무관하게 `enter_plan_mode`, `exit_plan_mode` 를 항상 비활성 목록에 포함합니다. 숨겨진 설정값이 code 실행 경로에 다시 영향을 주지 않게 한 변경입니다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 `GetOperationalStatus(...)` 도 기본 노출을 더 줄였습니다. queue/재시도 대기만 남은 상태로는 `RuntimeActivityBadge` 와 상단 strip 을 켜지 않고, 실제 실행/권한 대기/백그라운드 작업만 기본 헤더 메타를 살리도록 맞췄습니다.
- 이번 묶음 후 추정 parity 는 `core engine 92% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 92% / overall 96%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:48 (KST)
- 레거시 plan mode 도구는 기본 도구 registry에서도 제거했습니다. [ToolRegistry.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolRegistry.cs), [SkillService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SkillService.cs), [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 에서 `enter_plan_mode`, `exit_plan_mode` 등록과 별칭을 제거해, 기본 AX Agent runtime 도구 구성 자체를 더 `claw-code` 방향의 단순 축으로 맞췄습니다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 `RuntimeLabel` 역시 queue/재시도 대기 중심 문구를 걷고 `실행 / 승인 대기 / 백그라운드` 중심 요약만 남도록 조정했습니다. queue 상태는 하단 보조 UI에서만 다루고 상단 기본 메타는 더 읽기 중심으로 유지합니다.
- 이번 묶음 후 추정 parity 는 `core engine 94% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 93% / overall 97%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 20:56 (KST)
- 상태 모델의 레거시 `PlanMode` 필드도 제거했습니다. [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) 의 `PermissionPolicyState` 에 남아 있던 `PlanMode` 속성을 없애고, `LoadFromSettings(...)` 에서도 더 이상 고정 `off` 값을 복사하지 않게 정리했습니다. 현재 정책상 살아 있는 권한/결정/override 정보만 상태 모델에 남도록 맞춘 마감 정리입니다.
- 이번 묶음 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 93% / overall 97%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:03 (KST)
- 메인 설정/내부 오버레이의 숨김 `PlanMode` UI 잔재도 clean 파일 기준으로 제거했습니다. [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 숨김 plan row와 hidden `Plan Mode 도구` row, [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 plan radio sync/handler, [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs) 의 dead `PlanMode` property/save 경로, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 hidden `OverlayTogglePlanModeTools`, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 관련 load/save/visibility dead code를 함께 걷어냈습니다.
- 현재 남아 있는 `PlanMode`/`EnablePlanModeTools` 참조는 JSON 호환용 [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 와 구형 [AgentSettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs) 정도입니다. 현행 경로 기준으로는 핵심 엔진, 메인 설정, AX Agent 내부 오버레이의 레거시 의존성이 거의 제거된 상태입니다.
- 이번 묶음 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 95% / overall 98%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:12 (KST)
- 구형 [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml), [AgentSettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs) 의 `계획 모드`, `Plan Mode 도구` UI와 관련 save/load/event 코드도 제거했습니다. clean 파일 기준 검색상 남은 `PlanMode`/`EnablePlanModeTools` 참조는 JSON 저장 호환을 위한 [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 와 안전 고정값을 두는 [SubAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SubAgentTool.cs) 정도만 남았습니다.
- 이번 묶음 후 추정 parity 는 `core engine 95% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 97% / overall 99%` 정도로 재평가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:20 (KST)
- 레거시 호환용 필드까지 마지막으로 제거했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 의 `planMode`, `enablePlanModeTools` JSON 필드를 삭제했고, [SubAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/SubAgentTool.cs) 의 `llm.PlanMode = "off"` 대입도 함께 제거했습니다. clean 파일 기준 검색상 `PlanMode` / `EnablePlanModeTools` 참조는 이제 0입니다.
- 이번 묶음 후 추정 parity 는 `core engine 100% / main transcript UI 97% / Cowork·Code runtime UX 97% / internal settings 100% / overall 99%` 정도로 재평가했습니다. 현시점 남은 차이는 레거시 설정/엔진 경로가 아니라 transcript 세부 UI/UX polish 영역입니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:29 (KST)
- transcript UI 마지막 polish로 계획 카드도 compact 기본 상태로 전환했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `AddPlanningCard(...)` 는 단계 목록을 기본 숨김으로 두고, `계획 n단계 + 진행률 + 펼치기` 요약만 먼저 노출합니다. 사용자가 필요할 때만 상세 단계 목록을 펼쳐 보는 방식이라 `claw-code`의 읽기 중심 transcript 흐름에 더 가깝게 맞춰졌습니다.
- 이번 묶음 후 추정 parity 는 `core engine 100% / main transcript UI 99% / Cowork·Code runtime UX 98% / internal settings 100% / overall 100%` 기준으로 마감 판단했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:36 (KST)
- 마지막 transcript polish로 하단 상태 메타와 quick strip도 정리했습니다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `StatusElapsed`, `StatusTokens` 는 값이 있을 때만 보이게 바꾸고, `ConversationQuickStrip` 은 실제 running/spotlight count가 있는 경우에만 노출되도록 보수적으로 조정했습니다. 본문을 읽을 때 빈 메타가 자리를 차지하지 않게 한 마감 보정입니다.
- 이번 묶음 후 parity 는 `core engine 100% / main transcript UI 100% / Cowork·Code runtime UX 100% / internal settings 100% / overall 100%` 기준으로 최종 마감 판단했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0
- 업데이트: 2026-04-05 21:43 (KST)
- Document update: 2026-04-05 22:04 (KST) - Added a canonical 10-prompt regression set to `docs/claw-code-parity-plan.md` so AX Agent and `claw-code` can be compared on the same Chat/Cowork/Code scenarios: basic/long chat, document/data cowork, bug-fix/build code, queued follow-up, post-compaction continuity, permission approval, and slash skill entry.
- Document update: 2026-04-05 22:04 (KST) - Added a tool/skill delta snapshot to the parity plan. AX remains stronger on document/office/data workflows, while `claw-code` remains stronger on transcript-native approval/tool-result/permission message taxonomy.
- Document update: 2026-04-05 22:04 (KST) - Switched plan approval flow to transcript-first. `CreatePlanDecisionCallback()` now prepares `PlanViewerWindow` without auto-opening it, shows the inline approval controls in the transcript first, and keeps the bottom `계획` button as the secondary detail surface.
- 업데이트: 2026-04-05 22:18 (KST)
- transcript 도구/스킬 표시 카탈로그를 [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs) 로 분리했습니다. 도구 결과와 task summary 카드가 공통 분류 기준(`파일 / 빌드 / Git / 문서 / 질문 / 제안 / 스킬`)을 사용하도록 맞춰 transcript 언어 일관성을 높였습니다.
- [ChatWindow.TranscriptPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs) 를 추가해 transcript badge/summary/task-summary policy helper 를 partial class 로 분리했습니다. `ChatWindow.xaml.cs` 본문에는 실제 렌더만 남기고, recent task 노출 정책은 `active 우선 + debug 보조` 기준으로 축소했습니다.
- 회귀 프롬프트 세트는 [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md) 로 별도 분리했습니다. Chat/Cowork/Code, queue follow-up, compact 이후 다음 턴, permission, slash skill 진입까지 `claw-code`와 같은 체크리스트로 비교할 수 있습니다.
- 업데이트: 2026-04-05 22:24 (KST)
- 런처 하단 위젯 기본값을 전부 꺼짐으로 변경했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 에서 `ShowWidgetPerf`, `ShowWidgetPomo`, `ShowWidgetNote`, `ShowWidgetWeather`, `ShowWidgetCalendar`, `ShowWidgetBattery` 기본값을 `false` 로 내려 신규 설치/초기화 시 하단 위젯이 기본 숨김 상태로 시작하도록 맞췄습니다.
- 업데이트: 2026-04-05 22:31 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 AX Agent 내부 설정 공통 탭에서 `테마 스타일`, `테마 모드` 섹션을 서비스/모델보다 위로 재배치했습니다. 또 상단 좌측 사이드바 토글 버튼은 3줄 햄버거 아이콘 대신 좌측 패널/본문 구조를 암시하는 패널 토글형 아이콘으로 교체해 메뉴 버튼과 구분되도록 정리했습니다.
- 업데이트: 2026-04-05 19:38 (KST)
- `ChatWindow.xaml`의 상단 탭 스타일과 헤더 래퍼를 정리해 AX Agent의 `채팅 / Cowork / 코드` 탭이 예전 pill 형태로 다시 보이도록 복구했다.
- 업데이트: 2026-04-05 19:41 (KST)
- `ChatWindow` 빈 상태 상단 심볼 크기를 확대하고, 프리셋 카드/기타/프리셋 추가 카드의 내부 아이콘 크기를 축소해 화면 비율을 정리했다.
- 업데이트: 2026-04-05 19:46 (KST)
- `ChatWindow` 모델 선택 팝업의 중복 `InlineModelChipPanel`을 제거하고, 하단 `ModelLabel`은 현재 모델명 중심으로 단순화해 Claude류의 간결한 표시 방식에 가깝게 조정했다.
- 업데이트: 2026-04-05 19:53 (KST)
- `ChatWindow` 작업 폴더 팝업을 최근 목록 중심 UI로 재구성하고, 검색 박스와 요약 스트립을 제거해 `최근 폴더 + 다른 폴더 선택` 흐름으로 단순화했다.
- 업데이트: 2026-04-05 20:01 (KST)
- `SelectedPresetGuide`를 헤더 영역에서 composer 상단으로 재배치해 선택된 주제/작업 유형 안내가 입력 흐름 근처에서 보이도록 정리했다.
- 업데이트: 2026-04-05 20:08 (KST)
- `UpdateSidebarModeMenu()`에서 Chat/Cowork/Code 보조 필터 메뉴를 기본 숨김으로 고정해, 좌측 상단 필터와 겹쳐 보이던 중복 인상을 제거했다.
- 업데이트: 2026-04-05 22:34 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 좌측 보조 필터 메뉴(`주제 / 작업 유형 / 워크스페이스`)를 항상 숨김으로 고정했다. 이제 AX Agent 좌측에서는 상단 공통 필터만 노출되어 Chat/Cowork/Code 모두 하나의 필터 흐름만 유지한다.
- 업데이트: 2026-04-05 22:39 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 Cowork/Code 하단 작업 폴더 바에서 `폴더 연결 해제``X` 버튼을 제거했다. 함께 그리드 컬럼 인덱스와 구분선/권한/Git 배치도 다시 맞춰 작업 폴더, 데이터 활용, 권한, Git 상태가 한 줄로 더 자연스럽게 이어지도록 정리했다.
- 업데이트: 2026-04-05 22:44 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 AX Agent 내부 설정의 `실행 방식` 블록을 `코워크/코드` 공통 탭 전용으로 제한했다. 이제 `호출 간격 최적화`, `의사결정 수준``코워크/코드` 탭에만 보이고, `코워크``코드` 개별 탭에서는 중복 노출되지 않는다. 레거시 `실행 전 계획` 행도 XAML에서 완전히 제거했다.
- 업데이트: 2026-04-05 22:48 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `최대 컨텍스트 토큰` 프리셋 카드에 `32K`, `128K`를 추가했다. 함께 선택 매핑도 확장해서 현재 `MaxContextTokens` 값이 `16K~32K`, `64K~128K` 구간에 있을 때도 가장 가까운 프리셋 카드가 올바르게 활성화되도록 보정했다.
- 업데이트: 2026-04-05 22:53 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 하단 컨텍스트 토큰 심볼과 hover 팝업을 다듬었다. 토큰 카드가 숨겨질 때 팝업도 함께 강제 종료되도록 보강했고, hover 종료 시 실제로 카드/팝업 둘 다 벗어난 경우에만 닫히게 조건을 정리했다. 또 심볼과 팝업은 흐린 배경/강한 그림자 대신 얇은 테두리와 약한 그림자 중심으로 수정했다.
- 업데이트: 2026-04-05 22:57 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()`에서 프리셋 카드 hover 설명 라벨을 `Visibility` 토글 대신 `Opacity` 전환 방식으로 바꿨다. 카드 hover 중 설명 라벨이 나타날 때 레이아웃이 다시 잡히며 깜빡이던 현상을 줄이기 위한 보정이다.
- 업데이트: 2026-04-05 20:17 (KST)
- 런처 클립보드 붙여넣기 포커스 경로를 공통 helper 기반으로 재정리했다. [ForegroundPasteHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ForegroundPasteHelper.cs) 를 추가해 이전 활성 창 복원, 최소 대기, `Ctrl+V` 주입 순서를 하나로 통일했고, [ClipboardHistoryHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs), [ClipboardHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/ClipboardHandler.cs), [PasteHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/PasteHandler.cs) 가 모두 같은 포커스 복원 로직을 사용하도록 맞췄다.
- 업데이트: 2026-04-05 20:17 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `AddMessageBubble(...)`에서 사용자/assistant wrapper 여백을 줄여 transcript 좌우 끝단 정렬감을 높였다. 또 assistant 헤더의 `AX 에이전트` 라벨, 상단 아이콘, 사용자/assistant 시간 표기 글꼴 크기와 opacity를 함께 올려 메타 정보가 지나치게 작게 보이던 문제를 보정했다.
- 업데이트: 2026-04-05 23:02 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 StartNewConversation()을 수정해 새 대화 클릭 시 기존 대화를 다시 불러오지 않도록 정리했다. 기존 구현은 session.ClearCurrentConversation(_activeTab) 직후 session.LoadOrCreateConversation(...)을 호출해, 기억된 최근 대화가 다시 current conversation으로 복원되는 경로가 있었다. 이 때문에 빈 상태가 잠깐 깜빡인 뒤 기존 메시지가 다시 그려지는 현상이 발생했다. 이제는 clear 직후 session.CreateFreshConversation(...)만 사용하고, 이어서 LoadConversationSettings(), LoadCompactionMetricsFromConversation(), SyncAppStateWithCurrentConversation(), UpdateSelectedPresetGuide(), UpdateConditionalSkillActivation(reset: true), RenderMessages()를 fresh conversation 기준으로 다시 호출해 새 대화 화면이 안정적으로 유지되게 했다.
- 업데이트: 2026-04-05 23:09 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 AX Agent 좌측 사이드바 기본 폭을 246 -> 262로 넓히고, AX Agent 헤더, 새 대화, 검색, 상단 공통 필터, 탭별 보조 메뉴, 전체 삭제, 하단 사용자/설정 영역의 폰트와 아이콘 크기를 전반적으로 상향 조정했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 대화 목록 렌더에서도 그룹 헤더, 대화 카드 제목, 날짜, 실행 상태, 실행 요약, 편집 아이콘 크기를 함께 키우고 카드 내부 패딩을 늘려 좌측 패널 텍스트가 지나치게 작게 보이던 문제를 보정했다.
- 업데이트: 2026-04-05 23:15 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에 AX Agent 좌측 패널과 본문 사이를 드래그할 수 있는 SidebarResizeSplitter를 추가했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 사이드바 마지막 확장 폭을 _sidebarExpandedWidth로 기억하도록 하고, 열기/닫기 애니메이션과 splitter 드래그 완료 이벤트가 같은 폭 상태를 공유하도록 정리했다. 이제 사용자가 경계선을 조절한 뒤 사이드바를 닫았다 다시 열어도 마지막 너비가 유지된다.
- 업데이트: 2026-04-05 23:22 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 상단 헤더 첫 행 높이를 42 -> 48로 늘리고, 중앙 탭 래퍼의 Padding, MinHeight를 함께 키워 탭 글자가 위아래로 잘리던 문제를 보정했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 사용자/assistant/streaming 메시지 컨테이너를 모두 GetMessageMaxWidth() 기준 동일 폭으로 맞추고, wrapper 자체는 중앙 transcript 축을 공유한 채 내부 bubble만 좌우 정렬되도록 바꿨다. 이로써 메시지 박스가 서로 다른 고정 마진 때문에 어긋나 보이던 정렬 문제를 줄였다.
- 업데이트: 2026-04-05 23:28 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 작업 폴더 선택 팝업을 더 도톰한 radius, 약한 그림자, 넓은 padding 기준으로 다시 조정했다. 최근 헤더 여백과 목록 내부 padding도 함께 늘려 레퍼런스처럼 더 안정된 카드형 팝업으로 보이게 했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `CreatePopupMenuRow(...)`는 하단 border 구분선 방식 대신 개별 라운드 row 방식으로 바꿨다. 최근 폴더 항목과 `다른 폴더 선택` 행이 동일한 시각 언어를 쓰고, 선택 상태는 우측 체크와 은은한 배경으로만 표현되게 정리했다.
- 업데이트: 2026-04-05 23:33 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 빈 상태 상단 심볼 크기를 42 기준으로 다시 맞추고, 제목/설명 폰트도 함께 미세 조정했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()`에서 프리셋 카드 아이콘 원형 배경과 아이콘 크기, `기타`/`프리셋 추가` 카드 심볼 크기를 함께 줄여 프리셋 아이콘이 과하게 커 보이지 않도록 균형을 맞췄다.
- 업데이트: 2026-04-05 23:38 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 하단 컨텍스트 토큰 hover 팝업을 더 단순하게 정리했다. 모델명/오늘 사용량 줄은 제거하고 `현재 사용량``자동 압축 시작 기준`만 남겼다.
- 같은 파일의 토큰 팝업 닫힘 판정도 `IsMouseOver`만 보던 방식에서 실제 마우스 좌표 기반으로 바꿔, 마우스를 떼었는데도 라벨이 남아 있는 경우를 줄였다.
- 업데이트: 2026-04-05 23:44 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 권한 모드 팝업 폭, padding, 그림자를 줄여 더 단순한 드롭다운 패널처럼 보이게 정리했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 권한 모드 팝업의 `상세 정보` 섹션을 제거하고, `계획 모드`를 제외한 핵심 권한 모드 행만 남기도록 재구성했다. 각 행도 왼쪽 accent bar 방식 대신 라운드 row 리스트형으로 바꿔 레퍼런스처럼 더 가벼운 선택 UI로 맞췄다.
- 업데이트: 2026-04-05 23:49 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 상단 중앙 탭 버튼 폰트, padding, 최소 크기와 탭 래퍼 크기를 소폭 줄여 글자가 잘리던 문제를 보정했다.
- 같은 파일의 빈 상태 상단 아이콘과 제목/설명은 반대로 한 단계 키워, 작업 유형/대화 주제 프리셋 카드 한 장과 상단 안내 블록 사이 시각 비율이 더 자연스럽게 맞도록 조정했다.
- 업데이트: 2026-04-05 23:55 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 하단 컨텍스트 토큰 카드 외곽 크기를 30 기준으로 조정하고 내부 원형 그리드에 여백을 확보해 파이 심볼이 왼쪽에서 잘려 보이지 않도록 보정했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 원형 usage arc와 threshold marker 계산 중심점을 실제 카드 크기에 맞게 다시 잡았다. 함께 hover popup은 비상호작용 툴팁처럼 바꿔 카드 밖으로 벗어나면 더 자연스럽게 닫히도록 정리했다.
- 업데이트: 2026-04-06 00:01 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 토큰 hover popup 닫힘 경로를 추가 보강했다. 카드 hover 종료 외에도 창 전체 마우스 이동, 마우스 클릭, 창 비활성화 시 popup 닫힘을 다시 검사하도록 해 라벨이 남는 경우를 줄였다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 전송 버튼은 42 기준으로 키우고 내부 send glyph 크기와 오프셋을 다시 조정해 아이콘이 작고 아래로 치우쳐 보이던 문제를 보정했다.
- 업데이트: 2026-04-06 00:08 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 내부 설정 동기화 경로에서 `BuildOverlayModelChips(...)` 호출을 제거해, XAML에서 제거한 등록 모델 상단 중복 선택 UI를 더 이상 다시 그리지 않게 정리했다.
- 같은 파일의 `BuildOverlayRegisteredModelsPanel(...)` 에서는 `선택 / 편집 / 삭제` 액션을 기본 `Button` 대신 `Border + MouseLeftButtonUp` 기반 클릭 row로 교체했다. 이로써 AX Agent 내부 설정 오버레이 안에서도 액션 클릭이 더 안정적으로 반응하고, 등록 모델 관리 UI가 리스트 중심 구조로 일관되게 유지된다.
- 업데이트: 2026-04-06 00:14 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 상단 `채팅 / Cowork / 코드` 탭 그룹에서 각 버튼의 margin, padding, 최소 크기와 바깥 래퍼 높이/padding을 소폭 줄였다. 이제 탭 그룹이 지나치게 넓고 꽉 차 보이지 않고, 레퍼런스처럼 좀 더 여유 있는 pill 세그먼트 느낌으로 보인다.
- 업데이트: 2026-04-06 00:22 (KST)
- [docs/claw-code-parity-plan.md](/E:/AX%20Copilot%20-%20Codex/docs/claw-code-parity-plan.md) 에 `claw-code` 대비 추가 품질 향상 계획을 구체화했다. 이번 계획은 transcript renderer 분리, permission presentation catalog 도입, tool result 메시지 분화, plan approval inline 마감, runtime summary 전용 계층화, regression prompt ritual 고정까지 포함한다.
- 설정/로직 검토 기준도 함께 정리해 `FreeTierDelaySeconds`, `MaxAgentIterations`, `MaxRetryOnError`는 개발자 전용 후보로, `OperationMode`, `MaxContextTokens`, `ContextCompactTriggerPercent`, 검증 토글류는 유지해야 할 핵심 런타임 설정으로 구분했다.
- 업데이트: 2026-04-06 00:27 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 기본 창 크기에서 Height를 `820 -> 880`으로 조정했다. AX Agent를 처음 열었을 때 프리셋/입력 영역이 조금 더 여유 있게 보이도록 시작 높이를 상향한 변경이다.
- 업데이트: 2026-04-06 00:31 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 상단 `채팅 / Cowork / 코드` 탭 그룹에서 각 버튼의 margin, padding, 최소 폭/높이와 바깥 래퍼의 padding, 최소 높이를 한 단계 더 줄였다.
- 결과적으로 탭 그룹이 바깥 테두리를 거의 꽉 채우지 않고, pill 바깥선 안쪽에 숨 쉴 여백이 남는 레퍼런스형 비율로 정리됐다.
- 업데이트: 2026-04-06 00:38 (KST)
- [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs) 에서 내부 서비스(Ollama/vLLM) 모델 해석 경로를 보강했다. 현재 선택값이 alias 또는 등록 모델 키여도 `RegisteredModel`에서 실제 모델명을 다시 찾아 payload의 `model` 값으로 보내도록 정리했다.
- 같은 파일에 vLLM용 `max_tokens` 상한 보정 helper를 추가하고, [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs) 의 일반 도구 호출 / OpenAI-compatible tool body 생성에도 같은 값을 쓰게 맞췄다. 이로써 `Model Not Exist`, `invalid max_tokens` 계열 오류를 줄이는 방향으로 정리했다.
- 업데이트: 2026-04-06 00:48 (KST)
- [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs) 의 `LoadOrCreateConversation()`에 현재 탭의 저장되지 않은 fresh conversation 우선 유지 분기를 추가했다. 새 대화를 시작한 직후처럼 `RememberConversation(tab, null)` 상태에서 다시 로드 경로가 돌면, 이전에는 최신 저장 대화를 다시 불러와 기존 transcript가 복원될 수 있었다.
- 이제 현재 세션에 같은 탭의 비저장 fresh conversation이 남아 있으면 최신 저장 meta fallback보다 그 임시 conversation을 먼저 반환한다. 이 변경으로 AX Agent에서 `새 대화` 클릭 시 빈 화면이 잠깐 깜빡인 뒤 기존 대화가 다시 나타나던 문제가 줄어들도록 보정했다.
- 업데이트: 2026-04-06 01:00 (KST)
- [ChatModels.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/ChatModels.cs) 의 `ChatMessage``responseElapsedMs`, `promptTokens`, `completionTokens` 저장 필드를 추가했다. 이제 assistant 응답별 응답시간과 토큰 사용량을 메시지 자체에 묶어 저장할 수 있다.
- [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 의 `CommitAssistantMessage()` / `FinalizeAssistantTurn()`은 응답 메타를 함께 커밋하도록 확장했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 실행 완료 시 `_llm.LastTokenUsage`, 누적 에이전트 토큰, 응답 경과 시간을 assistant 메시지에 저장하고, transcript 렌더 시 `응답시간 · 총 토큰` 메타를 assistant bubble 아래에 함께 표시하도록 연결했다.
- 사용자/assistant 메시지 액션 바는 완전 숨김(Opacity 0) 대신 기본 저강도 노출 + hover 시 100% 강조로 바꿨다. 이로써 메시지에 마우스를 올렸을 때 복사/편집/재생성/수정 후 재시도/좋아요·싫어요가 보이지 않던 UX 문제를 줄였다.
- 업데이트: 2026-04-06 01:08 (KST)
- `claw-code``SessionPreview.tsx`, `PreviewBox.tsx`를 참고해 AX Agent 프리뷰 surface를 공통화했다. [AgentPreviewSurfaceFactory.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentPreviewSurfaceFactory.cs)를 추가해 `title + summary + preview box` 구조를 재사용 가능한 helper로 분리했다.
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs) 의 `BuildPreviewCard`, `BuildFileEditPreviewCard`, `BuildFileWriteTwoColumnPreviewCard`는 이 helper를 쓰도록 정리해 권한 승인 프리뷰가 같은 preview 언어를 따르도록 맞췄다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 우측 프리뷰 패널 헤더를 `파일명 / 경로 / 형식·크기 메타` 구조로 재배치하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `SetPreviewHeader`, `SetPreviewHeaderState`를 추가해 현재 탭 파일 메타를 표시하도록 했다.
- 텍스트 프리뷰 본문도 bordered preview box 안에 렌더되게 바꿔, AX Agent 파일 프리뷰와 transcript/승인 프리뷰 사이 시각 언어를 더 가깝게 맞췄다.
- Document update: 2026-04-06 00:50 (KST) - Reworked Chat/Cowork preset hover behavior in `BuildTopicButtons()`. The previous inline hover label overlay was removed and replaced with a stable tooltip-style description so preset cards only change background/border on hover, eliminating the repeated flicker effect caused by overlay-style label toggling.
- Document update: 2026-04-06 00:45 (KST) - Restored the missing AX Agent internal-settings UX for conversation-style persistence by adding a visible `대화 스타일` section header above the existing `문서 형태` and `디자인 스타일` controls. The underlying save path already existed in `CmbOverlayDefaultOutputFormat_SelectionChanged` and `CmbOverlayDefaultMood_SelectionChanged`; this pass makes that persisted behavior explicit again in the overlay.
- Document update: 2026-04-06 00:45 (KST) - Restored MCP server management inside the AX Agent internal settings overlay. The skill tab now exposes `+ 서버 추가`, and overlay MCP cards support inline enable/disable and delete actions with immediate persistence through `PersistOverlaySettingsState(...)`.
- 업데이트: 2026-04-06 00:35 (KST)
- `ChatWindow.xaml.cs`의 프리셋 카드 hover 처리에서 WPF 기본 ToolTip을 제거했습니다. 이제 hover는 배경/테두리만 바뀌고 tooltip에 의한 enter/leave 반복이 줄어듭니다.
- 업데이트: 2026-04-06 00:42 (KST)
- `ChatWindow` 하단 Git 브랜치 UI를 단순화했습니다. 변경 파일 수/추가/삭제 수치는 기본 버튼에서는 숨기고 브랜치명 중심의 선택 버튼처럼 보이게 조정했습니다.
- 업데이트: 2026-04-05 22:26 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `IsFolderDataAlwaysEnabledTab()` helper를 추가하고, 코드 탭에서는 `_folderDataUsage`를 항상 `active`로 로드/저장하도록 고정했다. 이에 맞춰 `BtnDataUsage_Click`, `UpdateDataUsageUI()`, 오버레이의 `OverlayFolderDataUsageRow`, `BtnOverlayFolderDataUsage_Click`, `CmbOverlayFolderDataUsage_SelectionChanged`도 코드 탭에서는 숨김 또는 강제 active만 유지하게 정리했다.
- 같은 파일의 사용자 메시지 bubble 렌더에서 코워크/코드 탭은 plain `TextBlock` 대신 [MarkdownRenderer.Render](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/MarkdownRenderer.cs) 경로를 타도록 변경했다. 이로써 코워크/코드 사용자 입력 안의 파일명/파일 경로가 assistant 응답과 동일한 파란 강조 규칙을 쓰게 됐다.
- 업데이트: 2026-04-05 22:29 (KST)
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SelectTopic(...)`에서 fresh conversation이 이미 있는 경우에는 `StartNewConversation()`를 다시 호출하지 않도록 보정했다. 기존에는 메시지도 입력도 없는 빈 대화에서 프리셋만 눌러도 새 conversation이 계속 생성되어 좌측 목록에 `새 대화`가 누적될 수 있었다.
- 현재는 `_currentConversation` 존재 여부를 기준으로 빈 대화 재사용과 실제 신규 생성 경로를 분리해, 프리셋 클릭은 같은 새 대화 안에서 메타데이터만 갱신하도록 맞췄다.
- 업데이트: 2026-04-05 22:32 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `ChkOverlayWorkflowVisualizer`, `ChkOverlayShowTotalCallStats`, `ChkOverlayEnableAuditLog``Checked/Unchecked` 이벤트를 연결했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 각 토글 전용 저장 handler를 추가해, 개발자 탭 옵션이 눌린 직후 오버레이 재동기화로 기본값으로 되돌아가던 문제를 해결했다.
- 업데이트: 2026-04-05 22:36 (KST)
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 훅 설정 섹션에서 `도구 훅 스크립트 제한 시간` row 오른쪽 컬럼을 `120 -> 180`, 값 배지 컬럼을 `44 -> 56`으로 늘렸다.
- 같은 파일의 `등록된 훅` 헤더 우측 `훅 추가` 버튼은 좌측 여백과 최소 폭을 키워, 작은 창에서도 텍스트 잘림 없이 보이도록 정리했다.
- 업데이트: 2026-04-05 22:40 (KST)
- AX Agent 테마 리소스 구조를 다시 점검한 뒤 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 프리셋을 추가했다. 새 리소스 파일은 [AgentNordLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordLight.xaml), [AgentNordDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordDark.xaml), [AgentNordSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordSystem.xaml), [AgentEmberLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberLight.xaml), [AgentEmberDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberDark.xaml), [AgentEmberSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberSystem.xaml) 이다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildAgentThemeDictionaryUri()`, `RefreshOverlayThemeCards()`에 새 프리셋 분기를 추가했고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `테마 스타일` 선택 카드에도 `Nord`, `Ember`를 노출했다.
- 업데이트: 2026-04-06 00:58 (KST)
- transcript renderer 분리 1차를 반영했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs)에 `ShowInlineUserAskAsync`, `CreatePlanDecisionCallback`, `ShowPlanButton`, `EnsurePlanViewerWindow` 계열을 옮기고, [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)에 `ShowTaskSummaryPopup`, `BuildTaskSummaryCard`를 옮겨 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 transcript 책임을 줄였다.
- 권한/도구 결과 presentation catalog를 추가했다. [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령/네트워크/파일/일반` 권한 요청·허용 메타를, [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success/error/reject/cancel` 기준의 도구 결과 badge 메타를 제공한다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)`는 이제 이 catalog를 사용해 권한 요청과 도구 결과 badge를 결정한다.
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState``GetOperationalStatusPresentation(...)`을 추가해 status line/runtime summary 계산을 presentation layer로 분리했다. `UpdateTaskSummaryIndicators()`는 presentation summary만 소비하도록 바뀌었다.
- 업데이트: 2026-04-06 01:12 (KST)
- 코워크/코드의 `폴더 내 문서 활용`은 사용자 제어 옵션에서 제거하고 탭별 자동 정책으로 고정했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 하단 데이터 활용 버튼과 AX Agent 내부 설정의 관련 row를 제거했고, [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 의 대응 UI도 정리했다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 는 이제 `GetAutomaticFolderDataUsage()`만 사용해 채팅=`none`, 코워크=`passive`, 코드=`active`를 적용한다. 오버레이 저장에서도 `llm.FolderDataUsage`를 더 이상 사용자 입력으로 덮어쓰지 않으며, UI 클릭/선택 변경 핸들러는 자동 정책 유지용 no-op 수준으로 축소했다.
- 업데이트: 2026-04-06 01:24 (KST)
- transcript display catalog를 `claw-code` 기준으로 정교화했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 도구/스킬 이름과 badge label을 `파일 / 문서 / 빌드 / Git / 웹 / 질문 / 제안 / 에이전트` 축으로 재정의했고, summary fallback 문구도 더 자연스러운 한국어로 정리했다.
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 색상/라벨로 분기하게 바꿨다. [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 도구 결과를 `파일 작업`, `빌드/테스트`, `Git`, `문서`, `스킬`, `웹 요청`, `명령 실행` 기준으로 성공/실패 라벨을 더 세밀하게 반환한다.
- 이벤트 배너 renderer를 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 로 분리했다. 기존 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 있던 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 별도 partial로 옮겨, 이후 `permission/tool-result/plan` 타입별 renderer 확장을 더 쉽게 할 수 있는 구조를 마련했다.
- Document update: 2026-04-06 08:12 (KST) - Split footer/preset/Git popup presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.FooterPresentation.cs`. The folder bar refresh, selected preset guide, Git branch surface update, Git popup assembly, and popup summary-pill/row helpers now live in a dedicated partial instead of the main window orchestration file.
- Document update: 2026-04-06 08:12 (KST) - This pass keeps the AX Agent transcript/runtime flow closer to the `claw-code` separation model by reducing UI assembly inside the main chat window file and isolating footer/prompt-adjacent presentation code for future parity work.
- Document update: 2026-04-06 08:28 (KST) - Split composer/draft queue presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.ComposerQueuePresentation.cs`. Input-box height calculation, draft-kind inference, queue summary pills, compact queue cards, expanded queue sections, queue badges, and queue action buttons now live in a dedicated partial.
- Document update: 2026-04-06 08:28 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused while preserving the same runtime behavior, and it aligns AX Agent more closely with the `claw-code` model of separating prompt/footer presentation from session execution logic.
- Document update: 2026-04-06 08:27 (KST) - Split message interaction presentation out of `ChatWindow.xaml.cs` into `ChatWindow.MessageInteractions.cs`. Feedback button creation, assistant response meta text, transcript entry animation, and inline user-message edit/regenerate flow are now grouped in a dedicated partial.
- Document update: 2026-04-06 08:27 (KST) - This pass further reduces renderer responsibility in the main chat window file and moves AX Agent closer to the `claw-code` structure where transcript orchestration and message interaction presentation are separated.
- Document update: 2026-04-06 08:39 (KST) - Moved runtime status bar event handling and spinner animation out of `ChatWindow.xaml.cs` into `ChatWindow.StatusPresentation.cs`. `UpdateStatusBar`, `StartStatusAnimation`, and `StopStatusAnimation` now live alongside the existing operational status presentation helpers.
- Document update: 2026-04-06 08:39 (KST) - This pass reduces direct status-line branching in the main chat window file and keeps AX Agent closer to the `claw-code` model where runtime/footer presentation is separated from session orchestration.
- Document update: 2026-04-06 08:47 (KST) - Split preview surface rendering out of `ChatWindow.xaml.cs` into `ChatWindow.PreviewPresentation.cs`. Preview tab tracking, panel open/close, tab bar rebuilding, preview header state, CSV/text/markdown/HTML loaders, context menu actions, and external popup preview are now grouped in a dedicated partial.
- Document update: 2026-04-06 08:47 (KST) - This keeps `ChatWindow.xaml.cs` focused on transcript/runtime orchestration and aligns AX Agent more closely with the `claw-code` model where preview/session surfaces are treated as separate presentation layers.
- Document update: 2026-04-06 08:55 (KST) - Split file browser presentation out of `ChatWindow.xaml.cs` into `ChatWindow.FileBrowserPresentation.cs`. File browser open/close handlers, folder tree population, file item header/icon/size formatting, context menu actions, and debounced refresh logic now live in a dedicated partial.
- Document update: 2026-04-06 08:55 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused and aligns AX Agent more closely with the `claw-code` model where sidebar/file surfaces are separated from transcript and runtime flow.

View File

@@ -3,11 +3,97 @@
## Scope
- Align AX Copilot with claw-code quality for loop reliability, permission/hook behavior, and session durability.
## Update
- Updated: 2026-04-05 15:34 (KST)
- Rebased the AX Agent improvement plan on actual `claw-code` runtime files instead of earlier AX snapshots. The reference spine is now `src/bootstrap/state.ts -> src/bridge/initReplBridge.ts -> src/bridge/sessionRunner.ts -> src/screens/REPL.tsx -> src/components/Messages.tsx -> src/components/StatusLine.tsx`.
- AX Agent work should follow that same quality order: state first, execution second, render last. UI-only fixes that bypass state/execution should be treated as temporary.
- Updated: 2026-04-05 16:55 (KST)
- Current estimated parity vs `claw-code`: core execution engine `82%`, main chat UI `68%`, Cowork/Code status UX `63%`, internal settings linkage `88%`, overall AX Agent `74%`.
- Engine-affecting settings should be handled conservatively during parity work. If a setting changes the main execution route, approval flow, or recovery behavior without representing a stable real-world user choice, it should be moved to developer-only UI or removed from user-facing surfaces.
## Preserved History (Summary)
- Core loop guards and post-tool verification gates are already partially implemented.
- Plan Mode, parallel tool execution, and unknown-tool recovery are in place.
- Session restore hardening is ongoing.
## Reference Map
| claw-code reference | AX apply target | completion criteria | quality criteria |
|---|---|---|---|
| `src/bootstrap/state.ts` | `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatStorageService.cs` | one canonical runtime/session state for current turn, queue, retry, execution events, and persisted snapshot | reopen/retry/queue flows do not create duplicate or blank assistant messages |
| `src/bridge/initReplBridge.ts` | `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/LlmService.cs` | send/regenerate/retry/queued follow-up/slash all enter through one prepared-execution path | same input under same settings takes same execution route regardless of entry point |
| `src/bridge/sessionRunner.ts` | `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs` | tool start/result/error/progress normalized once inside loop layer | Cowork/Code no longer flash repeated status strings or overshare debug payloads |
| `src/bridge/bridgeMessaging.ts` | `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Services/Agent/AgentLoopService.cs` | inbound execution events separated from display-only events before UI render | execution event replay does not duplicate visible timeline banners |
| `src/screens/REPL.tsx` | `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml.cs` | screen state transitions, queue flow, retry flow, and composer state use shared runtime helpers | window resize, queue chaining, and retry feel stable instead of UI-patched |
| `src/components/Messages.tsx` | `src/AxCopilot/Views/ChatWindow.xaml.cs` | timeline derives from normalized conversation/session state only | no token-only completions, blank cards, or direct injected duplicates |
| `src/components/StatusLine.tsx` | `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml.cs` | status strip computed from debounced runtime state, not multiple imperative refresh calls | metadata stays lightweight and does not overpower message timeline |
## AX Agent Improvement Phases
### Phase A. Runtime State Canonicalization
- Reference: `src/bootstrap/state.ts`
- AX apply location: `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatStorageService.cs`
- Completion criteria:
- `Chat`, `Cowork`, `Code` all update one shared runtime/session state model.
- queue, retry, post-compaction, and execution-event state can be restored after reopen.
- Quality criteria:
- reopening a conversation reproduces the same visible timeline without extra assistant cards.
- queue and execution badges remain in sync with the stored conversation.
### Phase B. Prepared Execution Unification
- Reference: `src/bridge/initReplBridge.ts`
- AX apply location: `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/LlmService.cs`
- Completion criteria:
- prompt stack assembly, execution mode choice, and final assistant commit are engine-owned.
- send/regenerate/retry/queued follow-up/slash flows all call the same preparation API.
- Quality criteria:
- behavior is deterministic per tab/settings combination.
- UI stops building different prompt stacks for the same conversation state.
### Phase C. AgentLoop Event Normalization
- Reference: `src/bridge/sessionRunner.ts`, `src/bridge/bridgeMessaging.ts`
- AX apply location: `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`
- Completion criteria:
- loop events are normalized into bounded activity/event records before UI consumption.
- permission requests, failure states, retries, and completion states use a stable event shape.
- Quality criteria:
- Cowork/Code no longer flash rapidly during long-running tool sequences.
- file path/debug detail remains collapsed by default.
### Phase D. Timeline Render Parity
- Reference: `src/screens/REPL.tsx`, `src/components/Messages.tsx`
- AX apply location: `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml.cs`
- Completion criteria:
- assistant/user messages, execution logs, compact boundaries, and queue summaries are rendered from one derived timeline model.
- direct imperative bubble injection is removed from normal send/regenerate/retry flows.
- Quality criteria:
- no blank assistant cards.
- no token-only completion without visible content.
- no duplicate event banners after re-render.
### Phase E. Composer and Status Strip Simplification
- Reference: `src/screens/REPL.tsx`, `src/components/StatusLine.tsx`
- AX apply location: `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml.cs`
- Completion criteria:
- composer height grows only on explicit line breaks.
- status strip, queue summary, and runtime activity all use debounced runtime updates.
- Chat/Cowork/Code share one responsive width calculation policy.
- Quality criteria:
- resizing feels natural.
- composer does not keep growing after send.
- metadata remains subordinate to the message timeline.
### Phase F. Recovery, Resume, and Verification
- Reference: `src/bootstrap/state.ts`, `src/bridge/sessionRunner.ts`, `src/screens/REPL.tsx`
- AX apply location: `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatStorageService.cs`
- Completion criteria:
- reopen after interruption keeps queue, runtime summary, and latest visible assistant state consistent.
- retry-last and regenerate do not depend on mutating `InputBox.Text`.
- all three tabs pass reopen/retry/manual compact/manual stop/manual resume scenarios.
- Quality criteria:
- stored conversation and rendered conversation stay identical after restore.
- final reopened state matches the last completed runtime state.
## Execution Tracks
1. Hook contract parity
- Structured hook output support (`updatedInput`, `updatedPermissions`, `additionalContext`).
@@ -28,3 +114,308 @@
- Internal parity scenarios pass target threshold.
- Resume/replay failures: zero.
- `dotnet build` warnings/errors: zero.
## Validation Matrix
- Build: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\`
- Manual scenario 1: Chat send -> answer visible -> retry -> regenerate -> reopen conversation
- Manual scenario 2: Cowork tool run -> progress summary -> completion -> queue next request -> reopen
- Manual scenario 3: Code task with execution log noise -> completion -> compact -> next turn -> reopen
- Manual scenario 4: AX Agent internal settings change -> immediate runtime reflection without layout regression
## Canonical Prompt Set
- Updated: 2026-04-05 22:04 (KST)
- The following prompt set should be used for AX vs `claw-code` parity checks. The goal is not byte-identical output, but equivalent execution route, approval behavior, and artifact/result quality.
- Operational checklist copy: `docs/AX_AGENT_REGRESSION_PROMPTS.md`
1. Chat basic answer
- Prompt: `회의 일정 조정 메일을 정중한 한국어로 써줘`
- Apply to: `Chat`
- Verify: normal reply render, retry/regenerate stability, reopen durability
2. Chat long-form explanation
- Prompt: `RAG와 fine-tuning 차이를 실무 관점으로 7가지로 설명해줘`
- Apply to: `Chat`
- Verify: long response rendering, compaction follow-up continuity
3. Cowork document task
- Prompt: `신규 ERP 도입 제안서 초안을 작성해줘. 목적, 범위, 기대효과, 추진일정 포함`
- Apply to: `Cowork`
- Verify: topic/task preset routing, plan-first execution, actual document-oriented output path
4. Cowork data task
- Prompt: `매출 CSV를 분석해서 월별 추세와 이상치를 요약해줘`
- Apply to: `Cowork`
- Verify: data-analysis tool choice, reduced runtime noise, final summary quality
5. Code bug-fix task
- Prompt: `현재 프로젝트에서 설정 저장 버그 원인 찾고 수정해줘`
- Apply to: `Code`
- Verify: read/search/edit path, diff persistence, reopen consistency
6. Code build/test task
- Prompt: `빌드 오류를 재현하고 수정한 뒤 다시 빌드해줘`
- Apply to: `Code`
- Verify: build/test loop, failure retry, final completion message
7. Queued follow-up
- Prompt sequence:
- `이 창 레이아웃 문제 원인 찾아줘`
- `끝나면 README도 같이 갱신해줘`
- Apply to: `Cowork`, `Code`
- Verify: queue chaining, next-turn pickup without UI mutation
8. Post-compaction continuity
- Prompt: `지금까지 논의한 내용을 5줄로 이어서 정리하고 다음 작업 제안해줘`
- Apply to: `Chat`, `Cowork`, `Code`
- Verify: compact-after-next-turn continuity, no token-only completion
9. Permission approval
- Prompt: `이 파일을 수정해서 저장해줘`
- Apply to: `Code`
- Verify: permission request, approve/reject rendering, final transcript consistency
10. Slash / skill entry
- Prompt: `/bug-hunt src 폴더 잠재 버그 찾아줘`
- Apply to: `Code`
- Verify: slash entry uses the same prepared-execution route as normal send
## Tool / Skill Delta Snapshot
- Updated: 2026-04-05 22:04 (KST)
- AX tool registry count is larger than `claw-code`, but the shape is different.
- AX reference: `src/AxCopilot/Services/Agent/ToolRegistry.cs`
- `claw-code` reference: `src/tools/*`, `src/skills/bundledSkills.ts`
### AX stronger areas
- Document/office generation and conversion (`ExcelSkill`, `DocxSkill`, `PptxSkill`, `DocumentPlannerTool`, `DocumentAssemblerTool`)
- Data/business utilities (`DataPivotTool`, `SqlTool`, `FormatConvertTool`, `TextSummarizeTool`)
- WPF-integrated enterprise UX and Korean workflow presets
### claw-code stronger areas
- Transcript-native tool use / rejection / approval message taxonomy
- Plan approval request/response rendering in the message stream
- Permission and tool-result message consistency
- Bundled skill registry and skill message integration
### Remaining parity target
- Keep AX's richer business/document tool set
- Bring transcript rendering and approval/status UX closer to `claw-code`
## Transcript-First Approval / Ask UX
- Updated: 2026-04-05 18:58 (KST)
- `plan approval` and `user ask` should both resolve inside the transcript first.
- Secondary windows are allowed only as detail surfaces, not as the primary decision flow.
- AX implementation status:
- `plan approval`: transcript-first, detail view via `PlanViewerWindow`
- `user ask`: transcript-first inline question card with choices / direct input / submit
## Tool / Skill UX Parity Follow-up
- Updated: 2026-04-05 19:04 (KST)
- Default transcript should prefer role-oriented badges and readable labels over raw internal tool names.
- AX implementation status:
- tool event badges: simplified to role-first labels
- item naming: normalized into readable Korean labels or `/skill-name` style
- observability panels: permission/background diagnostics reduced outside debug mode
- Remaining quality target:
- move more tool-result and permission-result presentation into smaller message-type-specific helpers, closer to `claw-code` component separation
## Current Snapshot
- Updated: 2026-04-05 19:42 (KST)
- Estimated parity:
- Core engine: `89%`
- Main transcript UI: `96%`
- Cowork/Code runtime UX: `92%`
- Internal settings linkage: `88%`
- Overall AX Agent parity: `93%`
## Remaining Gaps
1. Prompt lifecycle parity
- `claw-code` reference: `src/utils/handlePromptSubmit.ts`, `src/utils/processUserInput/processTextPrompt.ts`
- AX gap:
- `send / retry / regenerate` are mostly unified, but `slash / compact 후 다음 턴 / 일부 queue 후처리`는 아직 `ChatWindow.xaml.cs`에서 UI 상태를 먼저 만지는 구간이 남아 있습니다.
- 목표는 모든 입력 진입점이 `AxAgentExecutionEngine`의 동일한 prepare/execute/finalize 축만 타게 만드는 것입니다.
2. Plan / approval rendering parity
- `claw-code` reference: `src/components/messages/PlanApprovalMessage.tsx`
- AX gap:
- 기본 transcript에서는 compact pill 위주로 줄였지만, 승인/계획 결과 표현이 아직 `Popup/Window + WPF 카드`와 섞여 있습니다.
## Quality Uplift Plan
- Updated: 2026-04-06 00:22 (KST)
- Goal: move AX Agent from parity-oriented stability into `claw-code`-grade maintainability and transcript quality, without copying implementation expression.
### Track 1. Transcript Renderer Decomposition
- `claw-code` references:
- `src/components/Messages.tsx`
- `src/components/MessageRow.tsx`
- `src/components/messages/AssistantToolUseMessage.tsx`
- `src/components/messages/PlanApprovalMessage.tsx`
- AX apply targets:
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- new partial/helper files under `src/AxCopilot/Views/`
- Completion criteria:
- `plan / permission / ask / tool-result / task-summary` rendering no longer lives as one large block inside `ChatWindow.xaml.cs`
- each transcript concern has a dedicated helper/partial/class boundary
- Quality criteria:
- render changes for one message type do not regress unrelated timeline behavior
- transcript behavior remains stable after reopen / retry / regenerate
### Track 2. Permission Presentation Catalog
- `claw-code` references:
- `src/components/permissions/PermissionRequest.tsx`
- `src/components/permissions/PermissionDialog.tsx`
- tool-specific permission request components under `src/components/permissions/*`
- AX apply targets:
- `src/AxCopilot/Services/Agent/PermissionModeCatalog.cs`
- new `src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs`
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- Completion criteria:
- permission request title, subtitle, icon, severity, and choice set are resolved by tool/request type
- file edit / shell / skill / ask-user / web-like permission requests use distinct presentation metadata
- Quality criteria:
- permission prompts feel explicit and predictable
- user can distinguish request type without reading raw tool names or payload
### Track 3. Tool Result Message Taxonomy
- `claw-code` references:
- `src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx`
- `src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx`
- `src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx`
- `src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx`
- AX apply targets:
- new `src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs`
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- Completion criteria:
- transcript display rules differ for `success / error / reject / cancel`
- tool-result badges and summaries are resolved from presentation metadata instead of inline ad-hoc branches
- Quality criteria:
- result cards read as stable UX language, not raw execution logs
- failed and rejected tool runs are visually distinct without increasing noise
### Track 4. Plan Approval Transcript-Only Flow
- `claw-code` references:
- `src/components/messages/PlanApprovalMessage.tsx`
- `src/components/messages/UserPlanMessage.tsx`
- AX apply targets:
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- `src/AxCopilot/Views/PlanViewerWindow.cs`
- Completion criteria:
- default approval / reject / revise flow completes inline in transcript
- `PlanViewerWindow` is detail-only and never required for primary approval flow
- Quality criteria:
- planning feels like part of the conversation, not a modal interruption
- approval history is replayable from persisted conversation state
### Track 5. Runtime Summary Layer
- `claw-code` references:
- `src/components/StatusLine.tsx`
- `src/components/PromptInput/PromptInputFooter.tsx`
- `src/bootstrap/state.ts`
- AX apply targets:
- `src/AxCopilot/Services/AppStateService.cs`
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- Completion criteria:
- one runtime/status summary model feeds the status line, queue summary, runtime badge, and completion hint
- status rendering no longer depends on scattered imperative refresh branches
- Quality criteria:
- no contradictory or stale runtime badges
- long-running Cowork/Code sessions stay visually calm
### Track 6. Regression Prompt Ritual
- `claw-code` references:
- runtime validation scenarios implied by `sessionRunner`, `Messages`, `StatusLine`, and permission components
- AX apply targets:
- `docs/AX_AGENT_REGRESSION_PROMPTS.md`
- `docs/claw-code-parity-plan.md`
- developer workflow / release checklist
- Completion criteria:
- Chat / Cowork / Code prompt set is treated as mandatory regression for runtime-affecting changes
- each prompt is mapped to a failure class (`blank reply`, `duplicate banner`, `bad approval flow`, `queue drift`, `restore drift`)
- Quality criteria:
- parity claims are based on repeatable checks instead of visual spot-checks
- regressions are easier to catch before release
## Recommended Execution Order
1. Transcript renderer decomposition
2. Permission presentation catalog
3. Tool result taxonomy
4. Plan approval transcript-only flow
5. Runtime summary layer
6. Regression prompt ritual hardening
## Settings and Logic Review
- Updated: 2026-04-06 00:22 (KST)
- Candidate to move to developer-only:
- `FreeTierDelaySeconds`
- `MaxAgentIterations`
- `MaxRetryOnError`
- Keep as runtime-critical user settings:
- `OperationMode`
- `MaxContextTokens`
- `ContextCompactTriggerPercent`
- `EnableProactiveContextCompact`
- `EnableCoworkVerification`
- `EnableCodeVerification`
- code tool exposure toggles
- Rule:
- if a setting changes the main execution route or recovery semantics without representing a stable real-world user choice, move it out of default user-facing surfaces
- 목표는 “본문 우선 + 필요 시 열기” 기준으로 더 단일한 timeline 언어로 수렴시키는 것입니다.
3. Status line / composer parity
- `claw-code` reference: `src/components/StatusLine.tsx`, `src/components/PromptInput/PromptInput.tsx`
- AX gap:
- 하단 상태바와 composer 옵션은 많이 줄었지만, 상태 메타가 여전히 분산돼 있고 일부 토글/빠른 설정이 별도 행으로 남아 있습니다.
- 목표는 transcript 하단의 작업 바 한 축으로 더 압축하는 것입니다.
4. Runtime event density parity
- `claw-code` reference: `src/bridge/sessionRunner.ts`, `src/components/StatusNotices.tsx`
- AX gap:
- non-debug 기본 로그는 줄었지만, 일부 Cowork/Code 이벤트는 여전히 timeline을 자주 흔듭니다.
- 목표는 `permission / tool / error / complete / paused / resumed`를 더 안정된 event shape로 정규화하는 것입니다.
## Settings Review
- Remove candidate:
- `PlanMode`
- current state: 사용자 노출 UI와 저장 경로는 `off` 고정으로 정리됐지만 `AppSettings`, `SettingsViewModel`, `AppStateService` 타입 잔재가 남아 있음
- rationale: 현재 정책이 `off` 고정이라 사용자 선택값이 엔진에 의미 있게 기여하지 않음
- `Code.EnablePlanModeTools`
- current state: UI/저장 경로와 기본값은 `false` 고정으로 정리됐지만 모델/설정 타입에 호환용 잔재가 남아 있음
- rationale: 현재 엔진 정책에서 실제 실행 경로를 더 이상 바꾸지 않음
- Move to developer-only candidate:
- `FreeTierDelaySeconds`
- rationale: 일반 사용자가 조정할 이유가 적고 엔진 지연 정책에 직접 영향
- `MaxAgentIterations`
- `MaxRetryOnError`
- rationale: 핵심 실행 루프 품질에 직접 영향하는 런타임 튜닝값
- Keep as runtime-critical:
- `OperationMode`
- `MaxContextTokens`
- `ContextCompactTriggerPercent`
- `EnableProactiveContextCompact`
- `EnableCoworkVerification`
- `EnableCodeVerification`
- `Code.EnableWorktreeTools / EnableTeamTools / EnableCronTools`
## Known UX / Performance Risks
- Topic preset hover flicker was caused by duplicate hover systems:
- custom hover label
- default WPF `ToolTip`
- AX fix:
- remove default `ToolTip` from topic cards and keep a single hover label path
- Remaining runtime performance review targets:
- `RefreshContextUsageVisual()` frequency
- `BuildTopicButtons()` rebuild frequency
- `OnAgentEvent` timeline churn during long Cowork/Code runs
- compact queue summary still needs one more pass to fully match `claw-code` footer minimalism
## Progress Notes
- 업데이트: 2026-04-06 00:58 (KST)
- transcript renderer 분리 1차 완료
- AX 적용: [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)
- 완료 조건: `plan / ask / task-summary` 렌더 helper가 메인 `ChatWindow.xaml.cs` 밖으로 이동
- permission / tool-result presentation catalog 도입
- AX 적용: [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)
- 완료 조건: `AddAgentEventBanner(...)`가 권한/도구 결과 badge 메타를 inline switch가 아니라 catalog에서 해석
- runtime summary 전용 계층 1차 반영
- AX 적용: [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)
- 완료 조건: 상태선 UI가 `OperationalStatusPresentationState`를 소비해 strip/runtime badge visibility를 계산

View File

@@ -0,0 +1,128 @@
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// AX Agent execution-prep engine.
/// Inspired by the `claw-code` split between input preparation and session execution,
/// so the UI layer stops owning message assembly and final assistant commit logic.
/// </summary>
public sealed class AxAgentExecutionEngine
{
public sealed record PreparedTurn(List<ChatMessage> Messages);
public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt);
public IReadOnlyList<string> BuildPromptStack(
string? conversationSystem,
string? slashSystem,
string? taskSystem = null)
{
var prompts = new List<string>();
if (!string.IsNullOrWhiteSpace(conversationSystem))
prompts.Add(conversationSystem.Trim());
if (!string.IsNullOrWhiteSpace(slashSystem))
prompts.Add(slashSystem.Trim());
if (!string.IsNullOrWhiteSpace(taskSystem))
prompts.Add(taskSystem.Trim());
return prompts;
}
public ExecutionMode ResolveExecutionMode(
string runTab,
bool streamingEnabled,
string resolvedService,
string? coworkSystemPrompt,
string? codeSystemPrompt)
{
if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return new ExecutionMode(true, false, coworkSystemPrompt);
if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
return new ExecutionMode(true, false, codeSystemPrompt);
return new ExecutionMode(false, false, null);
}
public PreparedTurn PrepareTurn(
ChatConversation conversation,
IEnumerable<string?> systemPrompts,
string? fileContext = null,
IReadOnlyList<ImageAttachment>? images = null)
{
var outbound = conversation.Messages
.Select(CloneMessage)
.ToList();
if (!string.IsNullOrWhiteSpace(fileContext))
{
var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
if (lastUserIndex >= 0)
outbound[lastUserIndex].Content = (outbound[lastUserIndex].Content ?? string.Empty) + fileContext;
}
if (images is { Count: > 0 })
{
var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
if (lastUserIndex >= 0)
outbound[lastUserIndex].Images = images.Select(CloneImage).ToList();
}
var promptList = systemPrompts
.Where(prompt => !string.IsNullOrWhiteSpace(prompt))
.Select(prompt => prompt!.Trim())
.ToList();
for (var i = promptList.Count - 1; i >= 0; i--)
outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] });
return new PreparedTurn(outbound);
}
public ChatMessage CommitAssistantMessage(
ChatSessionStateService? session,
ChatConversation conversation,
string tab,
string content,
ChatStorageService? storage = null)
{
var assistant = new ChatMessage
{
Role = "assistant",
Content = content,
};
if (session != null)
{
session.AppendMessage(tab, assistant, storage);
return assistant;
}
conversation.Messages.Add(assistant);
conversation.UpdatedAt = DateTime.Now;
return assistant;
}
private static ChatMessage CloneMessage(ChatMessage source)
{
return new ChatMessage
{
Role = source.Role,
Content = source.Content,
Timestamp = source.Timestamp,
MetaRunId = source.MetaRunId,
AttachedFiles = source.AttachedFiles?.ToList(),
Images = source.Images?.Select(CloneImage).ToList(),
};
}
private static ImageAttachment CloneImage(ImageAttachment source)
{
return new ImageAttachment
{
Base64 = source.Base64,
MimeType = source.MimeType,
FileName = source.FileName,
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ public partial class App : System.Windows.Application
private SettingsService? _settings;
private SettingsWindow? _settingsWindow;
private PluginHost? _pluginHost;
private SchedulerService? _schedulerService;
private ClipboardHistoryService? _clipboardHistory;
private DockBarWindow? _dockBar;
private FileDialogWatcher? _fileDialogWatcher;
@@ -172,6 +173,102 @@ public partial class App : System.Windows.Application
commandResolver.RegisterHandler(new EverythingHandler());
commandResolver.RegisterHandler(new HelpHandler(settings));
commandResolver.RegisterHandler(new ChatHandler(settings));
commandResolver.RegisterHandler(new QuickLinkHandler(settings));
commandResolver.RegisterHandler(new TagHandler());
commandResolver.RegisterHandler(new NotifHandler());
commandResolver.RegisterHandler(new PomoHandler());
commandResolver.RegisterHandler(new FileBrowserHandler());
commandResolver.RegisterHandler(new HotkeyHandler(settings));
commandResolver.RegisterHandler(new OcrHandler());
commandResolver.RegisterHandler(new SessionHandler(settings));
commandResolver.RegisterHandler(new BatchRenameHandler());
_schedulerService = new SchedulerService(settings);
_schedulerService.Start();
commandResolver.RegisterHandler(new ScheduleHandler(settings));
commandResolver.RegisterHandler(new MacroHandler(settings));
commandResolver.RegisterHandler(new ContextHandler());
commandResolver.RegisterHandler(new GitHandler());
commandResolver.RegisterHandler(new RegexHandler());
commandResolver.RegisterHandler(new TimeZoneHandler());
commandResolver.RegisterHandler(new NetDiagHandler());
commandResolver.RegisterHandler(new FileHashHandler());
commandResolver.RegisterHandler(new ZipHandler());
commandResolver.RegisterHandler(new EventLogHandler());
commandResolver.RegisterHandler(new SshHandler(settings));
commandResolver.RegisterHandler(new PasswordGenHandler());
commandResolver.RegisterHandler(new SubnetHandler());
commandResolver.RegisterHandler(new CleanHandler());
commandResolver.RegisterHandler(new BaseConvertHandler());
commandResolver.RegisterHandler(new XmlHandler());
commandResolver.RegisterHandler(new UuidHandler());
commandResolver.RegisterHandler(new CertHandler());
commandResolver.RegisterHandler(new LoremHandler());
commandResolver.RegisterHandler(new CsvHandler());
commandResolver.RegisterHandler(new JwtHandler());
commandResolver.RegisterHandler(new CronHandler());
commandResolver.RegisterHandler(new UnicodeHandler());
commandResolver.RegisterHandler(new HttpTesterHandler());
commandResolver.RegisterHandler(new HostsHandler());
commandResolver.RegisterHandler(new MorseHandler());
commandResolver.RegisterHandler(new StartupHandler());
commandResolver.RegisterHandler(new DnsQueryHandler());
commandResolver.RegisterHandler(new PathHandler());
commandResolver.RegisterHandler(new DriveHandler());
commandResolver.RegisterHandler(new AgeHandler());
commandResolver.RegisterHandler(new WolHandler());
commandResolver.RegisterHandler(new RegHandler());
commandResolver.RegisterHandler(new TipHandler());
commandResolver.RegisterHandler(new FontHandler());
commandResolver.RegisterHandler(new WslHandler());
commandResolver.RegisterHandler(new CurrencyHandler());
commandResolver.RegisterHandler(new BmiHandler());
commandResolver.RegisterHandler(new MdHandler());
commandResolver.RegisterHandler(new PingHandler());
commandResolver.RegisterHandler(new DockerHandler());
commandResolver.RegisterHandler(new TodoHandler());
commandResolver.RegisterHandler(new TableHandler());
commandResolver.RegisterHandler(new UnitHandler());
commandResolver.RegisterHandler(new NumHandler());
commandResolver.RegisterHandler(new YamlHandler());
commandResolver.RegisterHandler(new GitignoreHandler());
commandResolver.RegisterHandler(new SqlHandler());
commandResolver.RegisterHandler(new TextCaseHandler());
commandResolver.RegisterHandler(new AspectHandler());
commandResolver.RegisterHandler(new AbbrHandler());
commandResolver.RegisterHandler(new CalcHandler());
commandResolver.RegisterHandler(new TimerHandler());
commandResolver.RegisterHandler(new IpInfoHandler());
commandResolver.RegisterHandler(new NpmHandler());
commandResolver.RegisterHandler(new HexHandler());
commandResolver.RegisterHandler(new RandHandler());
commandResolver.RegisterHandler(new StrHandler());
commandResolver.RegisterHandler(new PermHandler());
commandResolver.RegisterHandler(new TomlHandler());
commandResolver.RegisterHandler(new LogHandler());
commandResolver.RegisterHandler(new PsHandler());
commandResolver.RegisterHandler(new KeyHandler());
commandResolver.RegisterHandler(new ProcHandler());
commandResolver.RegisterHandler(new XlHandler());
commandResolver.RegisterHandler(new PipHandler());
commandResolver.RegisterHandler(new FormHandler());
commandResolver.RegisterHandler(new CalHandler());
commandResolver.RegisterHandler(new LeaveHandler());
commandResolver.RegisterHandler(new WorkTimeHandler());
commandResolver.RegisterHandler(new FixHandler());
commandResolver.RegisterHandler(new SpellHandler());
commandResolver.RegisterHandler(new ContactHandler());
commandResolver.RegisterHandler(new RemindHandler());
commandResolver.RegisterHandler(new PhraseHandler());
commandResolver.RegisterHandler(new TodayHandler());
commandResolver.RegisterHandler(new VolHandler());
commandResolver.RegisterHandler(new QrHandler());
commandResolver.RegisterHandler(new MeetHandler());
commandResolver.RegisterHandler(new BrightHandler());
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
commandResolver.RegisterHandler(new PkgHandler());
commandResolver.RegisterHandler(new ApHandler());
commandResolver.RegisterHandler(new DictHandler());
commandResolver.RegisterHandler(new FlowHandler());
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
@@ -848,6 +945,7 @@ public partial class App : System.Windows.Application
_inputListener?.Dispose();
_clipboardHistory?.Dispose();
_indexService?.Dispose();
_schedulerService?.Dispose();
_sessionTracking?.Dispose();
_worktimeReminder?.Dispose();
_trayIcon?.Dispose();

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
@@ -66,6 +66,7 @@
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />

View File

@@ -0,0 +1,427 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L18-4: IT·개발 약어 사전 핸들러. "abbr" 프리픽스로 사용합니다.
///
/// 예: abbr → 주요 약어 목록
/// abbr api → API 뜻 + 설명
/// abbr crud → CRUD 약어 풀이
/// abbr rest → REST 설명
/// abbr http → HTTP 약어 목록
/// Enter → 약어 또는 뜻을 클립보드에 복사.
/// </summary>
public class AbbrHandler : IActionHandler
{
public string? Prefix => "abbr";
public PluginMetadata Metadata => new(
"Abbr",
"IT·개발 약어 사전 — API · CRUD · REST · HTTP · SQL 등 내장",
"1.0",
"AX");
private record AbbrEntry(string Short, string Full, string Description, string Category);
// ── 내장 약어 사전 (~150개) ───────────────────────────────────────────────
private static readonly AbbrEntry[] Entries =
[
// 웹/네트워크
new("API", "Application Programming Interface", "소프트웨어 간 통신 규격", "웹/네트워크"),
new("REST", "Representational State Transfer", "HTTP 기반 아키텍처 스타일", "웹/네트워크"),
new("HTTP", "HyperText Transfer Protocol", "웹 통신 프로토콜", "웹/네트워크"),
new("HTTPS", "HTTP Secure", "TLS/SSL로 암호화된 HTTP", "웹/네트워크"),
new("HTML", "HyperText Markup Language", "웹 페이지 마크업 언어", "웹/네트워크"),
new("CSS", "Cascading Style Sheets", "웹 스타일 시트 언어", "웹/네트워크"),
new("URL", "Uniform Resource Locator", "인터넷 자원 주소", "웹/네트워크"),
new("URI", "Uniform Resource Identifier", "자원 식별자 (URL의 상위 개념)", "웹/네트워크"),
new("DNS", "Domain Name System", "도메인 ↔ IP 변환 시스템", "웹/네트워크"),
new("IP", "Internet Protocol", "인터넷 패킷 전송 프로토콜", "웹/네트워크"),
new("TCP", "Transmission Control Protocol", "신뢰성 연결형 전송 프로토콜", "웹/네트워크"),
new("UDP", "User Datagram Protocol", "비연결형 전송 프로토콜", "웹/네트워크"),
new("SSH", "Secure Shell", "원격 보안 접속 프로토콜", "웹/네트워크"),
new("FTP", "File Transfer Protocol", "파일 전송 프로토콜", "웹/네트워크"),
new("SMTP", "Simple Mail Transfer Protocol", "이메일 전송 프로토콜", "웹/네트워크"),
new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "웹/네트워크"),
new("VPN", "Virtual Private Network", "가상 사설 네트워크", "웹/네트워크"),
new("NAT", "Network Address Translation", "사설↔공인 IP 주소 변환", "웹/네트워크"),
new("CORS", "Cross-Origin Resource Sharing", "브라우저 교차 출처 리소스 공유", "웹/네트워크"),
new("SSE", "Server-Sent Events", "서버→클라이언트 단방향 스트림", "웹/네트워크"),
new("WebSocket","WebSocket Protocol", "양방향 실시간 통신 프로토콜", "웹/네트워크"),
new("gRPC", "Google Remote Procedure Call", "프로토콜 버퍼 기반 RPC 프레임워크", "웹/네트워크"),
new("GraphQL", "Graph Query Language", "API 쿼리 언어 (Facebook 개발)", "웹/네트워크"),
new("SOAP", "Simple Object Access Protocol", "XML 기반 웹 서비스 프로토콜", "웹/네트워크"),
new("WSDL", "Web Services Description Language", "웹 서비스 인터페이스 설명 언어", "웹/네트워크"),
new("TLS", "Transport Layer Security", "암호화 통신 프로토콜 (SSL 후속)", "웹/네트워크"),
new("SSL", "Secure Sockets Layer", "암호화 통신 프로토콜 (TLS의 전신)", "웹/네트워크"),
new("OAuth", "Open Authorization", "위임 인증 표준 프로토콜", "웹/네트워크"),
new("MIME", "Multipurpose Internet Mail Extensions", "이메일·웹 콘텐츠 타입 표준", "웹/네트워크"),
// 개발/프로그래밍
new("CRUD", "Create Read Update Delete", "기본 데이터 처리 4가지 연산", "개발"),
new("OOP", "Object-Oriented Programming", "객체 지향 프로그래밍", "개발"),
new("SOLID", "Single responsibility Open/closed Liskov Interface-segregation Dependency-inversion",
"객체지향 5가지 설계 원칙", "개발"),
new("DRY", "Don't Repeat Yourself", "코드 중복 최소화 원칙", "개발"),
new("KISS", "Keep It Simple, Stupid", "단순하게 유지하라 원칙", "개발"),
new("YAGNI", "You Aren't Gonna Need It", "필요할 때만 구현하라 원칙", "개발"),
new("TDD", "Test-Driven Development", "테스트 주도 개발 방법론", "개발"),
new("BDD", "Behavior-Driven Development", "행동 주도 개발 방법론", "개발"),
new("DDD", "Domain-Driven Design", "도메인 주도 설계", "개발"),
new("MVC", "Model-View-Controller", "UI 아키텍처 패턴", "개발"),
new("MVP", "Model-View-Presenter", "MVC의 변형, Presenter가 View와 Model 중재", "개발"),
new("MVVM", "Model-View-ViewModel", "WPF·모바일 아키텍처 패턴", "개발"),
new("SRP", "Single Responsibility Principle", "단일 책임 원칙 (SOLID의 S)", "개발"),
new("OCP", "Open/Closed Principle", "개방/폐쇄 원칙 (SOLID의 O)", "개발"),
new("LSP", "Liskov Substitution Principle", "리스코프 치환 원칙 (SOLID의 L)", "개발"),
new("ISP", "Interface Segregation Principle", "인터페이스 분리 원칙 (SOLID의 I)", "개발"),
new("DIP", "Dependency Inversion Principle", "의존성 역전 원칙 (SOLID의 D)", "개발"),
new("IoC", "Inversion of Control", "제어 역전 (프레임워크 핵심 개념)", "개발"),
new("DI", "Dependency Injection", "의존성 주입 패턴", "개발"),
new("AOP", "Aspect-Oriented Programming", "관점 지향 프로그래밍", "개발"),
new("FP", "Functional Programming", "함수형 프로그래밍", "개발"),
new("DSL", "Domain-Specific Language", "특정 도메인 전용 언어", "개발"),
new("SDK", "Software Development Kit", "소프트웨어 개발 도구 모음", "개발"),
new("IDE", "Integrated Development Environment", "통합 개발 환경", "개발"),
new("CLI", "Command Line Interface", "명령줄 인터페이스", "개발"),
new("GUI", "Graphical User Interface", "그래픽 사용자 인터페이스", "개발"),
new("UI", "User Interface", "사용자 인터페이스", "개발"),
new("UX", "User Experience", "사용자 경험", "개발"),
new("SPA", "Single Page Application", "단일 페이지 애플리케이션", "개발"),
new("SSR", "Server-Side Rendering", "서버 사이드 렌더링", "개발"),
new("CSR", "Client-Side Rendering", "클라이언트 사이드 렌더링", "개발"),
new("PWA", "Progressive Web App", "프로그레시브 웹 앱", "개발"),
new("CDK", "Cloud Development Kit", "클라우드 인프라 코드 개발 도구", "개발"),
new("ORM", "Object-Relational Mapping", "객체-관계형 데이터베이스 매핑", "개발"),
new("REPL", "Read-Eval-Print Loop", "대화형 프로그래밍 환경", "개발"),
new("GC", "Garbage Collection", "자동 메모리 회수", "개발"),
new("JIT", "Just-In-Time Compilation", "실행 시점 컴파일", "개발"),
new("AOT", "Ahead-Of-Time Compilation", "사전 컴파일", "개발"),
new("WASM", "WebAssembly", "브라우저용 바이너리 형식", "개발"),
new("AST", "Abstract Syntax Tree", "추상 구문 트리", "개발"),
new("LSP", "Language Server Protocol", "언어 분석 서버 표준 프로토콜", "개발"),
// 데이터베이스
new("SQL", "Structured Query Language", "관계형 DB 쿼리 언어", "DB"),
new("NoSQL", "Not Only SQL", "비관계형 데이터베이스 총칭", "DB"),
new("ACID", "Atomicity Consistency Isolation Durability", "DB 트랜잭션 4가지 성질", "DB"),
new("CAP", "Consistency Availability Partition-tolerance","분산 DB 설계 이론 (셋 중 2개만 보장)", "DB"),
new("ETL", "Extract Transform Load", "데이터 추출·변환·적재 프로세스", "DB"),
new("OLTP", "Online Transaction Processing", "트랜잭션 처리 중심 DB", "DB"),
new("OLAP", "Online Analytical Processing", "분석 처리 중심 DB (데이터 웨어하우스)", "DB"),
new("DDL", "Data Definition Language", "DB 구조 정의 SQL (CREATE/ALTER/DROP)", "DB"),
new("DML", "Data Manipulation Language", "데이터 조작 SQL (SELECT/INSERT/UPDATE/DELETE)", "DB"),
new("DCL", "Data Control Language", "접근 권한 SQL (GRANT/REVOKE)", "DB"),
// 보안
new("JWT", "JSON Web Token", "JSON 기반 인증 토큰 표준", "보안"),
new("RBAC", "Role-Based Access Control", "역할 기반 접근 제어", "보안"),
new("ABAC", "Attribute-Based Access Control", "속성 기반 접근 제어", "보안"),
new("MFA", "Multi-Factor Authentication", "다중 인증", "보안"),
new("2FA", "Two-Factor Authentication", "이중 인증", "보안"),
new("XSS", "Cross-Site Scripting", "악성 스크립트 삽입 공격", "보안"),
new("CSRF", "Cross-Site Request Forgery", "교차 사이트 요청 위조 공격", "보안"),
new("SQL Injection", "SQL Injection Attack", "SQL 쿼리 삽입 공격", "보안"),
new("OWASP", "Open Web Application Security Project", "웹 보안 가이드라인 기관", "보안"),
new("CVE", "Common Vulnerabilities and Exposures", "공개 보안 취약점 DB", "보안"),
new("HTTPS", "HTTP Secure", "TLS로 암호화된 HTTP", "보안"),
new("AES", "Advanced Encryption Standard", "대칭키 암호화 표준 (128/192/256bit)", "보안"),
new("RSA", "RivestShamirAdleman", "공개키 비대칭 암호화 알고리즘", "보안"),
new("HMAC", "Hash-based Message Authentication Code", "해시 기반 메시지 인증 코드", "보안"),
new("PKI", "Public Key Infrastructure", "공개키 인프라", "보안"),
// 클라우드/인프라
new("AWS", "Amazon Web Services", "아마존 클라우드 서비스", "클라우드"),
new("GCP", "Google Cloud Platform", "구글 클라우드 서비스", "클라우드"),
new("SaaS", "Software as a Service", "구독형 소프트웨어 서비스", "클라우드"),
new("PaaS", "Platform as a Service", "플랫폼 서비스", "클라우드"),
new("IaaS", "Infrastructure as a Service", "인프라 서비스", "클라우드"),
new("FaaS", "Function as a Service", "서버리스 함수 서비스", "클라우드"),
new("K8s", "Kubernetes", "컨테이너 오케스트레이션 플랫폼", "클라우드"),
new("CI/CD", "Continuous Integration/Continuous Delivery","지속적 통합/배포 파이프라인", "클라우드"),
new("IaC", "Infrastructure as Code", "코드로 관리하는 인프라", "클라우드"),
new("VPC", "Virtual Private Cloud", "가상 사설 클라우드 네트워크", "클라우드"),
new("SLA", "Service Level Agreement", "서비스 수준 계약 (가용성 보장)", "클라우드"),
new("SLO", "Service Level Objective", "서비스 수준 목표", "클라우드"),
new("RTO", "Recovery Time Objective", "복구 목표 시간", "클라우드"),
new("RPO", "Recovery Point Objective", "복구 목표 지점", "클라우드"),
new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "클라우드"),
new("ECS", "Elastic Container Service", "AWS 컨테이너 관리 서비스", "클라우드"),
new("ECR", "Elastic Container Registry", "AWS 컨테이너 이미지 저장소", "클라우드"),
new("EKS", "Elastic Kubernetes Service", "AWS 관리형 Kubernetes", "클라우드"),
// AI/ML
new("AI", "Artificial Intelligence", "인공지능", "AI/ML"),
new("ML", "Machine Learning", "머신러닝", "AI/ML"),
new("DL", "Deep Learning", "딥러닝", "AI/ML"),
new("LLM", "Large Language Model", "대규모 언어 모델", "AI/ML"),
new("NLP", "Natural Language Processing", "자연어 처리", "AI/ML"),
new("CNN", "Convolutional Neural Network", "합성곱 신경망", "AI/ML"),
new("RNN", "Recurrent Neural Network", "순환 신경망", "AI/ML"),
new("GAN", "Generative Adversarial Network", "생성적 적대 신경망", "AI/ML"),
new("RAG", "Retrieval-Augmented Generation", "검색 증강 생성 (LLM + 검색)", "AI/ML"),
new("RLHF", "Reinforcement Learning from Human Feedback","인간 피드백 강화학습", "AI/ML"),
new("GPT", "Generative Pre-trained Transformer", "생성형 사전학습 변환기 (OpenAI)", "AI/ML"),
new("BERT", "Bidirectional Encoder Representations from Transformers","양방향 트랜스포머 언어 모델", "AI/ML"),
new("SFT", "Supervised Fine-Tuning", "지도 학습 파인튜닝", "AI/ML"),
new("LoRA", "Low-Rank Adaptation", "저순위 행렬 파인튜닝 기법", "AI/ML"),
new("MCP", "Model Context Protocol", "LLM 외부 도구 연결 표준 프로토콜", "AI/ML"),
new("CoT", "Chain of Thought", "사고 단계 명시 프롬프팅", "AI/ML"),
// 데이터 형식
new("JSON", "JavaScript Object Notation", "경량 데이터 교환 형식", "데이터형식"),
new("XML", "Extensible Markup Language", "확장 가능 마크업 언어", "데이터형식"),
new("YAML", "YAML Ain't Markup Language", "사람이 읽기 쉬운 데이터 직렬화 형식", "데이터형식"),
new("TOML", "Tom's Obvious, Minimal Language", "설정 파일 전용 경량 형식", "데이터형식"),
new("CSV", "Comma-Separated Values", "쉼표 구분 데이터 형식", "데이터형식"),
new("TSV", "Tab-Separated Values", "탭 구분 데이터 형식", "데이터형식"),
new("Protobuf","Protocol Buffers", "구글 이진 직렬화 형식", "데이터형식"),
new("Avro", "Apache Avro", "Hadoop 생태계 이진 직렬화", "데이터형식"),
new("Parquet", "Apache Parquet", "열 지향 이진 데이터 형식", "데이터형식"),
new("Base64", "Base 64 encoding", "이진 데이터 텍스트 인코딩 (64진수)", "데이터형식"),
// 버전 관리/협업
new("VCS", "Version Control System", "버전 관리 시스템", "협업"),
new("SCM", "Source Code Management", "소스 코드 관리", "협업"),
new("PR", "Pull Request", "코드 병합 요청 (GitHub 용어)", "협업"),
new("MR", "Merge Request", "코드 병합 요청 (GitLab 용어)", "협업"),
new("LGTM", "Looks Good To Me", "코드 리뷰 승인 표현", "협업"),
new("WIP", "Work In Progress", "진행 중 작업", "협업"),
new("RFC", "Request for Comments", "의견 요청 문서 (표준화 프로세스)", "협업"),
new("POC", "Proof of Concept", "개념 검증", "협업"),
new("MVP", "Minimum Viable Product", "최소 기능 제품", "협업"),
new("KPI", "Key Performance Indicator", "핵심 성과 지표", "협업"),
new("OKR", "Objectives and Key Results", "목표 및 핵심 결과", "협업"),
new("SOP", "Standard Operating Procedure", "표준 운영 절차", "협업"),
];
private static readonly string[] Categories = Entries.Select(e => e.Category).Distinct().ToArray();
// ── 커스텀 약어 ───────────────────────────────────────────────────────────
private static readonly string CustomPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "abbr_custom.json");
private sealed class CustomAbbr
{
[JsonPropertyName("short")] public string Short { get; set; } = "";
[JsonPropertyName("full")] public string Full { get; set; } = "";
[JsonPropertyName("desc")] public string Description { get; set; } = "";
[JsonPropertyName("cat")] public string Category { get; set; } = "사용자정의";
}
private static List<CustomAbbr> LoadCustom()
{
try {
if (System.IO.File.Exists(CustomPath))
return JsonSerializer.Deserialize<List<CustomAbbr>>(
System.IO.File.ReadAllText(CustomPath)) ?? [];
} catch { }
return [];
}
private static void SaveCustom(List<CustomAbbr> list)
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(CustomPath)!);
System.IO.File.WriteAllText(CustomPath, JsonSerializer.Serialize(list,
new JsonSerializerOptions { WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// ── 서브커맨드 처리 ───────────────────────────────────────────────────
// abbr custom → 사용자 정의 약어 목록
if (q.Equals("custom", StringComparison.OrdinalIgnoreCase))
{
var customList = LoadCustom();
if (customList.Count == 0)
{
items.Add(new LauncherItem("사용자 정의 약어 없음",
"abbr add <약어> <풀이> [설명] 으로 추가하세요",
null, null, Symbol: "\uE82D"));
}
else
{
items.Add(new LauncherItem($"사용자 정의 약어 {customList.Count}개", "", null, null, Symbol: "\uE82D"));
foreach (var c in customList)
items.Add(new LauncherItem(c.Short,
$"{c.Full} · {c.Description}",
null, ("copy", $"{c.Short}: {c.Full}"), Symbol: "\uE82D"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// abbr add <약어> <풀이> [설명]
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var parts = rest.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
items.Add(new LauncherItem("사용법: abbr add <약어> <풀이> [설명]",
"예: abbr add ROI 투자수익률 Return on Investment",
null, null, Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var abbr = parts[0].ToUpper();
var full = parts[1];
var desc = parts.Length > 2 ? parts[2] : "";
var encoded = $"{abbr}|{full}|{desc}";
items.Add(new LauncherItem($"약어 추가: {abbr} = {full}",
desc.Length > 0 ? desc : "Enter: 저장",
null, ("add", encoded), Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// abbr del <약어>
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var abbr = q[4..].Trim().ToUpper();
if (string.IsNullOrEmpty(abbr))
{
items.Add(new LauncherItem("사용법: abbr del <약어>",
"예: abbr del ROI",
null, null, Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"약어 삭제: {abbr}",
"Enter: 삭제 확인",
null, ("del", abbr), Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem($"IT·개발 약어 사전 {Entries.Length}개",
"abbr <약어> 예: abbr api / abbr crud / abbr jwt",
null, null, Symbol: "\uE82D"));
// 커스텀 약어 추가 안내
items.Add(new LauncherItem("abbr add <약어> <풀이> — 나만의 약어 추가",
"예: abbr add ROI 투자수익률",
null, null, Symbol: "\uE82D"));
items.Add(new LauncherItem("── 카테고리 ──", "", null, null, Symbol: "\uE82D"));
foreach (var cat in Categories)
{
var cnt = Entries.Count(e => e.Category == cat);
items.Add(new LauncherItem(cat, $"{cnt}개 · abbr {cat}로 목록 보기",
null, null, Symbol: "\uE82D"));
}
// 자주 쓰는 약어 샘플
items.Add(new LauncherItem("── 자주 쓰는 약어 ──", "", null, null, Symbol: "\uE82D"));
var popular = new[] { "API", "CRUD", "REST", "JWT", "SQL", "CI/CD", "TDD", "OOP" };
foreach (var p in popular)
{
var e = Entries.FirstOrDefault(x => x.Short.Equals(p, StringComparison.OrdinalIgnoreCase));
if (e != null)
items.Add(MakeAbbrItem(e));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 카테고리 검색
if (Categories.Any(c => c.Equals(q, StringComparison.OrdinalIgnoreCase)))
{
var catEntries = Entries.Where(e => e.Category.Equals(q, StringComparison.OrdinalIgnoreCase)).ToList();
items.Add(new LauncherItem($"{q} {catEntries.Count}개", "", null, null, Symbol: "\uE82D"));
foreach (var e in catEntries)
items.Add(MakeAbbrItem(e));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 약어 검색 (정확 일치 우선, 그 다음 부분 일치) — 내장 + 커스텀 통합
var customSearch = LoadCustom();
var customEntries = customSearch.Select(c => new AbbrEntry(c.Short, c.Full, c.Description, c.Category)).ToList();
var allEntries = Entries.Concat(customEntries).ToList();
var exact = allEntries.Where(e =>
e.Short.Equals(q, StringComparison.OrdinalIgnoreCase)).ToList();
var partial = allEntries.Where(e =>
!exact.Contains(e) &&
(e.Short.Contains(q, StringComparison.OrdinalIgnoreCase) ||
e.Full.Contains(q, StringComparison.OrdinalIgnoreCase) ||
e.Description.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
var all = exact.Concat(partial).ToList();
if (all.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 약어를 찾을 수 없습니다",
"카테고리: " + string.Join(", ", Categories),
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (exact.Count == 1)
{
// 정확 일치 1개 → 상세 표시
var e = exact[0];
items.Add(new LauncherItem($"{e.Short} = {e.Full}",
e.Description, null, ("copy", $"{e.Short}: {e.Full}"), Symbol: "\uE82D"));
items.Add(new LauncherItem("약어", e.Short, null, ("copy", e.Short), Symbol: "\uE82D"));
items.Add(new LauncherItem("원문", e.Full, null, ("copy", e.Full), Symbol: "\uE82D"));
items.Add(new LauncherItem("설명", e.Description, null, ("copy", e.Description), Symbol: "\uE82D"));
items.Add(new LauncherItem("카테고리",e.Category, null, null, Symbol: "\uE82D"));
}
else
{
items.Add(new LauncherItem($"'{q}' 검색 결과 {all.Count}개", "", null, null, Symbol: "\uE82D"));
foreach (var e in all.Take(30))
items.Add(MakeAbbrItem(e));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Abbr", "클립보드에 복사했습니다.");
}
catch { }
break;
case ("add", string encoded):
var parts = encoded.Split('|', 3);
var custom = LoadCustom();
custom.RemoveAll(c => c.Short.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
custom.Add(new CustomAbbr {
Short = parts[0].ToUpper(),
Full = parts.Length > 1 ? parts[1] : "",
Description = parts.Length > 2 ? parts[2] : "",
});
SaveCustom(custom);
NotificationService.Notify("약어", $"{parts[0].ToUpper()} 등록 완료");
break;
case ("del", string abbr):
var customDel = LoadCustom();
customDel.RemoveAll(c => c.Short.Equals(abbr, StringComparison.OrdinalIgnoreCase));
SaveCustom(customDel);
NotificationService.Notify("약어", $"{abbr.ToUpper()} 삭제 완료");
break;
}
return Task.CompletedTask;
}
private static LauncherItem MakeAbbrItem(AbbrEntry e) =>
new(e.Short,
$"{e.Full} · {e.Description}",
null, ("copy", $"{e.Short}: {e.Full}"), Symbol: "\uE82D");
}

View File

@@ -0,0 +1,284 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L13-4: 나이·D-day 계산기 핸들러. "age" 프리픽스로 사용합니다.
///
/// 예: age 1990-05-15 → 나이 계산 (만/한국식)
/// age 1990.05.15 → 점 구분자도 지원
/// age 19900515 → 숫자만 입력 (YYYYMMDD)
/// age 2025-12-25 → D-day 계산 (미래 날짜)
/// age next monday → 다음 월요일까지 D-day
/// age christmas → 크리스마스까지 D-day
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class AgeHandler : IActionHandler
{
public string? Prefix => "age";
public PluginMetadata Metadata => new(
"Age",
"나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day",
"1.0",
"AX");
// 특수 날짜 키워드
private static readonly Dictionary<string, Func<DateTime, DateTime>> Keywords =
new(StringComparer.OrdinalIgnoreCase)
{
["christmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
["xmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
["newyear"] = t => new DateTime(t.Year + 1, 1, 1),
["new year"] = t => new DateTime(t.Year + 1, 1, 1),
["설날"] = t => GetNextLunarNewYear(t),
["chuseok"] = t => GetNextChuseok(t),
["추석"] = t => GetNextChuseok(t),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var today = DateTime.Today;
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("나이·D-day 계산기",
"예: age 1990-05-15 / age 2025-12-25 / age christmas",
null, null, Symbol: "\uE787"));
items.Add(new LauncherItem("age 1990-01-01", "생년월일 → 나이", null, null, Symbol: "\uE787"));
items.Add(new LauncherItem("age 2025-12-25", "미래 날짜 D-day", null, null, Symbol: "\uE787"));
items.Add(new LauncherItem("age christmas", "크리스마스 D-day", null, null, Symbol: "\uE787"));
items.Add(new LauncherItem("age newyear", "신년 D-day", null, null, Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 특수 키워드 확인
foreach (var (kw, fn) in Keywords)
{
if (q.Equals(kw, StringComparison.OrdinalIgnoreCase))
{
var targetDate = fn(today);
items.AddRange(BuildDdayItems(targetDate, kw, today));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 요일 키워드: "next monday"
if (TryParseNextWeekday(q, out var weekdayDate))
{
items.AddRange(BuildDdayItems(weekdayDate, q, today));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 날짜 파싱 시도
if (!TryParseDate(q, out var date))
{
items.Add(new LauncherItem("날짜 형식 오류",
"예: 1990-05-15 / 1990.05.15 / 19900515 / 2025-12-25",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (date <= today)
{
// 과거 날짜 → 나이/경과 계산
items.AddRange(BuildAgeItems(date, today));
}
else
{
// 미래 날짜 → D-day
items.AddRange(BuildDdayItems(date, date.ToString("yyyy-MM-dd"), today));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Age", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 나이 계산 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildAgeItems(DateTime birth, DateTime today)
{
var ageInt = CalcAge(birth, today);
var ageKor = today.Year - birth.Year + 1; // 한국식 나이
var days = (today - birth).Days;
var months = (today.Year - birth.Year) * 12 + (today.Month - birth.Month);
var nextBirthday = NextBirthday(birth, today);
var daysToNext = (nextBirthday - today).Days;
var summary = $"""
생년월일: {birth:yyyy-MM-dd}
만 나이: {ageInt}세
한국 나이: {ageKor}세
경과 일수: {days:N0}일
경과 개월: {months:N0}개월
다음 생일: {nextBirthday:yyyy-MM-dd} (D-{daysToNext})
""";
yield return new LauncherItem(
$"만 {ageInt}세 (한국식 {ageKor}세)",
$"{birth:yyyy-MM-dd} · {days:N0}일 경과 · Enter 복사",
null, ("copy", summary), Symbol: "\uE787");
yield return new LauncherItem("만 나이", $"{ageInt}세", null, ("copy", ageInt.ToString()), Symbol: "\uE787");
yield return new LauncherItem("한국 나이", $"{ageKor}세", null, ("copy", ageKor.ToString()), Symbol: "\uE787");
yield return new LauncherItem("경과 일수", $"{days:N0}일", null, ("copy", days.ToString()), Symbol: "\uE787");
yield return new LauncherItem("경과 개월", $"{months:N0}개월", null, ("copy", months.ToString()), Symbol: "\uE787");
yield return new LauncherItem(
"다음 생일",
$"{nextBirthday:yyyy-MM-dd} · D-{daysToNext}",
null, ("copy", nextBirthday.ToString("yyyy-MM-dd")), Symbol: "\uE787");
// 요일
yield return new LauncherItem("태어난 요일", DayKor(birth.DayOfWeek), null, null, Symbol: "\uE787");
}
private static IEnumerable<LauncherItem> BuildDdayItems(DateTime target, string label, DateTime today)
{
var diff = (target - today).Days;
var absDiff = Math.Abs(diff);
var dLabel = diff > 0 ? $"D-{diff}" : diff == 0 ? "D-Day!" : $"D+{absDiff}";
yield return new LauncherItem(
dLabel,
$"{target:yyyy-MM-dd} ({label}) · {DayKor(target.DayOfWeek)}",
null, ("copy", dLabel), Symbol: "\uE787");
yield return new LauncherItem("날짜", target.ToString("yyyy-MM-dd"), null, ("copy", target.ToString("yyyy-MM-dd")), Symbol: "\uE787");
yield return new LauncherItem("요일", DayKor(target.DayOfWeek), null, null, Symbol: "\uE787");
yield return new LauncherItem("남은 일수", diff > 0 ? $"{diff:N0}일 후" : diff == 0 ? "오늘!" : $"{absDiff:N0}일 전", null, ("copy", absDiff.ToString()), Symbol: "\uE787");
yield return new LauncherItem("남은 주", $"{diff / 7}주 {diff % 7}일", null, null, Symbol: "\uE787");
}
// ── 파싱 헬퍼 ─────────────────────────────────────────────────────────────
private static bool TryParseDate(string s, out DateTime result)
{
result = default;
s = s.Trim();
// YYYYMMDD
if (s.Length == 8 && s.All(char.IsDigit))
{
return DateTime.TryParseExact(s, "yyyyMMdd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out result);
}
// 다양한 구분자 (-, ., /)
var normalized = s.Replace('.', '-').Replace('/', '-');
var formats = new[] { "yyyy-M-d", "yyyy-MM-dd", "yy-M-d", "M-d" };
foreach (var fmt in formats)
{
if (DateTime.TryParseExact(normalized, fmt,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out result))
return true;
}
// "M월 d일" 한국어 형식
if (normalized.Contains('월'))
{
var parts = normalized.Split('월', '일');
if (parts.Length >= 2 &&
int.TryParse(parts[0].Trim(), out var m) &&
int.TryParse(parts[1].Trim(), out var d))
{
result = new DateTime(DateTime.Today.Year, m, d);
return true;
}
}
return false;
}
private static bool TryParseNextWeekday(string q, out DateTime result)
{
result = default;
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !parts[0].Equals("next", StringComparison.OrdinalIgnoreCase))
return false;
DayOfWeek? dow = parts[1].ToLowerInvariant() switch
{
"monday" or "mon" or "월" or "월요일" => DayOfWeek.Monday,
"tuesday" or "tue" or "화" or "화요일" => DayOfWeek.Tuesday,
"wednesday" or "wed" or "수" or "수요일" => DayOfWeek.Wednesday,
"thursday" or "thu" or "목" or "목요일" => DayOfWeek.Thursday,
"friday" or "fri" or "금" or "금요일" => DayOfWeek.Friday,
"saturday" or "sat" or "토" or "토요일" => DayOfWeek.Saturday,
"sunday" or "sun" or "일" or "일요일" => DayOfWeek.Sunday,
_ => null,
};
if (dow == null) return false;
var today = DateTime.Today;
var daysAhead = ((int)dow.Value - (int)today.DayOfWeek + 7) % 7;
if (daysAhead == 0) daysAhead = 7; // "next"이므로 다음 주
result = today.AddDays(daysAhead);
return true;
}
// ── 날짜 계산 헬퍼 ────────────────────────────────────────────────────────
private static int CalcAge(DateTime birth, DateTime today)
{
var age = today.Year - birth.Year;
if (today.Month < birth.Month || (today.Month == birth.Month && today.Day < birth.Day))
age--;
return age;
}
private static DateTime NextBirthday(DateTime birth, DateTime today)
{
var thisYear = new DateTime(today.Year, birth.Month, birth.Day);
return thisYear >= today ? thisYear : thisYear.AddYears(1);
}
// 간략화된 양력 설날 근사값 (실제 음력 계산은 복잡하므로 고정 근사)
private static DateTime GetNextLunarNewYear(DateTime today)
{
// 설날은 대략 1월 말 ~ 2월 초이므로 2월 5일을 기준점으로 사용
var approx = new DateTime(today.Year, 2, 5);
return approx >= today ? approx : new DateTime(today.Year + 1, 2, 5);
}
private static DateTime GetNextChuseok(DateTime today)
{
// 추석은 대략 9월 말 ~ 10월 초이므로 9월 28일을 기준점으로 사용
var approx = new DateTime(today.Year, 9, 28);
return approx >= today ? approx : new DateTime(today.Year + 1, 9, 28);
}
private static string DayKor(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => "월요일",
DayOfWeek.Tuesday => "화요일",
DayOfWeek.Wednesday => "수요일",
DayOfWeek.Thursday => "목요일",
DayOfWeek.Friday => "금요일",
DayOfWeek.Saturday => "토요일",
DayOfWeek.Sunday => "일요일",
_ => "",
};
}

View File

@@ -0,0 +1,260 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L28-4: 클립보드 텍스트 즉시 변환 핸들러. "ap" 프리픽스로 사용합니다.
/// (Advanced Paste — PowerToys Advanced Paste 대응)
///
/// 예: ap → 클립보드 내용 표시 + 변환 목록
/// ap upper → 대문자 변환
/// ap lower → 소문자 변환
/// ap trim → 앞뒤 공백 제거
/// ap sort → 줄 정렬
/// ap unique → 중복 줄 제거
/// ap number → 줄 번호 추가
/// ap reverse → 줄 순서 뒤집기
/// ap count → 글자/단어/줄 수
/// ap json → JSON 정리 (포맷팅)
/// ap remove blank → 빈 줄 제거
/// ap replace A B → A를 B로 전체 치환
/// Enter → 변환된 텍스트를 클립보드에 복사.
/// </summary>
public class ApHandler : IActionHandler
{
public string? Prefix => "ap";
public PluginMetadata Metadata => new(
"텍스트 변환",
"클립보드 텍스트 즉시 변환 (Advanced Paste)",
"1.0",
"AX");
private static readonly (string Cmd, string Label, string Desc)[] Commands =
[
("upper", "대문자 변환", "전체 텍스트를 대문자로"),
("lower", "소문자 변환", "전체 텍스트를 소문자로"),
("trim", "공백 정리", "각 줄 앞뒤 공백 제거"),
("sort", "줄 정렬", "알파벳/가나다 순 줄 정렬"),
("rsort", "줄 역순 정렬", "역순 줄 정렬"),
("unique", "중복 제거", "동일 줄 제거 (순서 유지)"),
("number", "줄 번호 추가", "각 줄 앞에 1. 2. 3. 번호"),
("reverse", "줄 순서 뒤집기", "마지막 줄 → 첫 줄"),
("count", "텍스트 통계", "글자·단어·줄 수 표시"),
("blank", "빈 줄 제거", "빈 줄 삭제"),
("json", "JSON 정리", "JSON 들여쓰기 포맷팅"),
("single", "한 줄로 합치기", "줄바꿈 → 공백으로 연결"),
("slug", "URL 슬러그", "소문자 + 하이픈 (공백/특수문자 변환)"),
("base64", "Base64 인코딩", "텍스트 → Base64"),
("decode64","Base64 디코딩", "Base64 → 텍스트"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 클립보드 텍스트 읽기
string clipText;
try
{
clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
}
catch
{
clipText = "";
}
if (string.IsNullOrEmpty(clipText))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"변환할 텍스트를 먼저 복사하세요",
null, null, Symbol: Symbols.Clipboard));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var preview = clipText.Length > 80 ? clipText[..77].Replace("\n", " ") + "…" : clipText.Replace("\n", " ");
int lineCount = clipText.Split('\n').Length;
// ── replace 명령 ───────────────────────────────────────────────────────
if (q.StartsWith("replace "))
{
var parts = q[8..].Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
var from = parts[0];
var to = parts[1];
var result = clipText.Replace(from, to, StringComparison.OrdinalIgnoreCase);
int count = (clipText.Length - result.Length) / Math.Max(from.Length - to.Length, 1);
items.Add(new LauncherItem(
$"치환: '{from}' → '{to}'",
$"Enter: 클립보드 갱신",
null, ("result", result), Symbol: Symbols.Clipboard));
}
else
{
items.Add(new LauncherItem("사용법: ap replace {찾을값} {바꿀값}",
"예: ap replace hello world", null, null, Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 빈 쿼리 → 전체 명령 목록 ─────────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"클립보드: {preview}",
$"{clipText.Length}자 · {lineCount}줄 · 아래 변환 명령 선택",
null, null, Symbol: Symbols.Clipboard));
foreach (var (cmd, label, desc) in Commands)
{
items.Add(new LauncherItem(
$"ap {cmd} — {label}",
desc,
null, ("cmd", cmd), Symbol: "\uE8AC"));
}
items.Add(new LauncherItem(
"ap replace {A} {B} — 텍스트 치환",
"A를 B로 전체 치환",
null, null, Symbol: "\uE8AC"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 명령 실행 미리보기 ───────────────────────────────────────────────
var transformed = Transform(clipText, q);
if (transformed != null)
{
var tPreview = transformed.Length > 120 ? transformed[..117].Replace("\n", " ") + "…" : transformed.Replace("\n", " ");
var cmdInfo = Commands.FirstOrDefault(c => c.Cmd == q);
items.Add(new LauncherItem(
$"{(cmdInfo.Label ?? q)} 결과",
$"{tPreview} · Enter: 클립보드 갱신",
null, ("result", transformed), Symbol: Symbols.Clipboard));
}
else
{
// 부분 매칭으로 명령 제안
var matched = Commands.Where(c =>
c.Cmd.Contains(q) || c.Label.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (matched.Count > 0)
{
foreach (var (cmd, label, desc) in matched)
items.Add(new LauncherItem($"ap {cmd} — {label}", desc,
null, ("cmd", cmd), Symbol: "\uE8AC"));
}
else
{
items.Add(new LauncherItem($"'{q}' 알 수 없는 변환 명령",
"ap upper/lower/trim/sort/unique/number/reverse/json ...",
null, null, Symbol: Symbols.Warning));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("result", string result))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
}
catch (Exception ex)
{
NotificationService.Notify("ap", $"복사 실패: {ex.Message}");
}
}
else if (item.Data is ("cmd", string cmd))
{
try
{
var clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
var result2 = Transform(clipText, cmd);
if (result2 != null)
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result2));
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
}
}
catch (Exception ex)
{
NotificationService.Notify("ap", $"변환 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 변환 로직 ───────────────────────────────────────────────────────────
private static string? Transform(string text, string cmd)
{
var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
return cmd switch
{
"upper" => text.ToUpperInvariant(),
"lower" => text.ToLowerInvariant(),
"trim" => string.Join("\n", lines.Select(l => l.Trim())),
"sort" => string.Join("\n", lines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)),
"rsort" => string.Join("\n", lines.OrderByDescending(l => l, StringComparer.OrdinalIgnoreCase)),
"unique" => string.Join("\n", lines.Distinct()),
"number" => string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}")),
"reverse" => string.Join("\n", lines.Reverse()),
"blank" => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))),
"single" => string.Join(" ", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim())),
"count" => $"글자: {text.Length} · 단어: {CountWords(text)} · 줄: {lines.Length} · 바이트: {Encoding.UTF8.GetByteCount(text)}",
"json" => TryFormatJson(text),
"slug" => ToSlug(text),
"base64" => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
"decode64" => TryDecodeBase64(text),
_ => null
};
}
private static int CountWords(string text)
=> Regex.Matches(text, @"[\w가-힣]+").Count;
private static string TryFormatJson(string text)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(text);
using var ms = new System.IO.MemoryStream();
using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
doc.WriteTo(writer);
writer.Flush();
return Encoding.UTF8.GetString(ms.ToArray());
}
catch { return "유효하지 않은 JSON입니다."; }
}
private static string ToSlug(string text)
{
var slug = text.ToLowerInvariant().Trim();
slug = Regex.Replace(slug, @"[^a-z0-9가-힣\s-]", "");
slug = Regex.Replace(slug, @"[\s]+", "-");
slug = Regex.Replace(slug, @"-{2,}", "-");
return slug.Trim('-');
}
private static string TryDecodeBase64(string text)
{
try
{
var bytes = Convert.FromBase64String(text.Trim());
return Encoding.UTF8.GetString(bytes);
}
catch { return "유효하지 않은 Base64입니다."; }
}
}

View File

@@ -0,0 +1,297 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L18-3: 화면 비율·해상도 계산기 핸들러. "aspect" 프리픽스로 사용합니다.
///
/// 예: aspect → 주요 비율 목록
/// aspect 1920 1080 → 1920x1080 비율 계산
/// aspect 16:9 1280 → 16:9 비율에서 너비 1280의 높이
/// aspect 16:9 h 720 → 16:9 비율에서 높이 720의 너비
/// aspect 4:3 → 4:3 비율의 주요 해상도 목록
/// aspect crop 1920 1080 4:3 → 크롭 영역 계산
/// Enter → 해상도 복사.
/// </summary>
public class AspectHandler : IActionHandler
{
public string? Prefix => "aspect";
public PluginMetadata Metadata => new(
"Aspect",
"화면 비율·해상도 계산기 — 16:9 · 4:3 · 21:9 · 크롭 영역",
"1.0",
"AX");
private record AspectPreset(string Ratio, string Name, (int W, int H)[] Resolutions);
private static readonly AspectPreset[] Presets =
[
new("16:9", "와이드스크린 (모니터·TV·유튜브)",
[(3840,2160),(2560,1440),(1920,1080),(1600,900),(1366,768),(1280,720),(960,540),(854,480),(640,360)]),
new("4:3", "전통 CRT·클래식 TV",
[(2048,1536),(1600,1200),(1400,1050),(1280,960),(1024,768),(800,600),(640,480)]),
new("21:9", "울트라와이드 시네마",
[(5120,2160),(3440,1440),(2560,1080),(2560,1080),(1280,540)]),
new("1:1", "정사각형 (인스타그램·SNS)",
[(4096,4096),(2048,2048),(1080,1080),(720,720),(512,512)]),
new("9:16", "세로 (모바일·스토리·릴스)",
[(1080,1920),(720,1280),(540,960),(360,640)]),
new("3:2", "DSLR 카메라 (35mm)",
[(6000,4000),(4500,3000),(3000,2000),(1500,1000)]),
new("2:1", "시네마 와이드",
[(4096,2048),(2048,1024),(1920,960)]),
new("5:4", "구형 모니터",
[(1280,1024),(1024,819)]),
new("2.35:1","영화 시네마스코프",
[(2560,1090),(1920,817),(1280,544)]),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("화면 비율·해상도 계산기",
"예: aspect 1920 1080 / aspect 16:9 1280 / aspect 4:3",
null, null, Symbol: "\uE7F4"));
items.Add(new LauncherItem("── 주요 비율 ──", "", null, null, Symbol: "\uE7F4"));
foreach (var p in Presets)
items.Add(new LauncherItem($"{p.Ratio} {p.Name}",
$"주요 해상도: {string.Join(", ", p.Resolutions.Take(3).Select(r => $"{r.W}×{r.H}"))}…",
null, null, Symbol: "\uE7F4"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// aspect <W> <H> → 비율 계산
if (parts.Length >= 2 && int.TryParse(parts[0], out var w1) && int.TryParse(parts[1], out var h1))
{
items.AddRange(BuildFromResolution(w1, h1));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// aspect <비율> 형식 (예: 16:9, 4:3, 16/9)
if (TryParseRatio(parts[0], out var rw, out var rh))
{
// aspect 16:9 <너비> 또는 aspect 16:9 h <높이>
if (parts.Length >= 2)
{
var isHeight = parts.Length >= 3 &&
parts[1].ToLowerInvariant() is "h" or "height" or "높이";
var dimStr = isHeight ? parts[2] : parts[1];
if (int.TryParse(dimStr, out var dim))
{
if (isHeight)
{
var calcW = (int)Math.Round((double)dim * rw / rh);
items.AddRange(BuildFromRatioAndDim(rw, rh, calcW, dim));
}
else
{
var calcH = (int)Math.Round((double)dim * rh / rw);
items.AddRange(BuildFromRatioAndDim(rw, rh, dim, calcH));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// aspect 16:9 → 주요 해상도 목록
items.AddRange(BuildFromRatio(rw, rh));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// crop 서브커맨드
if (parts[0].ToLowerInvariant() == "crop" && parts.Length >= 5)
{
if (int.TryParse(parts[1], out var srcW) &&
int.TryParse(parts[2], out var srcH) &&
TryParseRatio(parts[3], out var cRw, out var cRh))
{
items.AddRange(BuildCropItems(srcW, srcH, cRw, cRh));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
items.Add(new LauncherItem("형식 오류",
"예: aspect 1920 1080 / aspect 16:9 1280 / aspect 16:9 h 720",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Aspect", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ─────────────────────────────────────────────────────────────────
private static List<LauncherItem> BuildFromResolution(int w, int h)
{
var items = new List<LauncherItem>();
var gcd = Gcd(w, h);
var rw = w / gcd;
var rh = h / gcd;
var ratio = $"{rw}:{rh}";
var frac = (double)w / h;
items.Add(new LauncherItem($"{w} × {h} → 비율 {ratio}",
$"소수 비율: {frac:F4}", null, ("copy", ratio), Symbol: "\uE7F4"));
items.Add(new LauncherItem($"비율", ratio, null, ("copy", ratio), Symbol: "\uE7F4"));
items.Add(new LauncherItem($"소수 비율", $"{frac:F4}", null, ("copy", $"{frac:F4}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem($"픽셀 수", $"{(long)w * h:N0} px ({(long)w * h / 1_000_000.0:F1} MP)",
null, null, Symbol: "\uE7F4"));
// 비슷한 프리셋 찾기
var preset = Presets.FirstOrDefault(p => p.Ratio == ratio);
if (preset != null)
{
items.Add(new LauncherItem($"── {preset.Ratio} {preset.Name} ──", "", null, null, Symbol: "\uE7F4"));
foreach (var (pw, ph) in preset.Resolutions)
items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph),
null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4"));
}
else
{
// 같은 비율의 다른 해상도 계산
items.Add(new LauncherItem("── 같은 비율 기타 해상도 ──", "", null, null, Symbol: "\uE7F4"));
var scales = new[] { 0.25, 0.5, 0.75, 1.25, 1.5, 2.0 };
foreach (var s in scales)
{
var sw = (int)Math.Round(w * s / gcd) * gcd;
var sh = (int)Math.Round(h * s / gcd) * gcd;
if (sw > 0 && sh > 0)
items.Add(new LauncherItem($"{sw} × {sh} ({s:P0})",
FormatPixels(sw, sh), null, ("copy", $"{sw}x{sh}"), Symbol: "\uE7F4"));
}
}
return items;
}
private static List<LauncherItem> BuildFromRatio(int rw, int rh)
{
var items = new List<LauncherItem>();
var preset = Presets.FirstOrDefault(p => p.Ratio == $"{rw}:{rh}");
if (preset != null)
{
items.Add(new LauncherItem($"{preset.Ratio} {preset.Name}",
$"주요 해상도 {preset.Resolutions.Length}개", null, null, Symbol: "\uE7F4"));
foreach (var (pw, ph) in preset.Resolutions)
items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph),
null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4"));
}
else
{
items.Add(new LauncherItem($"{rw}:{rh} 비율",
"자주 쓰는 너비 기준 해상도", null, null, Symbol: "\uE7F4"));
var widths = new[] { 640, 1280, 1920, 2560, 3840 };
foreach (var bw in widths)
{
var bh = (int)Math.Round((double)bw * rh / rw);
items.Add(new LauncherItem($"{bw} × {bh}", FormatPixels(bw, bh),
null, ("copy", $"{bw}x{bh}"), Symbol: "\uE7F4"));
}
}
return items;
}
private static List<LauncherItem> BuildFromRatioAndDim(int rw, int rh, int w, int h)
{
var items = new List<LauncherItem>();
var label = $"{rw}:{rh} → {w} × {h}";
items.Add(new LauncherItem(label,
$"픽셀 {(long)w * h:N0} · Enter 복사", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem($"너비", $"{w} px", null, ("copy", $"{w}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem($"높이", $"{h} px", null, ("copy", $"{h}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("CSS", $"{w}px × {h}px", null, ("copy", $"{w}px × {h}px"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("w×h", $"{w}x{h}", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4"));
return items;
}
private static List<LauncherItem> BuildCropItems(int srcW, int srcH, int cRw, int cRh)
{
var items = new List<LauncherItem>();
// 크롭 방향 결정
var srcRatio = (double)srcW / srcH;
var cropRatio = (double)cRw / cRh;
int cropW, cropH, offsetX, offsetY;
if (srcRatio > cropRatio)
{
// 좌우 크롭
cropH = srcH;
cropW = (int)Math.Round(srcH * cropRatio);
offsetX = (srcW - cropW) / 2;
offsetY = 0;
}
else
{
// 상하 크롭
cropW = srcW;
cropH = (int)Math.Round(srcW / cropRatio);
offsetX = 0;
offsetY = (srcH - cropH) / 2;
}
items.Add(new LauncherItem($"크롭: {srcW}×{srcH} → {cRw}:{cRh}",
$"크롭 영역: {cropW}×{cropH} 오프셋: ({offsetX},{offsetY})",
null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("크롭 크기", $"{cropW} × {cropH}", null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("X 오프셋", $"{offsetX} px", null, ("copy", $"{offsetX}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("Y 오프셋", $"{offsetY} px", null, ("copy", $"{offsetY}"), Symbol: "\uE7F4"));
items.Add(new LauncherItem("FFmpeg crop", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}",
null, ("copy", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}"), Symbol: "\uE7F4"));
return items;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool TryParseRatio(string s, out int rw, out int rh)
{
rw = rh = 0;
var sep = s.Contains(':') ? ':' : s.Contains('/') ? '/' : '\0';
if (sep == '\0') return false;
var parts = s.Split(sep);
if (parts.Length != 2) return false;
return double.TryParse(parts[0], System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var drw) &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var drh) &&
(rw = (int)Math.Round(drw * 100)) > 0 &&
(rh = (int)Math.Round(drh * 100)) > 0;
}
private static int Gcd(int a, int b) => b == 0 ? a : Gcd(b, a % b);
private static string FormatPixels(int w, int h)
{
var mp = (long)w * h / 1_000_000.0;
return $"{(long)w * h:N0} px ({mp:F1} MP)";
}
}

View File

@@ -0,0 +1,235 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L9-4: 진수 변환기 핸들러. "base" 프리픽스로 사용합니다.
///
/// 예: base 255 → 10진수 → 2/8/16진수 동시 변환
/// base 0xFF → 16진수 → 10/2/8진수
/// base 0b11111111 → 2진수 → 10/8/16진수
/// base 0o377 → 8진수 → 10/2/16진수
/// base 255 to hex → 10→16진수 결과만
/// base ascii 65 → ASCII 코드 변환 (65 = 'A')
/// base ascii A → 문자 → ASCII 코드
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class BaseConvertHandler : IActionHandler
{
public string? Prefix => "base";
public PluginMetadata Metadata => new(
"BaseConvert",
"진수 변환기 — 2 · 8 · 10 · 16진수 · ASCII",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
"진수 변환기",
"예: base 255 / base 0xFF / base 0b1010 / base ascii 65",
null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("base 255", "10진수 → 2/8/16진수", null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("base 0xFF", "16진수 → 10/2/8진수", null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("base 0b1111", "2진수 → 10/8/16진수", null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("base ascii 65", "ASCII 코드 변환", null, null, Symbol: "\uE8C4"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// ASCII 모드
if (parts[0].Equals("ascii", StringComparison.OrdinalIgnoreCase) && parts.Length >= 2)
{
items.AddRange(BuildAsciiItems(string.Join(" ", parts.Skip(1))));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "to" 지정 변환 모드: base 255 to hex
if (parts.Length >= 3 && parts[1].Equals("to", StringComparison.OrdinalIgnoreCase))
{
if (TryParseNumber(parts[0], out var val))
{
var targetBase = parts[2].ToLowerInvariant();
var result = ConvertToBase(val, targetBase);
if (result != null)
{
items.Add(new LauncherItem(
result,
$"{parts[0]} → {targetBase}",
null,
("copy", result),
Symbol: "\uE8C4"));
}
else
{
items.Add(new LauncherItem("알 수 없는 진수",
"bin/oct/dec/hex 중 하나를 지정하세요", null, null, Symbol: "\uE783"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 전체 변환 모드
if (TryParseNumber(parts[0], out var number))
{
items.AddRange(BuildConversionItems(number, parts[0]));
}
else
{
// ASCII 문자열로 시도
if (q.Length <= 8 && q.All(c => c >= 32 && c < 128))
{
items.AddRange(BuildAsciiItems(q));
}
else
{
items.Add(new LauncherItem("파싱 실패", $"'{q}'을(를) 숫자로 해석할 수 없습니다", null, null, Symbol: "\uE783"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("BaseConvert", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildConversionItems(long val, string inputStr)
{
var inputBase = DetectBase(inputStr);
yield return new LauncherItem(
$"{val}",
$"10진수 (입력: {inputStr})",
null,
("copy", val.ToString()),
Symbol: "\uE8C4");
var hex = $"0x{val:X}";
yield return new LauncherItem(hex, "16진수 (HEX)", null, ("copy", hex), Symbol: "\uE8C4");
var bin = val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}";
yield return new LauncherItem(bin, "2진수 (BIN)", null, ("copy", bin), Symbol: "\uE8C4");
var oct = val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}";
yield return new LauncherItem(oct, "8진수 (OCT)", null, ("copy", oct), Symbol: "\uE8C4");
// 2진수 그룹핑 표시 (4비트 단위)
if (val >= 0 && val <= 0xFFFFFFFF)
{
var binRaw = Convert.ToString(val, 2).PadLeft((Convert.ToString(val, 2).Length + 3) / 4 * 4, '0');
var binGrouped = string.Join(" ", Enumerable.Range(0, binRaw.Length / 4)
.Select(i => binRaw.Substring(i * 4, 4)));
yield return new LauncherItem(binGrouped, "2진수 (4비트 그룹)", null, ("copy", binGrouped), Symbol: "\uE8C4");
}
// ASCII 문자 (0~127 범위)
if (val is >= 32 and <= 126)
{
var ch = (char)val;
yield return new LauncherItem($"'{ch}'", $"ASCII 문자 (코드 {val})", null, ("copy", ch.ToString()), Symbol: "\uE8C4");
}
}
private static IEnumerable<LauncherItem> BuildAsciiItems(string input)
{
// 숫자 → 문자
if (long.TryParse(input, out var code) && code >= 0 && code <= 127)
{
var ch = (char)code;
yield return new LauncherItem(
$"'{ch}'",
$"ASCII {code} = '{ch}'",
null,
("copy", ch.ToString()),
Symbol: "\uE8C4");
yield return new LauncherItem($"HEX: 0x{code:X2}", "16진수", null, ("copy", $"0x{code:X2}"), Symbol: "\uE8C4");
yield return new LauncherItem($"BIN: {Convert.ToString(code, 2).PadLeft(8, '0')}", "2진수", null, ("copy", Convert.ToString(code, 2).PadLeft(8, '0')), Symbol: "\uE8C4");
yield break;
}
// 문자/문자열 → 코드
foreach (var c in input.Where(c => c < 128))
{
var codeVal = (int)c;
yield return new LauncherItem(
$"'{c}' = {codeVal}",
$"HEX: 0x{codeVal:X2} BIN: {Convert.ToString(codeVal, 2).PadLeft(8, '0')}",
null,
("copy", codeVal.ToString()),
Symbol: "\uE8C4");
}
}
private static bool TryParseNumber(string s, out long result)
{
result = 0;
if (string.IsNullOrEmpty(s)) return false;
// 0x prefix → 16진수
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || s.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
{
return long.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out result);
}
// 0b prefix → 2진수
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase))
{
try { result = Convert.ToInt64(s[2..], 2); return true; }
catch { return false; }
}
// 0o prefix → 8진수
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase))
{
try { result = Convert.ToInt64(s[2..], 8); return true; }
catch { return false; }
}
// 순수 16진수 (0-9, A-F)
if (s.Length >= 2 && s.All(c => "0123456789ABCDEFabcdef".Contains(c)) && !s.All(char.IsDigit))
{
return long.TryParse(s, System.Globalization.NumberStyles.HexNumber, null, out result);
}
// 10진수
return long.TryParse(s, out result);
}
private static string? ConvertToBase(long val, string targetBase) => targetBase switch
{
"hex" or "16" or "h" => $"0x{val:X}",
"bin" or "2" or "b" => val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}",
"oct" or "8" or "o" => val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}",
"dec" or "10" or "d" => val.ToString(),
_ => null,
};
private static string DetectBase(string s)
{
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return "HEX";
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) return "BIN";
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) return "OCT";
return "DEC";
}
}

View File

@@ -0,0 +1,86 @@
using System.IO;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-5: 배치 파일 이름변경 핸들러. "batchren" 프리픽스로 사용합니다.
/// 예: batchren → 기능 소개 + 창 열기
/// batchren C:\work\*.xlsx → 해당 패턴 파일을 창에 미리 로드
/// </summary>
public class BatchRenameHandler : IActionHandler
{
public string? Prefix => "batchren";
public PluginMetadata Metadata => new(
"BatchRename",
"배치 파일 이름변경 — batchren",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>
{
new LauncherItem(
"배치 파일 이름변경 창 열기",
"변수 패턴 또는 정규식으로 여러 파일을 한 번에 이름변경합니다",
null,
string.IsNullOrWhiteSpace(q) ? "__open__" : q,
Symbol: Symbols.Rename),
new LauncherItem(
"변수: {name} 원본명 · {n} 순번 · {n:3} 세 자리 · {date} 날짜",
"예: 보고서_{n:3}_{date} → 보고서_001_2026-04-04.xlsx",
null, null,
Symbol: Symbols.Info),
new LauncherItem(
"변수: {ext} 확장자 · {date:yyyyMMdd} 날짜 형식 지정",
"정규식 모드: /old_pattern/new_text/ → 패턴 일치 부분 치환",
null, null,
Symbol: Symbols.Info),
};
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
var dataStr = item.Data as string;
if (dataStr == null) return Task.CompletedTask;
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.BatchRenameWindow();
// 초기 경로 패턴이 지정된 경우 파일 미리 로드
if (dataStr != "__open__" && !string.IsNullOrWhiteSpace(dataStr))
{
try
{
var dir = Path.GetDirectoryName(dataStr);
var glob = Path.GetFileName(dataStr);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
{
var files = Directory.GetFiles(dir, glob ?? "*");
Array.Sort(files);
win.AddFiles(files);
}
}
catch (Exception ex)
{
LogService.Warn($"BatchRenameHandler: 초기 로드 실패 — {ex.Message}");
}
}
win.Show();
});
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,219 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L15-3: BMI·건강 계산기 핸들러. "bmi" 프리픽스로 사용합니다.
///
/// 예: bmi 170 65 → 키 170cm / 몸무게 65kg BMI 계산
/// bmi 170 65 30 → 나이 포함 (기초대사량, 목표 칼로리)
/// bmi 170 65 30 m → 성별 포함 (남: m/male, 여: f/female)
/// bmi ideal 170 → 키 170cm 적정 체중 범위
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class BmiHandler : IActionHandler
{
public string? Prefix => "bmi";
public PluginMetadata Metadata => new(
"BMI",
"BMI·건강 계산기 — BMI · 적정 체중 · 기초대사량 · 칼로리",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("BMI·건강 계산기",
"예: bmi 170 65 / bmi 170 65 30 m / bmi ideal 170",
null, null, Symbol: "\uE73E"));
items.Add(new LauncherItem("bmi <키cm> <몸무게kg>", "BMI 지수 계산", null, null, Symbol: "\uE73E"));
items.Add(new LauncherItem("bmi <키> <몸무게> <나이>", "기초대사량 포함", null, null, Symbol: "\uE73E"));
items.Add(new LauncherItem("bmi <키> <몸무게> <나이> m/f", "성별 포함 (남/여)", null, null, Symbol: "\uE73E"));
items.Add(new LauncherItem("bmi ideal <키cm>", "적정 체중 범위", null, null, Symbol: "\uE73E"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// ideal 서브커맨드
if (parts[0].Equals("ideal", StringComparison.OrdinalIgnoreCase))
{
if (parts.Length < 2 || !double.TryParse(parts[1], out var ht) || ht < 100 || ht > 250)
{
items.Add(new LauncherItem("키를 입력하세요", "예: bmi ideal 170", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.AddRange(BuildIdealItems(ht));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 키 파싱
if (!double.TryParse(parts[0].Replace("cm", ""), out var height) || height < 100 || height > 250)
{
items.Add(new LauncherItem("키 형식 오류",
"키를 cm 단위로 입력하세요 (예: 170)", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 몸무게 파싱
if (parts.Length < 2 || !double.TryParse(parts[1].Replace("kg", ""), out var weight) || weight < 20 || weight > 300)
{
items.Add(new LauncherItem("몸무게를 입력하세요",
$"키 {height}cm → 예: bmi {height} 65", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 나이, 성별 (선택)
int? age = null;
bool? male = null;
for (var i = 2; i < parts.Length; i++)
{
var p = parts[i].ToLowerInvariant();
if (p is "m" or "male" or "남" or "남자" or "남성") { male = true; continue; }
if (p is "f" or "female" or "여" or "여자" or "여성") { male = false; continue; }
if (int.TryParse(p, out var a) && a is >= 1 and <= 120) { age = a; continue; }
}
items.AddRange(BuildBmiItems(height, weight, age, male));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("BMI", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildBmiItems(double height, double weight, int? age, bool? male)
{
var hm = height / 100.0;
var bmi = weight / (hm * hm);
var (grade, gradeEmoji) = GetGrade(bmi);
var summary = $"BMI {bmi:F1} ({grade})";
yield return new LauncherItem(summary,
$"키 {height}cm · 몸무게 {weight}kg",
null, ("copy", summary), Symbol: "\uE73E");
yield return new LauncherItem($"BMI 지수", $"{bmi:F2} {gradeEmoji}", null, ("copy", $"{bmi:F2}"), Symbol: "\uE73E");
yield return new LauncherItem("판정", grade, null, ("copy", grade), Symbol: "\uE73E");
// 적정 체중 (BMI 18.5 ~ 22.9 범위)
var idealMin = 18.5 * hm * hm;
var idealMax = 22.9 * hm * hm;
var idealStr = $"{idealMin:F1}kg ~ {idealMax:F1}kg";
yield return new LauncherItem("적정 체중 범위", idealStr, null, ("copy", idealStr), Symbol: "\uE73E");
var diff = weight - (idealMin + idealMax) / 2;
if (Math.Abs(diff) > 0.5)
{
var diffStr = diff > 0 ? $"+{diff:F1}kg 과잉" : $"{diff:F1}kg 부족";
yield return new LauncherItem("표준 체중 대비", diffStr, null, ("copy", diffStr), Symbol: "\uE73E");
}
// BMI 등급 기준
yield return new LauncherItem("── BMI 기준 (WHO 아시아태평양) ──", "", null, null, Symbol: "\uE73E");
yield return new LauncherItem("저체중", "BMI < 18.5", null, null, Symbol: "\uE73E");
yield return new LauncherItem("정상", "18.5 ≤ BMI < 23", null, null, Symbol: "\uE73E");
yield return new LauncherItem("과체중", "23 ≤ BMI < 25", null, null, Symbol: "\uE73E");
yield return new LauncherItem("비만 1단계","25 ≤ BMI < 30", null, null, Symbol: "\uE73E");
yield return new LauncherItem("비만 2단계","BMI ≥ 30", null, null, Symbol: "\uE73E");
// 기초대사량 (Harris-Benedict 개정식)
if (age.HasValue)
{
double bmr;
string bmrLabel;
if (male == true)
{
bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value);
bmrLabel = "남성 기초대사량 (Harris-Benedict)";
}
else if (male == false)
{
bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value);
bmrLabel = "여성 기초대사량 (Harris-Benedict)";
}
else
{
bmr = (88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value)
+ 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value)) / 2;
bmrLabel = "기초대사량 (남녀 평균)";
}
var bmrStr = $"{bmr:N0} kcal/일";
yield return new LauncherItem("── 대사량 ──", "", null, null, Symbol: "\uE73E");
yield return new LauncherItem(bmrLabel, bmrStr, null, ("copy", bmrStr), Symbol: "\uE73E");
// 활동 단계별 권장 칼로리
var actLevels = new[]
{
("비활동 (거의 운동 없음)", 1.2),
("저활동 (주 1~3회 운동)", 1.375),
("보통 활동 (주 3~5회)", 1.55),
("활동적 (주 6~7회)", 1.725),
("매우 활동적 (하루 2회)", 1.9),
};
foreach (var (label, factor) in actLevels)
{
var cal = bmr * factor;
yield return new LauncherItem(label, $"{cal:N0} kcal/일",
null, ("copy", $"{cal:N0} kcal"), Symbol: "\uE73E");
}
}
}
private static IEnumerable<LauncherItem> BuildIdealItems(double height)
{
var hm = height / 100.0;
var idealMin = 18.5 * hm * hm;
var idealMax = 22.9 * hm * hm;
var idealMid = (idealMin + idealMax) / 2;
yield return new LauncherItem(
$"키 {height}cm 적정 체중",
$"{idealMin:F1}kg ~ {idealMax:F1}kg",
null, ("copy", $"{idealMin:F1}kg ~ {idealMax:F1}kg"), Symbol: "\uE73E");
yield return new LauncherItem("최소 정상 체중 (BMI 18.5)", $"{idealMin:F1}kg", null, ("copy", $"{idealMin:F1}"), Symbol: "\uE73E");
yield return new LauncherItem("표준 체중 (BMI 20.7)", $"{idealMid:F1}kg", null, ("copy", $"{idealMid:F1}"), Symbol: "\uE73E");
yield return new LauncherItem("최대 정상 체중 (BMI 22.9)", $"{idealMax:F1}kg", null, ("copy", $"{idealMax:F1}"), Symbol: "\uE73E");
var overMin = 23.0 * hm * hm;
var overMax = 24.9 * hm * hm;
var obese = 25.0 * hm * hm;
yield return new LauncherItem("과체중 범위 (BMI 23~24.9)", $"{overMin:F1}kg ~ {overMax:F1}kg", null, null, Symbol: "\uE73E");
yield return new LauncherItem("비만 기준 (BMI ≥ 25)", $"{obese:F1}kg 이상", null, null, Symbol: "\uE73E");
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static (string Grade, string Emoji) GetGrade(double bmi) => bmi switch
{
< 18.5 => ("저체중", "🔵"),
< 23.0 => ("정상", "🟢"),
< 25.0 => ("과체중", "🟡"),
< 30.0 => ("비만 1단계", "🟠"),
_ => ("비만 2단계", "🔴"),
};
}

View File

@@ -0,0 +1,153 @@
using System.Diagnostics;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L27-5: 화면 밝기 제어 핸들러. "bright" 프리픽스로 사용합니다.
///
/// 예: bright → 현재 밝기 표시
/// bright 70 → 밝기 70% 설정
/// bright up / down → ±10% 조절
/// Enter → 해당 밝기로 설정.
/// WMI (WmiMonitorBrightness) 사용 — 노트북 내장 디스플레이 대상.
/// </summary>
public class BrightHandler : IActionHandler
{
public string? Prefix => "bright";
public PluginMetadata Metadata => new(
"밝기 제어",
"화면 밝기 조절 — 설정·증감 (노트북)",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 현재 밝기 읽기
int current = GetCurrentBrightness();
if (current < 0)
{
items.Add(new LauncherItem(
"밝기 센서를 찾을 수 없습니다",
"노트북 내장 디스플레이에서만 동작합니다 (외장 모니터 미지원)",
null, null, Symbol: "\uE7BA"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var bar = BrightnessBar(current);
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"현재 밝기: {current}%",
$"{bar} · bright 70 / bright up / bright down",
null, null, Symbol: "\uE706"));
items.Add(new LauncherItem("bright up", "밝기 +10%", null, ("set", Math.Min(current + 10, 100)), Symbol: "\uE706"));
items.Add(new LauncherItem("bright down", "밝기 10%", null, ("set", Math.Max(current - 10, 0)), Symbol: "\uE706"));
foreach (var p in new[] { 10, 25, 50, 75, 100 })
items.Add(new LauncherItem($"bright {p}", $"밝기 {p}%", null, ("set", p), Symbol: "\uE706"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (q is "up" or "올려" or "+")
{
var target = Math.Min(current + 10, 100);
items.Add(new LauncherItem($"밝기 +10% → {target}%", BrightnessBar(target), null, ("set", target), Symbol: "\uE706"));
}
else if (q is "down" or "내려" or "-")
{
var target = Math.Max(current - 10, 0);
items.Add(new LauncherItem($"밝기 10% → {target}%", BrightnessBar(target), null, ("set", target), Symbol: "\uE706"));
}
else if (int.TryParse(q, out int val) && val is >= 0 and <= 100)
{
items.Add(new LauncherItem(
$"밝기 {val}% 설정",
$"{BrightnessBar(val)} (현재 {current}%)",
null, ("set", val), Symbol: "\uE706"));
}
else
{
items.Add(new LauncherItem($"'{query}' — 알 수 없는 명령",
"사용법: bright 70 / bright up / bright down",
null, null, Symbol: "\uE7BA"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("set", int level))
{
bool ok = SetBrightness(level);
if (ok)
NotificationService.Notify("bright", $"밝기 {level}%");
else
NotificationService.Notify("bright", "밝기 설정 실패 — 노트북 내장 디스플레이에서만 동작합니다.");
}
return Task.CompletedTask;
}
private static string BrightnessBar(int pct)
{
int filled = pct / 5;
return "[" + new string('█', filled) + new string('░', 20 - filled) + "]";
}
// ─── WMI 밝기 읽기/쓰기 (PowerShell subprocess) ──────────────────────────
private static int GetCurrentBrightness()
{
try
{
var output = RunPowerShell(
"(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightness -ErrorAction Stop).CurrentBrightness");
if (int.TryParse(output.Trim(), out int val))
return val;
}
catch { }
return -1;
}
private static bool SetBrightness(int level)
{
try
{
level = Math.Clamp(level, 0, 100);
var cmd = $"$m = Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods -ErrorAction Stop; " +
$"Invoke-CimMethod -InputObject $m -MethodName WmiSetBrightness -Arguments @{{Timeout=1; Brightness={level}}}";
RunPowerShell(cmd);
return true;
}
catch { return false; }
}
private static string RunPowerShell(string command)
{
using var proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -Command \"{command}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
proc.Start();
var output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit(3000);
return output;
}
}

View File

@@ -0,0 +1,349 @@
using System.Globalization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다.
///
/// 예: cal → 이번달 달력·공휴일
/// cal next → 다음 공휴일 D-day (5개)
/// cal workdays → 이번달 업무일 수·잔여 업무일
/// cal today → 오늘 공휴일 여부
/// cal 2026-05 → 특정 월 조회
/// cal 2026-04-10 → 특정 날짜 조회
/// Enter → 복사
/// </summary>
public class CalHandler : IActionHandler
{
public string? Prefix => "cal";
public PluginMetadata Metadata => new(
"한국 달력",
"공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산",
"1.0",
"AX");
// ── 공휴일 데이터 ─────────────────────────────────────────────────────────
private static readonly Dictionary<DateOnly, string> Holidays = new()
{
// 2024
{ new DateOnly(2024, 1, 1), "신정" },
{ new DateOnly(2024, 2, 9), "설날연휴" },
{ new DateOnly(2024, 2, 10), "설날" },
{ new DateOnly(2024, 2, 11), "설날연휴" },
{ new DateOnly(2024, 2, 12), "대체공휴일" },
{ new DateOnly(2024, 3, 1), "삼일절" },
{ new DateOnly(2024, 4, 10), "국회의원선거" },
{ new DateOnly(2024, 5, 5), "어린이날" },
{ new DateOnly(2024, 5, 6), "대체공휴일" },
{ new DateOnly(2024, 5, 15), "부처님오신날" },
{ new DateOnly(2024, 6, 6), "현충일" },
{ new DateOnly(2024, 8, 15), "광복절" },
{ new DateOnly(2024, 9, 16), "추석연휴" },
{ new DateOnly(2024, 9, 17), "추석" },
{ new DateOnly(2024, 9, 18), "추석연휴" },
{ new DateOnly(2024, 10, 3), "개천절" },
{ new DateOnly(2024, 10, 9), "한글날" },
{ new DateOnly(2024, 12, 25), "크리스마스" },
// 2025
{ new DateOnly(2025, 1, 1), "신정" },
{ new DateOnly(2025, 1, 28), "설날연휴" },
{ new DateOnly(2025, 1, 29), "설날" },
{ new DateOnly(2025, 1, 30), "설날연휴" },
{ new DateOnly(2025, 3, 1), "삼일절" },
{ new DateOnly(2025, 3, 3), "대체공휴일" },
{ new DateOnly(2025, 5, 5), "어린이날" },
{ new DateOnly(2025, 5, 6), "부처님오신날" },
{ new DateOnly(2025, 6, 6), "현충일" },
{ new DateOnly(2025, 8, 15), "광복절" },
{ new DateOnly(2025, 10, 3), "개천절" },
{ new DateOnly(2025, 10, 5), "추석연휴" },
{ new DateOnly(2025, 10, 6), "추석" },
{ new DateOnly(2025, 10, 7), "추석연휴" },
{ new DateOnly(2025, 10, 8), "대체공휴일" },
{ new DateOnly(2025, 10, 9), "한글날" },
{ new DateOnly(2025, 12, 25), "크리스마스" },
// 2026
{ new DateOnly(2026, 1, 1), "신정" },
{ new DateOnly(2026, 2, 17), "설날연휴" },
{ new DateOnly(2026, 2, 18), "설날" },
{ new DateOnly(2026, 2, 19), "설날연휴" },
{ new DateOnly(2026, 3, 1), "삼일절" },
{ new DateOnly(2026, 3, 2), "대체공휴일" },
{ new DateOnly(2026, 5, 5), "어린이날" },
{ new DateOnly(2026, 5, 24), "부처님오신날" },
{ new DateOnly(2026, 5, 25), "대체공휴일" },
{ new DateOnly(2026, 6, 6), "현충일" },
{ new DateOnly(2026, 6, 8), "대체공휴일" },
{ new DateOnly(2026, 8, 15), "광복절" },
{ new DateOnly(2026, 8, 17), "대체공휴일" },
{ new DateOnly(2026, 9, 24), "추석연휴" },
{ new DateOnly(2026, 9, 25), "추석" },
{ new DateOnly(2026, 9, 26), "추석연휴" },
{ new DateOnly(2026, 10, 3), "개천절" },
{ new DateOnly(2026, 10, 5), "대체공휴일" },
{ new DateOnly(2026, 10, 9), "한글날" },
{ new DateOnly(2026, 12, 25), "크리스마스" },
// 2027
{ new DateOnly(2027, 1, 1), "신정" },
{ new DateOnly(2027, 2, 7), "설날연휴" },
{ new DateOnly(2027, 2, 8), "설날" },
{ new DateOnly(2027, 2, 9), "설날연휴" },
{ new DateOnly(2027, 3, 1), "삼일절" },
{ new DateOnly(2027, 5, 5), "어린이날" },
{ new DateOnly(2027, 5, 13), "부처님오신날" },
{ new DateOnly(2027, 6, 6), "현충일" },
{ new DateOnly(2027, 6, 7), "대체공휴일" },
{ new DateOnly(2027, 8, 15), "광복절" },
{ new DateOnly(2027, 8, 16), "대체공휴일" },
{ new DateOnly(2027, 9, 13), "추석연휴" },
{ new DateOnly(2027, 9, 14), "추석" },
{ new DateOnly(2027, 9, 15), "추석연휴" },
{ new DateOnly(2027, 10, 3), "개천절" },
{ new DateOnly(2027, 10, 4), "대체공휴일" },
{ new DateOnly(2027, 10, 9), "한글날" },
{ new DateOnly(2027, 12, 25), "크리스마스" },
{ new DateOnly(2027, 12, 27), "대체공휴일" },
};
private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool IsHoliday(DateOnly d) =>
Holidays.ContainsKey(d) || d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday;
private static bool IsWorkday(DateOnly d) => !IsHoliday(d);
private static int CountWorkdays(int year, int month)
{
var days = DateTime.DaysInMonth(year, month);
var count = 0;
for (var i = 1; i <= days; i++)
if (IsWorkday(new DateOnly(year, month, i))) count++;
return count;
}
private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
{
var count = 0;
var cur = from;
while (cur <= to)
{
if (IsWorkday(cur)) count++;
cur = cur.AddDays(1);
}
return count;
}
private static string DayOfWeekKor(DayOfWeek dow) => DayNames[(int)dow];
// ── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var today = DateOnly.FromDateTime(DateTime.Today);
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
BuildMonthItems(today.Year, today.Month, today, items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var kw = q.ToLowerInvariant();
// next → 다음 공휴일 5개
if (kw == "next")
{
items.Add(new LauncherItem("다음 공휴일 5개", "D-N일 표시 · Enter: 클립보드 복사",
null, null, Symbol: "\uE787"));
var upcoming = Holidays.Keys
.Where(d => d > today)
.OrderBy(d => d)
.Take(5);
foreach (var d in upcoming)
{
var diff = d.DayNumber - today.DayNumber;
var name = Holidays[d];
var label = $"{d:yyyy-MM-dd} ({DayOfWeekKor(d.DayOfWeek)}) — {name} D-{diff}일";
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
null, ("copy", $"{d:yyyy-MM-dd} {name}"), Symbol: "\uE787"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// workdays → 이번달 업무일
if (kw == "workdays")
{
var total = CountWorkdays(today.Year, today.Month);
var remaining = CountWorkdaysFrom(today, new DateOnly(today.Year, today.Month,
DateTime.DaysInMonth(today.Year, today.Month)));
items.Add(new LauncherItem(
$"{today.Year}년 {today.Month}월 업무일 — 총 {total}일",
$"오늘 기준 잔여 업무일: {remaining}일",
null, ("copy", $"{today.Year}년 {today.Month}월 업무일: 총 {total}일, 잔여 {remaining}일"),
Symbol: "\uE787"));
var monthHolidays = Holidays.Where(kv =>
kv.Key.Year == today.Year && kv.Key.Month == today.Month)
.OrderBy(kv => kv.Key);
foreach (var kv in monthHolidays)
{
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
}
if (!monthHolidays.Any())
items.Add(new LauncherItem("이번 달 공휴일 없음", "", null, null, Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// today → 오늘 정보
if (kw == "today")
{
var isHol = Holidays.TryGetValue(today, out var holName);
var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
string status;
if (isHol) status = $"공휴일 ({holName})";
else if (isWknd) status = "주말";
else status = "평일 (업무일)";
items.Add(new LauncherItem(
$"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) — {status}",
"Enter: 클립보드 복사",
null, ("copy", $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) {status}"),
Symbol: "\uE787"));
var next = Holidays.Keys.Where(d => d > today).OrderBy(d => d).FirstOrDefault();
if (next != default)
{
var diff = next.DayNumber - today.DayNumber;
items.Add(new LauncherItem(
$"다음 공휴일: {next:yyyy-MM-dd} ({DayOfWeekKor(next.DayOfWeek)}) — {Holidays[next]} D-{diff}일",
"", null, ("copy", $"{next:yyyy-MM-dd} {Holidays[next]}"), Symbol: "\uE787"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// yyyy-MM-dd 날짜 직접 조회
var dateFormats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" };
if (DateOnly.TryParseExact(q, dateFormats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var specificDate))
{
var isHol = Holidays.TryGetValue(specificDate, out var hName);
var isWknd = specificDate.DayOfWeek == DayOfWeek.Saturday ||
specificDate.DayOfWeek == DayOfWeek.Sunday;
string status;
if (isHol) status = $"공휴일 ({hName})";
else if (isWknd) status = "주말";
else status = "평일 (업무일)";
items.Add(new LauncherItem(
$"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) — {status}",
"Enter: 클립보드 복사",
null, ("copy", $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) {status}"),
Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// yyyy-MM or yyyy/MM or yyyyMM 월 조회
var monthFormats = new[] { "yyyy-MM", "yyyy/MM", "yyyyMM" };
if (DateOnly.TryParseExact(q + "-01", new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMM-dd" },
CultureInfo.InvariantCulture, DateTimeStyles.None, out var monthDate) ||
TryParseYearMonth(q, out monthDate))
{
BuildMonthItems(monthDate.Year, monthDate.Month, today, items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 기본 — 이번달
BuildMonthItems(today.Year, today.Month, today, items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static bool TryParseYearMonth(string q, out DateOnly result)
{
result = default;
// yyyy-MM, yyyy/MM, yyyyMM
var formats = new[] { "yyyy-MM", "yyyy/MM" };
foreach (var fmt in formats)
{
if (DateTime.TryParseExact(q, fmt, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var dt))
{
result = new DateOnly(dt.Year, dt.Month, 1);
return true;
}
}
// yyyyMM (6자리)
if (q.Length == 6 && int.TryParse(q[..4], out var y) && int.TryParse(q[4..], out var m)
&& m >= 1 && m <= 12)
{
result = new DateOnly(y, m, 1);
return true;
}
return false;
}
private static void BuildMonthItems(int year, int month, DateOnly today, List<LauncherItem> items)
{
var total = CountWorkdays(year, month);
var lastDay = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
int remaining;
if (today.Year == year && today.Month == month)
remaining = CountWorkdaysFrom(today, lastDay);
else if (today < new DateOnly(year, month, 1))
remaining = total;
else
remaining = 0;
var header = $"{year}년 {month}월 · 업무일 {total}일";
if (today.Year == year && today.Month == month)
header += $" (잔여 {remaining}일)";
items.Add(new LauncherItem(header, "공휴일 목록 · Enter: 클립보드 복사",
null, ("copy", header), Symbol: "\uE787"));
var monthHolidays = Holidays
.Where(kv => kv.Key.Year == year && kv.Key.Month == month)
.OrderBy(kv => kv.Key)
.ToList();
if (monthHolidays.Count == 0)
{
items.Add(new LauncherItem("공휴일 없음", "", null, null, Symbol: "\uE787"));
}
else
{
foreach (var kv in monthHolidays)
{
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
}
}
}
// ── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("달력", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,288 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-1: 공학 계산기 핸들러. "calc" 프리픽스로 사용합니다.
///
/// 예: calc → 사용법 목록
/// calc sin 45 → sin(45°) = 0.7071
/// calc cos 60 → cos(60°) = 0.5
/// calc tan 45 → tan(45°) = 1.0
/// calc sqrt 144 → √144 = 12
/// calc log 1000 → log₁₀(1000) = 3
/// calc ln 2.718 → ln(2.718) ≈ 1.0
/// calc pow 2 10 → 2¹⁰ = 1024
/// calc factorial 10 → 10! = 3628800
/// calc gcd 12 18 → GCD(12,18) = 6
/// calc lcm 4 6 → LCM(4,6) = 12
/// calc pi → π = 3.14159265358979
/// calc e → e = 2.71828182845905
/// calc deg 1.5707 → 라디안 → 도 변환
/// calc rad 90 → 도 → 라디안 변환
/// calc abs -42 → 절댓값
/// calc ceil 3.2 → 올림
/// calc floor 3.8 → 내림
/// calc round 3.567 2 → 반올림 (소수점 자리)
/// Enter → 결과 복사.
/// </summary>
public class CalcHandler : IActionHandler
{
public string? Prefix => "calc";
public PluginMetadata Metadata => new(
"Calc",
"공학 계산기 — sin·cos·log·sqrt·factorial·GCD·LCM 등",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("공학 계산기",
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/pi/e/deg/rad/abs/ceil/floor/round",
null, null, Symbol: "\uE8EF"));
items.Add(BuildUsage("삼각함수", "calc sin 45 / calc cos 60 / calc tan 30"));
items.Add(BuildUsage("제곱근·거듭제곱", "calc sqrt 144 / calc pow 2 10"));
items.Add(BuildUsage("로그", "calc log 1000 / calc ln 2.718"));
items.Add(BuildUsage("팩토리얼", "calc factorial 12"));
items.Add(BuildUsage("GCD · LCM", "calc gcd 12 18 / calc lcm 4 6"));
items.Add(BuildUsage("상수", "calc pi / calc e"));
items.Add(BuildUsage("단위 변환", "calc deg 1.5707 / calc rad 90"));
items.Add(BuildUsage("기타", "calc abs -42 / calc ceil 3.2 / calc floor 3.8 / calc round 3.567 2"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var fn = parts[0].ToLowerInvariant();
// 단순 상수
if (fn is "pi") { var v = Math.PI; return Result(items, "π (Pi)", v, "π"); }
if (fn is "e") { var v = Math.E; return Result(items, "e (자연상수)", v, "e"); }
if (fn is "phi") { var v = 1.6180339887; return Result(items, "φ (황금비)", v, "φ"); }
// 단일 인수 함수
if (parts.Length >= 2 && double.TryParse(parts[1],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var a))
{
switch (fn)
{
case "sin":
return Result(items, $"sin({a}°)", Math.Sin(DegToRad(a)), "sin");
case "cos":
return Result(items, $"cos({a}°)", Math.Cos(DegToRad(a)), "cos");
case "tan":
if (Math.Abs(a % 180 - 90) < 1e-9)
{
items.Add(ErrorItem("tan(90°±n·180°)는 정의되지 않습니다 (무한대)"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
return Result(items, $"tan({a}°)", Math.Tan(DegToRad(a)), "tan");
case "asin":
if (a < -1 || a > 1) { items.Add(ErrorItem("asin 입력값은 -1 ~ 1 범위여야 합니다")); break; }
return Result(items, $"asin({a}) °", RadToDeg(Math.Asin(a)), "asin", isAngle: true);
case "acos":
if (a < -1 || a > 1) { items.Add(ErrorItem("acos 입력값은 -1 ~ 1 범위여야 합니다")); break; }
return Result(items, $"acos({a}) °", RadToDeg(Math.Acos(a)), "acos", isAngle: true);
case "atan":
return Result(items, $"atan({a}) °", RadToDeg(Math.Atan(a)), "atan", isAngle: true);
case "sqrt":
if (a < 0) { items.Add(ErrorItem("음수의 실수 제곱근은 정의되지 않습니다")); break; }
return Result(items, $"√{a}", Math.Sqrt(a), "sqrt");
case "cbrt":
return Result(items, $"∛{a}", Math.Cbrt(a), "cbrt");
case "log":
if (a <= 0) { items.Add(ErrorItem("log 입력값은 양수여야 합니다")); break; }
return Result(items, $"log₁₀({a})", Math.Log10(a), "log10");
case "log2":
if (a <= 0) { items.Add(ErrorItem("log2 입력값은 양수여야 합니다")); break; }
return Result(items, $"log₂({a})", Math.Log2(a), "log2");
case "ln":
if (a <= 0) { items.Add(ErrorItem("ln 입력값은 양수여야 합니다")); break; }
return Result(items, $"ln({a})", Math.Log(a), "ln");
case "exp":
return Result(items, $"e^{a}", Math.Exp(a), "exp");
case "factorial":
{
var n = (long)Math.Round(a);
if (n < 0 || n > 20) { items.Add(ErrorItem("팩토리얼은 0~20 범위만 지원합니다")); break; }
var fac = Factorial(n);
items.Add(new LauncherItem($"{n}! = {fac:N0}", $"팩토리얼 · Enter 복사",
null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{fac}", null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "abs":
return Result(items, $"|{a}|", Math.Abs(a), "abs");
case "ceil":
return Result(items, $"⌈{a}⌉", Math.Ceiling(a), "ceil");
case "floor":
return Result(items, $"⌊{a}⌋", Math.Floor(a), "floor");
case "round":
{
int decimals = 0;
if (parts.Length >= 3 && int.TryParse(parts[2], out var d)) decimals = Math.Clamp(d, 0, 15);
var rounded = Math.Round(a, decimals, MidpointRounding.AwayFromZero);
return Result(items, $"round({a}, {decimals})", rounded, "round");
}
case "deg":
return Result(items, $"{a} rad → °", RadToDeg(a), "deg", isAngle: true);
case "rad":
return Result(items, $"{a}° → rad", DegToRad(a), "rad");
case "sign":
items.Add(new LauncherItem($"sign({a}) = {Math.Sign(a)}",
a > 0 ? "양수" : a < 0 ? "음수" : "0",
null, ("copy", $"{Math.Sign(a)}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 이인수 함수
if (parts.Length >= 3 &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var x) &&
double.TryParse(parts[2], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var y))
{
switch (fn)
{
case "pow":
return Result(items, $"{x}^{y}", Math.Pow(x, y), "pow");
case "log":
if (x <= 0 || y <= 0 || y == 1) { items.Add(ErrorItem("밑과 진수는 양수, 밑 ≠ 1 이어야 합니다")); break; }
return Result(items, $"log_({x}) {y}", Math.Log(y, x), "log");
case "atan2":
return Result(items, $"atan2({x},{y}) °", RadToDeg(Math.Atan2(x, y)), "atan2", isAngle: true);
case "hypot":
return Result(items, $"hypot({x},{y})", Math.Sqrt(x * x + y * y), "hypot");
case "gcd":
{
var ga = (long)Math.Abs(Math.Round(x));
var gb = (long)Math.Abs(Math.Round(y));
var gv = GcdLong(ga, gb);
items.Add(new LauncherItem($"GCD({ga}, {gb}) = {gv}", "최대공약수 · Enter 복사",
null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{gv}", null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "lcm":
{
var la = (long)Math.Abs(Math.Round(x));
var lb = (long)Math.Abs(Math.Round(y));
var g = GcdLong(la, lb);
var lv = g == 0 ? 0 : la / g * lb;
items.Add(new LauncherItem($"LCM({la}, {lb}) = {lv}", "최소공배수 · Enter 복사",
null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{lv}", null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "mod":
if (y == 0) { items.Add(ErrorItem("0으로 나눌 수 없습니다")); break; }
return Result(items, $"{x} mod {y}", x % y, "mod");
}
}
// 파싱 실패 안내
items.Add(new LauncherItem($"알 수 없는 함수: '{fn}'",
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/abs/ceil/floor/round/deg/rad",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Calc", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static Task<IEnumerable<LauncherItem>> Result(
List<LauncherItem> items, string label, double value, string fn, bool isAngle = false)
{
var formatted = FormatDouble(value);
var extra = isAngle ? " °" : "";
items.Add(new LauncherItem($"{label} = {formatted}{extra}",
$"· Enter 복사", null, ("copy", formatted), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{formatted}{extra}", null, ("copy", formatted), Symbol: "\uE8EF"));
// 추가 정보
if (!isAngle && value != 0)
{
items.Add(new LauncherItem("과학적 표기법", $"{value:E6}", null, ("copy", $"{value:E6}"), Symbol: "\uE8EF"));
if (value > 0)
items.Add(new LauncherItem("log₁₀ 값", $"{Math.Log10(value):F6}", null, null, Symbol: "\uE8EF"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static string FormatDouble(double v)
{
if (double.IsNaN(v)) return "NaN";
if (double.IsPositiveInfinity(v)) return "+∞";
if (double.IsNegativeInfinity(v)) return "-∞";
// 정수라면 소수점 없이
if (v == Math.Floor(v) && Math.Abs(v) < 1e15)
return $"{(long)v}";
return $"{v:G15}";
}
private static LauncherItem BuildUsage(string title, string example) =>
new(title, example, null, null, Symbol: "\uE8EF");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
private static double DegToRad(double deg) => deg * Math.PI / 180.0;
private static double RadToDeg(double rad) => rad * 180.0 / Math.PI;
private static long Factorial(long n)
{
long r = 1;
for (long i = 2; i <= n; i++) r *= i;
return r;
}
private static long GcdLong(long a, long b) => b == 0 ? a : GcdLong(b, a % b);
}

View File

@@ -0,0 +1,253 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L10-3: SSL/TLS 인증서 체커 핸들러. "cert" 프리픽스로 사용합니다.
///
/// 예: cert google.com → google.com의 인증서 정보 조회
/// cert github.com 443 → 포트 지정
/// cert https://example.com → URL 형식도 지원
/// Enter → 결과를 클립보드에 복사.
///
/// ⚠ 외부 인터넷 접속 필요. 사내 모드에서는 내부 호스트만 조회 가능.
/// </summary>
public class CertHandler : IActionHandler
{
public string? Prefix => "cert";
public PluginMetadata Metadata => new(
"Cert",
"SSL/TLS 인증서 체커 — 만료일 · 발급자 · SANs",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
"SSL 인증서 체커",
"예: cert google.com / cert 192.168.1.1 / cert example.com 8443",
null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("cert google.com", "google.com 인증서 조회", null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("cert github.com", "github.com 인증서 조회", null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("cert 192.168.1.1", "내부 서버 인증서 조회", null, null, Symbol: "\uE72E"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 비동기 조회 시작 — 빠른 반환 후 결과를 기다리지 않음
// 실제 조회는 ExecuteAsync에서 처리하며, 여기서는 "조회 중" 항목만 반환
var (host, port) = ParseHostPort(q);
if (string.IsNullOrWhiteSpace(host))
{
items.Add(new LauncherItem("형식 오류", "예: cert domain.com 또는 cert domain.com 443", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 사내 모드 확인
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
var isInternal = settings?.InternalModeEnabled ?? true;
if (isInternal && !IsInternalHost(host))
{
items.Add(new LauncherItem(
"사내 모드 제한",
$"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
$"{host}:{port} 인증서 조회",
"Enter를 눌러 조회하세요",
null,
("check", $"{host}:{port}"),
Symbol: "\uE72E"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Cert", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
return;
}
if (item.Data is not ("check", string target)) return;
var parts = target.Split(':');
var host = parts[0];
var port = parts.Length > 1 && int.TryParse(parts[1], out var p) ? p : 443;
NotificationService.Notify("Cert", $"{host}:{port} 인증서 조회 중…");
try
{
var certInfo = await FetchCertInfoAsync(host, port, ct);
var summary = BuildSummary(certInfo);
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(summary));
NotificationService.Notify("Cert", certInfo.StatusLine);
}
catch (OperationCanceledException)
{
NotificationService.Notify("Cert", "조회가 취소되었습니다.");
}
catch (Exception ex)
{
NotificationService.Notify("Cert", $"오류: {ex.Message}");
}
}
// ── 인증서 조회 ──────────────────────────────────────────────────────────
private static async Task<CertInfo> FetchCertInfoAsync(string host, int port, CancellationToken ct)
{
using var client = new TcpClient();
await client.ConnectAsync(host, port, ct);
using var sslStream = new SslStream(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback: (_, cert, _, _) => true); // 만료 인증서도 정보 확인
await sslStream.AuthenticateAsClientAsync(
new SslClientAuthenticationOptions
{
TargetHost = host,
RemoteCertificateValidationCallback = (_, _, _, _) => true,
}, ct);
var cert = sslStream.RemoteCertificate as X509Certificate2
?? new X509Certificate2(sslStream.RemoteCertificate!);
return BuildCertInfo(host, port, cert);
}
private static CertInfo BuildCertInfo(string host, int port, X509Certificate2 cert)
{
var now = DateTime.UtcNow;
var notAfter = cert.NotAfter.ToUniversalTime();
var daysLeft = (int)(notAfter - now).TotalDays;
var subject = cert.Subject;
var issuer = cert.Issuer;
// SANs (Subject Alternative Names)
var sans = new List<string>();
foreach (var ext in cert.Extensions)
{
if (ext.Oid?.Value == "2.5.29.17") // SAN OID
{
var raw = ext.Format(false);
sans.AddRange(raw.Split(new[] { ", ", ",\r\n" }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.StartsWith("DNS Name=", StringComparison.OrdinalIgnoreCase))
.Select(s => s[9..]));
}
}
var status = daysLeft > 30 ? "유효"
: daysLeft > 0 ? "만료 임박"
: "만료됨";
return new CertInfo
{
Host = host,
Port = port,
Subject = subject,
Issuer = issuer,
NotBefore = cert.NotBefore,
NotAfter = cert.NotAfter,
DaysLeft = daysLeft,
Sans = sans,
Thumbprint = cert.Thumbprint,
Status = status,
StatusLine = $"{status} · D-{(daysLeft > 0 ? daysLeft.ToString() : "")} · {host}:{port}",
};
}
private static string BuildSummary(CertInfo c)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"호스트: {c.Host}:{c.Port}");
sb.AppendLine($"상태: {c.Status} (만료까지 {c.DaysLeft}일)");
sb.AppendLine($"발급 대상: {c.Subject}");
sb.AppendLine($"발급 기관: {c.Issuer}");
sb.AppendLine($"유효 시작: {c.NotBefore:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"만료 일자: {c.NotAfter:yyyy-MM-dd HH:mm:ss}");
if (c.Sans.Count > 0)
sb.AppendLine($"SANs: {string.Join(", ", c.Sans.Take(10))}");
sb.AppendLine($"지문(SHA1): {c.Thumbprint}");
return sb.ToString().TrimEnd();
}
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
private static (string Host, int Port) ParseHostPort(string q)
{
// https:// 또는 http:// 제거
if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
q = q[8..];
else if (q.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
q = q[7..];
// 경로 제거
var slashIdx = q.IndexOf('/');
if (slashIdx >= 0) q = q[..slashIdx];
var colonIdx = q.LastIndexOf(':');
if (colonIdx >= 0 && int.TryParse(q[(colonIdx + 1)..], out var port))
return (q[..colonIdx], port);
return (q, 443);
}
private static bool IsInternalHost(string host)
{
if (host is "localhost" or "127.0.0.1") return true;
if (host.StartsWith("192.168.")) return true;
if (host.StartsWith("10.")) return true;
if (host.StartsWith("172.16.") || host.StartsWith("172.17.") ||
host.StartsWith("172.18.") || host.StartsWith("172.19.") ||
host.StartsWith("172.20.") || host.StartsWith("172.21.") ||
host.StartsWith("172.22.") || host.StartsWith("172.23.") ||
host.StartsWith("172.24.") || host.StartsWith("172.25.") ||
host.StartsWith("172.26.") || host.StartsWith("172.27.") ||
host.StartsWith("172.28.") || host.StartsWith("172.29.") ||
host.StartsWith("172.30.") || host.StartsWith("172.31.")) return true;
return false;
}
private record CertInfo
{
public string Host { get; init; } = "";
public int Port { get; init; }
public string Subject { get; init; } = "";
public string Issuer { get; init; } = "";
public DateTime NotBefore { get; init; }
public DateTime NotAfter { get; init; }
public int DaysLeft { get; init; }
public List<string> Sans { get; init; } = new();
public string Thumbprint { get; init; } = "";
public string Status { get; init; } = "";
public string StatusLine { get; init; } = "";
}
}

View File

@@ -0,0 +1,320 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L9-3: 시스템 정리 핸들러. "clean" 프리픽스로 사용합니다.
///
/// 예: clean → 정리 가능한 항목 목록 + 예상 용량
/// clean temp → Windows 임시 파일 정리 (%TEMP%)
/// clean recycle → 휴지통 비우기
/// clean downloads → 다운로드 폴더 오래된 파일 목록 (30일 이상)
/// clean logs → 앱 로그 폴더 정리 (%APPDATA%\AxCopilot\logs)
/// clean all → temp + recycle + logs 한 번에 정리
/// </summary>
public class CleanHandler : IActionHandler
{
public string? Prefix => "clean";
public PluginMetadata Metadata => new(
"Clean",
"시스템 정리 — 임시 파일 · 휴지통 · 다운로드 · 로그",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 각 영역 크기 미리보기
var tempSize = GetDirSize(Path.GetTempPath());
var recycleSize = GetRecycleBinSize();
var downloadsSize = GetOldDownloadsSize(30);
var logSize = GetDirSize(GetAppLogPath());
var total = tempSize + recycleSize + downloadsSize + logSize;
items.Add(new LauncherItem(
$"정리 가능 {FormatBytes(total)}",
"항목을 선택하거나 clean all 로 모두 정리",
null, null, Symbol: "\uE74D"));
items.Add(MakeCleanItem("temp", "\uE8B6", "임시 파일 (%TEMP%)", tempSize));
items.Add(MakeCleanItem("recycle", "\uE74D", "휴지통", recycleSize));
items.Add(MakeCleanItem("downloads", "\uE896", "다운로드 (30일 이상)", downloadsSize));
items.Add(MakeCleanItem("logs", "\uE9D9", "AxCopilot 로그 파일", logSize));
items.Add(new LauncherItem(
"clean all",
$"temp + recycle + logs 한 번에 정리 ({FormatBytes(tempSize + recycleSize + logSize)})",
null,
("clean_all", ""),
Symbol: "\uE74D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (q)
{
case "temp":
{
var tempPath = Path.GetTempPath();
var size = GetDirSize(tempPath);
var count = SafeCountFiles(tempPath);
items.Add(new LauncherItem(
$"임시 파일 정리 {FormatBytes(size)}",
$"{count}개 파일 · {tempPath}",
null,
("clean_temp", tempPath),
Symbol: "\uE8B6"));
break;
}
case "recycle":
{
var size = GetRecycleBinSize();
items.Add(new LauncherItem(
$"휴지통 비우기 {FormatBytes(size)}",
"복구 불가능합니다. Enter로 실행",
null,
("clean_recycle", ""),
Symbol: "\uE74D"));
break;
}
case "downloads":
{
var downloadsPath = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile);
downloadsPath = Path.Combine(downloadsPath, "Downloads");
var oldFiles = GetOldFiles(downloadsPath, 30);
var totalSz = oldFiles.Sum(f => f.Length);
items.Add(new LauncherItem(
$"다운로드 30일 이상 파일 {FormatBytes(totalSz)}",
$"{oldFiles.Count}개 파일",
null,
("list_downloads", downloadsPath),
Symbol: "\uE896"));
foreach (var f in oldFiles.Take(15))
{
items.Add(new LauncherItem(
f.Name,
$"{FormatBytes(f.Length)} · {f.LastWriteTime:MM-dd HH:mm}",
null,
("open_file", f.FullName),
Symbol: "\uE8A5"));
}
break;
}
case "logs":
{
var logPath = GetAppLogPath();
var size = GetDirSize(logPath);
var count = SafeCountFiles(logPath);
items.Add(new LauncherItem(
$"AxCopilot 로그 정리 {FormatBytes(size)}",
$"{count}개 파일 · {logPath}",
null,
("clean_logs", logPath),
Symbol: "\uE9D9"));
break;
}
case "all":
{
var tempSz = GetDirSize(Path.GetTempPath());
var recycleSz = GetRecycleBinSize();
var logSz = GetDirSize(GetAppLogPath());
items.Add(new LauncherItem(
$"모두 정리 {FormatBytes(tempSz + recycleSz + logSz)}",
"temp + recycle + logs · Enter로 실행",
null,
("clean_all", ""),
Symbol: "\uE74D"));
break;
}
default:
items.Add(new LauncherItem("서브커맨드", "temp · recycle · downloads · logs · all", null, null, Symbol: "\uE946"));
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("clean_temp", string path):
await Task.Run(() =>
{
var deleted = CleanDirectory(path, recursive: false);
NotificationService.Notify("정리 완료", $"임시 파일 {deleted}개 삭제");
}, ct);
break;
case ("clean_recycle", _):
await Task.Run(() =>
{
EmptyRecycleBin();
NotificationService.Notify("정리 완료", "휴지통을 비웠습니다.");
}, ct);
break;
case ("clean_logs", string path):
await Task.Run(() =>
{
var deleted = CleanDirectory(path, recursive: true);
NotificationService.Notify("정리 완료", $"로그 파일 {deleted}개 삭제");
}, ct);
break;
case ("clean_all", _):
await Task.Run(() =>
{
var t1 = CleanDirectory(Path.GetTempPath(), recursive: false);
EmptyRecycleBin();
var t2 = CleanDirectory(GetAppLogPath(), recursive: true);
NotificationService.Notify("모두 정리 완료",
$"임시 {t1}개, 로그 {t2}개 삭제, 휴지통 비움");
}, ct);
break;
case ("list_downloads", string path):
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
catch { /* 비핵심 */ }
break;
case ("open_file", string filePath):
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true,
});
}
catch { /* 비핵심 */ }
break;
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeCleanItem(string sub, string icon, string label, long size) =>
new(
$"clean {sub}",
$"{label} · {FormatBytes(size)}",
null,
($"clean_{sub}", ""),
Symbol: icon);
private static long GetDirSize(string path)
{
if (!Directory.Exists(path)) return 0;
try
{
return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } });
}
catch { return 0; }
}
private static long GetRecycleBinSize()
{
try
{
// SHQueryRecycleBin P/Invoke 대신 Shell32 통해 추정
// 간단히 0 반환 (실제 크기는 SHQueryRecycleBinW P/Invoke 필요)
return 0;
}
catch { return 0; }
}
private static long GetOldDownloadsSize(int olderThanDays)
{
try
{
var path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Downloads");
return GetOldFiles(path, olderThanDays).Sum(f => f.Length);
}
catch { return 0; }
}
private static List<FileInfo> GetOldFiles(string dir, int olderThanDays)
{
if (!Directory.Exists(dir)) return [];
var cutoff = DateTime.Now.AddDays(-olderThanDays);
try
{
return Directory.EnumerateFiles(dir)
.Select(f => new FileInfo(f))
.Where(fi => fi.LastWriteTime < cutoff)
.OrderByDescending(fi => fi.Length)
.ToList();
}
catch { return []; }
}
private static int SafeCountFiles(string path)
{
if (!Directory.Exists(path)) return 0;
try
{
return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Count();
}
catch { return 0; }
}
private static int CleanDirectory(string path, bool recursive)
{
if (!Directory.Exists(path)) return 0;
int deleted = 0;
var opts = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
foreach (var file in Directory.EnumerateFiles(path, "*", opts))
{
try { File.Delete(file); deleted++; } catch { /* 잠긴 파일 무시 */ }
}
return deleted;
}
[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern int SHEmptyRecycleBin(IntPtr hwnd, string? pszRootPath, uint dwFlags);
private static void EmptyRecycleBin()
{
try
{
// SHERB_NOCONFIRMATION=0x1, SHERB_NOPROGRESSUI=0x2, SHERB_NOSOUND=0x4
SHEmptyRecycleBin(IntPtr.Zero, null, 0x07);
}
catch { /* 비핵심 */ }
}
private static string GetAppLogPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "logs");
private static string FormatBytes(long bytes) => bytes switch
{
>= 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024:F1} GB",
>= 1024L * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
>= 1024L => $"{bytes / 1024.0:F0} KB",
_ => $"{bytes} B",
};
}

View File

@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -26,7 +25,6 @@ public class ClipboardHandler : IActionHandler
{
var items = new List<LauncherItem>();
// 빌트인 변환 목록
var builtins = GetBuiltinTransformers()
.Where(t => string.IsNullOrEmpty(query) ||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase));
@@ -34,14 +32,12 @@ public class ClipboardHandler : IActionHandler
foreach (var t in builtins)
items.Add(new LauncherItem(t.Key, t.Description ?? "", null, t, Symbol: Symbols.Clipboard));
// 사용자 정의 변환
var custom = _settings.Settings.ClipboardTransformers
.Where(t => string.IsNullOrEmpty(query) ||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase))
.Select(t => new LauncherItem(t.Key, t.Description ?? t.Type, null, t, Symbol: Symbols.Clipboard));
items.AddRange(custom);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
@@ -49,7 +45,6 @@ public class ClipboardHandler : IActionHandler
{
if (item.Data is not ClipboardTransformer transformer) return;
// 클립보드에서 텍스트 읽기 (STA 스레드 필요)
string? input = null;
Application.Current.Dispatcher.Invoke(() =>
{
@@ -61,17 +56,12 @@ public class ClipboardHandler : IActionHandler
string? result = await TransformAsync(transformer, input, ct);
if (result == null) return;
// 결과를 클립보드에 쓰고 이전 활성 창으로 포커스 복원 후 붙여넣기
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
// 런처 호출 전 활성 창으로 포커스 복원
var prevHwnd = WindowTracker.PreviousWindow;
if (prevHwnd != IntPtr.Zero)
SetForegroundWindow(prevHwnd);
await Task.Delay(120, ct);
System.Windows.Forms.SendKeys.SendWait("^v");
if (prevHwnd == IntPtr.Zero) return;
await ForegroundPasteHelper.PasteClipboardAsync(prevHwnd, ct, initialDelayMs: 220);
LogService.Info($"클립보드 변환: '{transformer.Key}' 적용");
}
@@ -81,7 +71,6 @@ public class ClipboardHandler : IActionHandler
{
return t.Type switch
{
// ReDoS 방지: 사용자 정의 패턴에 타임아웃 적용
"regex" when t.Pattern != null && t.Replace != null =>
Regex.Replace(input, t.Pattern, t.Replace, RegexOptions.None,
TimeSpan.FromMilliseconds(t.Timeout > 0 ? t.Timeout : 5000)),
@@ -120,8 +109,6 @@ public class ClipboardHandler : IActionHandler
return await proc.StandardOutput.ReadToEndAsync(cts.Token);
}
// ─── 빌트인 변환 ─────────────────────────────────────────────────────────
internal static string? ExecuteBuiltin(string key, string input)
{
return key switch
@@ -149,7 +136,10 @@ public class ClipboardHandler : IActionHandler
var doc = JsonDocument.Parse(input);
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
catch { return input; }
catch
{
return input;
}
}
private static string? TryParseTimestamp(string input)
@@ -159,6 +149,7 @@ public class ClipboardHandler : IActionHandler
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).LocalDateTime;
return dt.ToString("yyyy-MM-dd HH:mm:ss");
}
return null;
}
@@ -166,15 +157,13 @@ public class ClipboardHandler : IActionHandler
{
if (DateTime.TryParse(input.Trim(), out var dt))
return new DateTimeOffset(dt).ToUnixTimeSeconds().ToString();
return null;
}
private static string StripMarkdown(string input) =>
Regex.Replace(input, @"(\*\*|__)(.*?)\1|(\*|_)(.*?)\3|`(.+?)`|#{1,6}\s*", "$2$4$5");
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
private static IEnumerable<ClipboardTransformer> GetBuiltinTransformers() =>
[
new() { Key = "$json", Type = "builtin", Description = "JSON 포맷팅 (들여쓰기 적용)" },

View File

@@ -1,4 +1,3 @@
using System.Runtime.InteropServices;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
@@ -28,7 +27,6 @@ public class ClipboardHistoryHandler : IActionHandler
_historyService = historyService;
}
// 카테고리 필터 프리픽스: #url, #코드, #경로
private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase)
{
{ "url", "URL" }, { "코드", "코드" }, { "code", "코드" },
@@ -52,7 +50,6 @@ public class ClipboardHistoryHandler : IActionHandler
var q = query.Trim().ToLowerInvariant();
// 카테고리 필터 감지 (예: #url, #핀)
string? catFilter = null;
foreach (var (prefix, cat) in CategoryFilters)
{
@@ -66,17 +63,14 @@ public class ClipboardHistoryHandler : IActionHandler
var filtered = history.AsEnumerable();
// 카테고리 필터 적용
if (catFilter == "핀")
filtered = filtered.Where(e => e.IsPinned);
else if (catFilter != null)
filtered = filtered.Where(e => e.Category == catFilter);
// 텍스트 검색
if (!string.IsNullOrEmpty(q))
filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q));
// 핀 항목을 상단에 배치
var sorted = filtered
.OrderByDescending(e => e.IsPinned)
.ThenByDescending(e => e.CopiedAt);
@@ -113,14 +107,13 @@ public class ClipboardHistoryHandler : IActionHandler
try
{
_historyService.SuppressNextCapture();
_historyService.PromoteEntry(entry); // 사용 시각 갱신 + 목록 맨 위로
_historyService.PromoteEntry(entry);
if (!entry.IsText && entry.Image != null)
{
// 원본 이미지가 있으면 원본 해상도로 클립보드 복사
var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath);
Clipboard.SetImage(originalImg ?? entry.Image);
return; // 이미지는 붙여넣기 시뮬레이션 없이 클립보드만 설정
return;
}
if (string.IsNullOrEmpty(entry.Text)) return;
@@ -129,25 +122,7 @@ public class ClipboardHistoryHandler : IActionHandler
var prevWindow = WindowTracker.PreviousWindow;
if (prevWindow == IntPtr.Zero) return;
// ── 이전 창 포커스 복원 후 Ctrl+V ────────────────────────────────
// 런처 창이 완전히 숨겨지고 이전 창이 포커스를 회복할 시간 확보
await Task.Delay(300, ct);
// AttachThreadInput으로 포그라운드 전환 권한 획득 후 SetForegroundWindow 호출
// (Alt 트릭 대비: Alt 키 주입 없이 안정적으로 전환, 대상 앱 메뉴 트리거 방지)
var targetThread = GetWindowThreadProcessId(prevWindow, out _);
var currentThread = GetCurrentThreadId();
AttachThreadInput(currentThread, targetThread, true);
SetForegroundWindow(prevWindow);
AttachThreadInput(currentThread, targetThread, false);
// 포커스 전환이 완전히 반영될 때까지 대기
await Task.Delay(100, ct);
// SendInput으로 Ctrl+V 주입
// Win32 INPUT 구조체: type(4) + 패딩(4) + union(32) = 40 bytes on x64
// KEYBDINPUT.dwExtraInfo = ULONG_PTR → union 8바이트 정렬 필요 → offset 8에서 시작
SendCtrlV();
await ForegroundPasteHelper.PasteClipboardAsync(prevWindow, ct, initialDelayMs: 260);
}
catch (OperationCanceledException) { }
catch (Exception ex)
@@ -155,62 +130,4 @@ public class ClipboardHistoryHandler : IActionHandler
LogService.Warn($"클립보드 히스토리 붙여넣기 실패: {ex.Message}");
}
}
private static void SendCtrlV()
{
const uint INPUT_KEYBOARD = 1;
const uint KEYEVENTF_KEYUP = 0x0002;
const ushort VK_CONTROL = 0x11;
const ushort VK_V = 0x56;
var inputs = new INPUT[4];
// Ctrl 누름
inputs[0].Type = INPUT_KEYBOARD;
inputs[0].ki.wVk = VK_CONTROL;
// V 누름
inputs[1].Type = INPUT_KEYBOARD;
inputs[1].ki.wVk = VK_V;
// V 뗌
inputs[2].Type = INPUT_KEYBOARD;
inputs[2].ki.wVk = VK_V;
inputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
// Ctrl 뗌
inputs[3].Type = INPUT_KEYBOARD;
inputs[3].ki.wVk = VK_CONTROL;
inputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
}
// ─── P/Invoke ──────────────────────────────────────────────────────────
// Win32 INPUT 구조체 — x64에서 40바이트
// type(4) + 패딩(4) + union은 offset 8에서 시작 (MOUSEINPUT 32바이트가 최대)
[StructLayout(LayoutKind.Explicit, Size = 40)]
private struct INPUT
{
[FieldOffset(0)] public uint Type;
[FieldOffset(8)] public KEYBDINPUT ki;
}
// KEYBDINPUT: 2+2+4+4 = 12, dwExtraInfo(IntPtr)는 8바이트 정렬 → 패딩 포함 24바이트
[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT
{
public ushort wVk;
public ushort wScan;
public uint dwFlags;
public uint time;
public IntPtr dwExtraInfo;
}
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] pInputs, int cbSize);
}

View File

@@ -0,0 +1,311 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다.
///
/// 예: contact → 전체 연락처 목록
/// contact 홍길동 → 이름 검색
/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가
/// contact del 홍길동 → 삭제
/// contact <부서명> → 부서 필터
/// Enter → 이메일 또는 전화번호 클립보드 복사
/// 저장: %APPDATA%\AxCopilot\contacts.json
/// </summary>
public class ContactHandler : IActionHandler
{
public string? Prefix => "contact";
public PluginMetadata Metadata => new(
"연락처",
"로컬 연락처 관리 — 추가 · 검색 · 삭제 · 복사",
"1.0",
"AX");
private static readonly string DataPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "contacts.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private sealed class Contact
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("dept")] public string Dept { get; set; } = "";
[JsonPropertyName("phone")] public string Phone { get; set; } = "";
[JsonPropertyName("email")] public string Email { get; set; } = "";
[JsonPropertyName("memo")] public string Memo { get; set; } = "";
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var contacts = LoadContacts();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"연락처 {contacts.Count}개",
"contact add 이름 부서 010-xxxx-xxxx email@company.com",
null, null, Symbol: "\uE8D4"));
if (contacts.Count == 0)
{
items.Add(new LauncherItem("저장된 연락처가 없습니다",
"contact add 이름 부서 전화 이메일 로 추가하세요",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
foreach (var c in contacts)
items.Add(MakeContactItem(c));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// add 명령
if (sub == "add")
{
var addPart = parts.Length > 1 ? parts[1].Trim() : "";
if (string.IsNullOrWhiteSpace(addPart))
{
items.Add(new LauncherItem("이름을 입력하세요",
"예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var (name, dept, phone, email, memo) = ParseAddArgs(addPart);
if (string.IsNullOrWhiteSpace(name))
{
items.Add(new LauncherItem("이름을 입력하세요",
"예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var encoded = $"{name}|{dept}|{phone}|{email}|{memo}";
items.Add(new LauncherItem(
$"연락처 추가: {name}",
$"{dept} {phone} {email} · Enter로 추가",
null, ("add", encoded), Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// del 명령
if (sub is "del" or "delete" or "remove")
{
var delName = parts.Length > 1 ? parts[1].Trim() : "";
if (string.IsNullOrWhiteSpace(delName))
{
items.Add(new LauncherItem("삭제할 이름을 입력하세요",
"예: contact del 홍길동",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var matches = contacts.Where(c =>
c.Name.Contains(delName, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count == 0)
{
items.Add(new LauncherItem($"'{delName}' 연락처를 찾을 수 없습니다", "",
null, null, Symbol: "\uE783"));
}
else
{
foreach (var c in matches)
items.Add(new LauncherItem(
$"{c.Name} 삭제",
$"{c.Dept} {c.Phone} {c.Email} · Enter로 삭제",
null, ("del", c.Name), Symbol: "\uE8D4"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 검색: 이름, 부서, 메모 포함
var byStartsWith = contacts
.Where(c => c.Name.StartsWith(q, StringComparison.OrdinalIgnoreCase) ||
c.Dept.StartsWith(q, StringComparison.OrdinalIgnoreCase))
.ToList();
var byContains = contacts
.Where(c => !byStartsWith.Contains(c) &&
(c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
c.Dept.Contains(q, StringComparison.OrdinalIgnoreCase) ||
c.Memo.Contains(q, StringComparison.OrdinalIgnoreCase)))
.ToList();
var results = byStartsWith.Concat(byContains).ToList();
if (results.Count > 0)
{
items.Add(new LauncherItem($"'{q}' 검색 결과 {results.Count}개", "",
null, null, Symbol: "\uE8D4"));
foreach (var c in results)
items.Add(MakeContactItem(c));
}
else
{
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
"contact add 로 새 연락처를 추가하세요",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy_email", string email) when !string.IsNullOrWhiteSpace(email):
CopyToClipboard(email);
NotificationService.Notify("연락처", $"{email} 복사");
break;
case ("copy_phone", string phone) when !string.IsNullOrWhiteSpace(phone):
CopyToClipboard(phone);
NotificationService.Notify("연락처", $"{phone} 복사");
break;
case ("copy", string text) when !string.IsNullOrWhiteSpace(text):
CopyToClipboard(text);
NotificationService.Notify("연락처", $"{text} 복사");
break;
case ("add", string encoded):
var addParts = encoded.Split('|');
if (addParts.Length >= 5)
{
var contact = new Contact
{
Name = addParts[0],
Dept = addParts[1],
Phone = addParts[2],
Email = addParts[3],
Memo = addParts[4],
};
var list = LoadContacts();
list.RemoveAll(c => c.Name == contact.Name);
list.Add(contact);
SaveContacts(list);
NotificationService.Notify("연락처", $"{contact.Name} 추가됨");
}
break;
case ("del", string name):
var contacts = LoadContacts();
var removed = contacts.RemoveAll(c => c.Name == name);
if (removed > 0)
{
SaveContacts(contacts);
NotificationService.Notify("연락처", $"{name} 삭제됨");
}
break;
}
return Task.CompletedTask;
}
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
private static List<Contact> LoadContacts()
{
try
{
if (!System.IO.File.Exists(DataPath)) return new List<Contact>();
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
return JsonSerializer.Deserialize<List<Contact>>(json, JsonOpts) ?? new List<Contact>();
}
catch { return new List<Contact>(); }
}
private static void SaveContacts(List<Contact> contacts)
{
try
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
System.IO.File.WriteAllText(DataPath,
JsonSerializer.Serialize(contacts, JsonOpts),
System.Text.Encoding.UTF8);
}
catch { /* 비핵심 */ }
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static LauncherItem MakeContactItem(Contact c)
{
var title = $"{c.Name}";
if (!string.IsNullOrWhiteSpace(c.Dept)) title += $" · {c.Dept}";
var sub = "";
if (!string.IsNullOrWhiteSpace(c.Phone)) sub += c.Phone;
if (!string.IsNullOrWhiteSpace(c.Email)) sub += (sub.Length > 0 ? " | " : "") + c.Email;
// 복사 우선순위: email > phone > name
(string action, string value) data;
if (!string.IsNullOrWhiteSpace(c.Email))
data = ("copy_email", c.Email);
else if (!string.IsNullOrWhiteSpace(c.Phone))
data = ("copy_phone", c.Phone);
else
data = ("copy", c.Name);
return new LauncherItem(title, sub, null, data, Symbol: "\uE8D4");
}
private static (string name, string dept, string phone, string email, string memo)
ParseAddArgs(string addPart)
{
var tokens = addPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0) return ("", "", "", "", "");
var name = tokens[0];
var dept = "";
var phone = "";
var email = "";
var memoTokens = new List<string>();
for (var i = 1; i < tokens.Length; i++)
{
var t = tokens[i];
if (string.IsNullOrWhiteSpace(phone) && IsPhoneLike(t))
phone = t;
else if (string.IsNullOrWhiteSpace(email) && t.Contains('@'))
email = t;
else if (string.IsNullOrWhiteSpace(dept) && i == 1)
dept = t;
else
memoTokens.Add(t);
}
return (name, dept, phone, email, string.Join(" ", memoTokens));
}
private static bool IsPhoneLike(string s)
{
var stripped = s.Replace("-", "").Replace(" ", "");
return stripped.Length >= 9 && stripped.All(c => char.IsDigit(c));
}
private static void CopyToClipboard(string text)
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { }
}
}

View File

@@ -0,0 +1,193 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다.
///
/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다.
/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천
/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천
/// </summary>
public class ContextHandler : IActionHandler
{
public string? Prefix => "ctx";
public PluginMetadata Metadata => new(
"Context",
"컨텍스트 명령 제안 — ctx",
"1.0",
"AX");
// ─── P/Invoke ─────────────────────────────────────────────────────────
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint dwProcessId);
// ─── 컨텍스트 규칙 ─────────────────────────────────────────────────────
// 프로세스 이름 → (앱 표시명, 추천 명령 목록)
private static readonly Dictionary<string[], (string AppName, ContextSuggestion[] Suggestions)> ContextMap
= new(new StringArrayEqualityComparer())
{
{
new[] { "chrome", "msedge", "firefox", "brave", "opera" },
("웹 브라우저", new[]
{
new ContextSuggestion("북마크 검색", "북마크를 검색합니다", "bm", Symbols.Favorite),
new ContextSuggestion("웹 검색", "기본 검색엔진으로 검색합니다", "?", "\uE721"),
new ContextSuggestion("URL 열기", "URL을 런처에서 직접 실행합니다", "url", "\uE71B"),
new ContextSuggestion("클립보드 내용 검색", "클립보드 이력을 검색합니다", "#", "\uE8C8"),
})
},
{
new[] { "code", "devenv", "rider", "idea64", "pycharm64", "webstorm64", "clion64" },
("코드 편집기", new[]
{
new ContextSuggestion("파일 검색", "인덱싱된 파일을 빠르게 엽니다", "", "\uE8A5"),
new ContextSuggestion("스니펫 입력", "텍스트 스니펫을 확장합니다", ";", "\uE8D2"),
new ContextSuggestion("클립보드 이력", "복사한 내용을 검색합니다", "#", "\uE8C8"),
new ContextSuggestion("파일 미리보기", "F3으로 파일 내용을 미리봅니다", "", "\uE7C3"),
new ContextSuggestion("QuickLook 편집","파일 인라인 편집 (Ctrl+E)", "", "\uE70F"),
})
},
{
new[] { "excel", "powerpnt", "winword", "onenote", "outlook", "hwp", "hwpx" },
("오피스", new[]
{
new ContextSuggestion("클립보드 이력", "복사한 셀·텍스트를 재사용합니다", "#", "\uE8C8"),
new ContextSuggestion("계산기", "수식을 빠르게 계산합니다", "=", "\uE8EF"),
new ContextSuggestion("날짜 계산", "날짜·기간을 계산합니다", "=today", "\uE787"),
new ContextSuggestion("파일 검색", "문서 파일을 빠르게 찾습니다", "", "\uE8A5"),
new ContextSuggestion("스니펫", "자주 쓰는 문구를 입력합니다", ";", "\uE8D2"),
})
},
{
new[] { "explorer" },
("파일 탐색기", new[]
{
new ContextSuggestion("파일 태그 검색", "태그로 파일을 찾습니다", "tag", "\uE932"),
new ContextSuggestion("배치 이름변경", "여러 파일을 한번에 이름변경합니다", "batchren", "\uE8AC"),
new ContextSuggestion("파일 미리보기", "선택 파일을 미리봅니다 (F3)", "", "\uE7C3"),
new ContextSuggestion("폴더 즐겨찾기", "즐겨찾기 폴더를 엽니다", "fav", Symbols.Favorite),
})
},
{
new[] { "slack", "teams", "zoom", "msteams" },
("커뮤니케이션", new[]
{
new ContextSuggestion("클립보드 이력", "공유할 내용을 클립보드에서 선택", "#", "\uE8C8"),
new ContextSuggestion("스크린 캡처", "화면을 캡처해 공유합니다", "cap", "\uE722"),
new ContextSuggestion("스니펫", "자주 쓰는 답변 텍스트를 입력합니다",";", "\uE8D2"),
new ContextSuggestion("번역", "텍스트를 번역합니다", "! 번역", "\uF2B7"),
})
},
};
// ─── 기본 제안 (앱 미인식) ────────────────────────────────────────────
private static readonly ContextSuggestion[] DefaultSuggestions =
{
new("파일/앱 검색", "이름으로 파일·앱을 빠르게 검색", "", "\uE721"),
new("클립보드 이력", "최근 복사한 내용 검색", "#", "\uE8C8"),
new("계산기", "수식 계산", "=", "\uE8EF"),
new("스니펫", "텍스트 스니펫 확장", ";", "\uE8D2"),
new("스케줄러", "자동화 스케줄 목록", "sched","\uE916"),
};
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var (procName, appDisplayName, suggestions) = GetContextInfo();
var items = new List<LauncherItem>();
var headerSub = string.IsNullOrEmpty(procName)
? "현재 포그라운드 앱을 인식할 수 없습니다"
: $"현재 앱: {appDisplayName} ({procName}) · 상황별 명령 제안";
items.Add(new LauncherItem(
"컨텍스트 제안",
headerSub,
null, null,
Symbol: "\uE945"));
var q = query.Trim().ToLowerInvariant();
foreach (var s in suggestions)
{
if (!string.IsNullOrEmpty(q) &&
!s.Title.Contains(q, StringComparison.OrdinalIgnoreCase) &&
!s.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase))
continue;
var subtitle = string.IsNullOrEmpty(s.Prefix)
? s.Subtitle
: $"{s.Subtitle} · 프리픽스: [{s.Prefix}]";
items.Add(new LauncherItem(
s.Title, subtitle, null, s.Prefix, Symbol: s.Symbol));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 제안 항목 실행 → 런처 입력창에 프리픽스 삽입
// LauncherWindow가 SetInputText를 지원하므로 Application 수준에서 접근
if (item.Data is string prefix && !string.IsNullOrEmpty(prefix))
{
var launcher = System.Windows.Application.Current?.Windows
.OfType<Views.LauncherWindow>()
.FirstOrDefault();
launcher?.SetInputText(prefix + " ");
}
return Task.CompletedTask;
}
// ─── 내부 유틸 ────────────────────────────────────────────────────────
private static (string ProcName, string AppName, ContextSuggestion[] Suggestions) GetContextInfo()
{
try
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return ("", "", DefaultSuggestions);
GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return ("", "", DefaultSuggestions);
var proc = Process.GetProcessById((int)pid);
var pName = proc.ProcessName.ToLowerInvariant();
foreach (var kv in ContextMap)
{
if (kv.Key.Any(k => pName.Contains(k)))
return (pName, kv.Value.AppName, kv.Value.Suggestions);
}
return (pName, pName, DefaultSuggestions);
}
catch
{
return ("", "", DefaultSuggestions);
}
}
// ─── 제안 레코드 ─────────────────────────────────────────────────────
private record ContextSuggestion(string Title, string Subtitle, string Prefix, string Symbol);
// ─── 키 비교기 ───────────────────────────────────────────────────────
private class StringArrayEqualityComparer : IEqualityComparer<string[]>
{
public bool Equals(string[]? x, string[]? y) =>
x != null && y != null && x.SequenceEqual(y);
public int GetHashCode(string[] obj) =>
obj.Aggregate(17, (h, s) => h * 31 + s.GetHashCode());
}
}

View File

@@ -0,0 +1,340 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-3: Cron 표현식 설명기 핸들러. "cron" 프리픽스로 사용합니다.
///
/// 예: cron * * * * * → 매 분 실행 (설명 + 다음 5회 실행 시간)
/// cron 0 9 * * 1-5 → 평일 오전 9시 실행
/// cron 0 0 1 * * → 매월 1일 자정
/// cron 30 18 * * 5 → 매주 금요일 오후 6시 30분
/// cron @daily → 매일 자정 (특수 키워드)
/// cron @hourly → 매시간
/// Enter → 표현식을 클립보드에 복사.
///
/// 지원 형식: 분(0-59) 시(0-23) 일(1-31) 월(1-12) 요일(0-7)
/// </summary>
public class CronHandler : IActionHandler
{
public string? Prefix => "cron";
public PluginMetadata Metadata => new(
"Cron",
"Cron 표현식 설명기 — 다음 실행 시간 · 한국어 설명",
"1.0",
"AX");
// 특수 키워드
private static readonly Dictionary<string, string> SpecialKeywords = new(StringComparer.OrdinalIgnoreCase)
{
["@yearly"] = "0 0 1 1 *",
["@annually"] = "0 0 1 1 *",
["@monthly"] = "0 0 1 * *",
["@weekly"] = "0 0 * * 0",
["@daily"] = "0 0 * * *",
["@midnight"] = "0 0 * * *",
["@hourly"] = "0 * * * *",
};
// 자주 쓰는 예제
private static readonly (string Expr, string Desc)[] CommonExamples =
[
("* * * * *", "매 분 실행"),
("0 * * * *", "매 시간 정각"),
("0 9 * * *", "매일 오전 9시"),
("0 9 * * 1-5", "평일 오전 9시"),
("0 0 * * *", "매일 자정"),
("0 0 1 * *", "매월 1일 자정"),
("0 0 1 1 *", "매년 1월 1일"),
("*/5 * * * *", "5분마다"),
("0 9,18 * * 1-5", "평일 오전 9시·오후 6시"),
("30 23 * * 5", "매주 금요일 오후 11시 30분"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("Cron 표현식 설명기",
"예: cron 0 9 * * 1-5 / cron @daily / cron */15 * * * *",
null, null, Symbol: "\uE823"));
foreach (var (expr, desc) in CommonExamples.Take(6))
items.Add(new LauncherItem(expr, desc, null, ("copy", expr), Symbol: "\uE823"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 특수 키워드 처리
var expr_ = q;
if (SpecialKeywords.TryGetValue(q, out var expanded))
expr_ = expanded;
if (!TryParseCron(expr_, out var cron))
{
items.Add(new LauncherItem("파싱 실패",
$"'{q}'은 유효한 cron 표현식이 아닙니다. 형식: 분 시 일 월 요일",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 한국어 설명
var description = Describe(cron);
items.Add(new LauncherItem(
description,
$"표현식: {expr_} · Enter 복사",
null, ("copy", expr_), Symbol: "\uE823"));
// 다음 5회 실행 시간
var nextRuns = GetNextRuns(cron, DateTime.Now, 5);
if (nextRuns.Count > 0)
{
items.Add(new LauncherItem("─ 다음 실행 시간 ─", "", null, null, Symbol: "\uE823"));
foreach (var run in nextRuns)
items.Add(new LauncherItem(
run.ToString("yyyy-MM-dd HH:mm (ddd)"),
GetRelativeTime(run),
null, ("copy", run.ToString("yyyy-MM-dd HH:mm:ss")),
Symbol: "\uE823"));
}
// 필드별 설명
items.Add(new LauncherItem("─ 필드 분석 ─", "", null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("분", DescribeField(cron.Minute, 0, 59, "분"), null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("시", DescribeField(cron.Hour, 0, 23, "시"), null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("일", DescribeField(cron.Day, 1, 31, "일"), null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("월", DescribeField(cron.Month, 1, 12, "월"), null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("요일", DescribeField(cron.DayOfWeek, 0, 7, "요일"), null, null, Symbol: "\uE823"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Cron", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── Cron 파서 ─────────────────────────────────────────────────────────────
private record CronExpr(string Minute, string Hour, string Day, string Month, string DayOfWeek);
private static bool TryParseCron(string expr, out CronExpr result)
{
result = new CronExpr("*", "*", "*", "*", "*");
var parts = expr.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5) return false;
// 각 필드 유효성 검사
if (!IsValidCronField(parts[0], 0, 59)) return false;
if (!IsValidCronField(parts[1], 0, 23)) return false;
if (!IsValidCronField(parts[2], 1, 31)) return false;
if (!IsValidCronField(parts[3], 1, 12)) return false;
if (!IsValidCronField(parts[4], 0, 7)) return false;
result = new CronExpr(parts[0], parts[1], parts[2], parts[3], parts[4]);
return true;
}
private static bool IsValidCronField(string field, int min, int max)
{
if (field == "*") return true;
foreach (var part in field.Split(','))
{
if (part.Contains('/'))
{
var sp = part.Split('/');
if (sp.Length != 2) return false;
if (sp[0] != "*" && !int.TryParse(sp[0], out _)) return false;
if (!int.TryParse(sp[1], out var step) || step < 1) return false;
}
else if (part.Contains('-'))
{
var sp = part.Split('-');
if (sp.Length != 2) return false;
if (!int.TryParse(sp[0], out var a) || !int.TryParse(sp[1], out var b)) return false;
if (a < min || b > max || a > b) return false;
}
else
{
if (!int.TryParse(part, out var v)) return false;
if (v < min || v > max) return false;
}
}
return true;
}
// ── 다음 실행 시간 계산 ────────────────────────────────────────────────────
private static List<DateTime> GetNextRuns(CronExpr cron, DateTime from, int count)
{
var results = new List<DateTime>();
// 다음 분부터 시작
var current = from.AddSeconds(-from.Second).AddMinutes(1);
var limit = from.AddDays(366); // 최대 1년 탐색
while (results.Count < count && current < limit)
{
if (MatchesMonth(cron.Month, current.Month) &&
MatchesDay(cron.Day, current.Day) &&
MatchesDayOfWeek(cron.DayOfWeek, (int)current.DayOfWeek) &&
MatchesHour(cron.Hour, current.Hour) &&
MatchesMinute(cron.Minute, current.Minute))
{
results.Add(current);
current = current.AddMinutes(1);
}
else
{
current = AdvanceCron(cron, current);
}
}
return results;
}
private static DateTime AdvanceCron(CronExpr cron, DateTime dt)
{
// 빠른 스킵: 분 단위로 증가
return dt.AddMinutes(1);
}
private static bool MatchesField(string field, int value, int min, int max)
{
if (field == "*") return true;
foreach (var part in field.Split(','))
{
if (part.Contains('/'))
{
var sp = part.Split('/');
var step = int.Parse(sp[1]);
var start = sp[0] == "*" ? min : int.Parse(sp[0]);
for (var v = start; v <= max; v += step)
if (v == value) return true;
}
else if (part.Contains('-'))
{
var sp = part.Split('-');
var a = int.Parse(sp[0]);
var b = int.Parse(sp[1]);
if (value >= a && value <= b) return true;
}
else
{
if (int.Parse(part) == value) return true;
}
}
return false;
}
private static bool MatchesMinute(string f, int v) => MatchesField(f, v, 0, 59);
private static bool MatchesHour(string f, int v) => MatchesField(f, v, 0, 23);
private static bool MatchesDay(string f, int v) => MatchesField(f, v, 1, 31);
private static bool MatchesMonth(string f, int v) => MatchesField(f, v, 1, 12);
private static bool MatchesDayOfWeek(string f, int v)
{
// 0과 7 모두 일요일
if (f == "*") return true;
return MatchesField(f, v, 0, 7) || (v == 0 && MatchesField(f, 7, 0, 7));
}
// ── 한국어 설명 ───────────────────────────────────────────────────────────
private static string Describe(CronExpr c)
{
var parts = new List<string>();
// 분
var minDesc = c.Minute == "*" ? "매 분" : DescribeField(c.Minute, 0, 59, "분");
// 시
var hourDesc = c.Hour == "*" ? "매 시간" : DescribeField(c.Hour, 0, 23, "시");
// 일
var dayDesc = c.Day == "*" ? "" : DescribeField(c.Day, 1, 31, "일");
// 월
var monDesc = c.Month == "*" ? "" : DescribeField(c.Month, 1, 12, "월");
// 요일
var dowDesc = c.DayOfWeek == "*" ? "" : DescribeWeekday(c.DayOfWeek);
if (c.Minute == "0" && c.Hour == "0" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*")
return "매일 자정(00:00)";
if (c.Minute == "0" && c.Hour == "*")
return "매 시간 정각";
if (c.Minute == "*" && c.Hour == "*" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*")
return "매 분 실행";
// 조합
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(monDesc)) { sb.Append(monDesc); sb.Append(' '); }
if (!string.IsNullOrEmpty(dayDesc)) { sb.Append(dayDesc); sb.Append(' '); }
if (!string.IsNullOrEmpty(dowDesc)) { sb.Append(dowDesc); sb.Append(' '); }
sb.Append(hourDesc);
sb.Append(' ');
sb.Append(minDesc);
sb.Append(" 실행");
return sb.ToString().Trim();
}
private static string DescribeField(string field, int min, int max, string unit)
{
if (field == "*") return $"모든 {unit}";
if (field.StartsWith("*/"))
{
var step = field[2..];
return $"{step}{unit}마다";
}
if (field.Contains('-'))
{
var sp = field.Split('-');
return $"{sp[0]}~{sp[1]}{unit}";
}
if (field.Contains(','))
{
return string.Join(",", field.Split(',')) + unit;
}
return $"{field}{unit}";
}
private static string DescribeWeekday(string field)
{
string[] days = ["일", "월", "화", "수", "목", "금", "토", "일"];
if (field.Contains('-'))
{
var sp = field.Split('-');
if (int.TryParse(sp[0], out var a) && int.TryParse(sp[1], out var b))
return $"{days[a]}~{days[Math.Min(b, 7)]}요일";
}
if (field.Contains(','))
{
var parts = field.Split(',')
.Where(p => int.TryParse(p, out _))
.Select(p => days[int.Parse(p) % 8]);
return string.Join(",", parts) + "요일";
}
if (int.TryParse(field, out var d))
return days[d % 8] + "요일";
return field;
}
private static string GetRelativeTime(DateTime dt)
{
var diff = dt - DateTime.Now;
if (diff.TotalMinutes < 1) return "1분 이내";
if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 후";
if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 {diff.Minutes}분 후";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 {diff.Hours}시간 후";
return $"{(int)diff.TotalDays}일 후";
}
}

View File

@@ -0,0 +1,338 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다.
///
/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수)
/// csv col 2 → 2번째 컬럼 값 목록 추출
/// csv row 3 → 3번째 행 출력
/// csv stats → 숫자 컬럼 합계·평균·최대·최소
/// csv head → 헤더 컬럼명 목록
/// csv tsv → CSV → TSV(탭 구분) 변환
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class CsvHandler : IActionHandler
{
public string? Prefix => "csv";
public PluginMetadata Metadata => new(
"CSV",
"CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var clip = GetClipboard();
var hasData = !string.IsNullOrWhiteSpace(clip) && LooksCsv(clip);
if (string.IsNullOrWhiteSpace(q))
{
if (hasData)
{
items.AddRange(BuildOverviewItems(clip));
}
else
{
items.Add(new LauncherItem("CSV 뷰어", "CSV를 클립보드에 복사 후 'csv' 입력",
null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("csv col 2", "2번째 컬럼 추출", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("csv stats", "숫자 컬럼 통계", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("csv tsv", "CSV → TSV 변환", null, null, Symbol: "\uE8A5"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
if (!hasData)
{
items.Add(new LauncherItem("클립보드에 CSV 없음",
"CSV 형식 데이터를 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var rows = ParseCsv(clip);
if (rows.Count == 0)
{
items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (sub)
{
case "col":
case "column":
{
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
items.AddRange(ExtractColumn(rows, colIdx));
break;
}
case "row":
{
var rowIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
items.AddRange(ExtractRow(rows, rowIdx));
break;
}
case "stats":
case "stat":
{
items.AddRange(BuildStatsItems(rows));
break;
}
case "head":
case "header":
{
if (rows.Count > 0)
{
var header = rows[0];
var all = string.Join(", ", header);
items.Add(new LauncherItem($"헤더 {header.Count}개 컬럼", all, null, ("copy", all), Symbol: "\uE8A5"));
for (var i = 0; i < header.Count; i++)
items.Add(new LauncherItem($"[{i+1}] {header[i]}", $"컬럼 {i+1}", null, ("copy", header[i]), Symbol: "\uE8A5"));
}
break;
}
case "tsv":
{
var tsv = ConvertToTsv(rows);
items.Add(new LauncherItem(
$"TSV 변환 {rows.Count}행",
"탭 구분자 · Enter 복사",
null, ("copy", tsv), Symbol: "\uE8A5"));
break;
}
default:
items.AddRange(BuildOverviewItems(clip));
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("CSV", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 빌더 ─────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildOverviewItems(string csv)
{
var rows = ParseCsv(csv);
if (rows.Count == 0)
{
yield return new LauncherItem("파싱 실패", "유효한 CSV 데이터가 아닙니다", null, null, Symbol: "\uE783");
yield break;
}
var headerRow = rows[0];
var dataRows = rows.Count > 1 ? rows.Count - 1 : 0;
var colCount = headerRow.Count;
yield return new LauncherItem(
$"CSV {dataRows}행 × {colCount}열",
$"헤더: {string.Join(", ", headerRow.Take(5))}{(colCount > 5 ? " " : "")}",
null,
("copy", csv),
Symbol: "\uE8A5");
yield return new LauncherItem("컬럼 수", $"{colCount}개", null, null, Symbol: "\uE8A5");
yield return new LauncherItem("데이터 행수", $"{dataRows}행", null, null, Symbol: "\uE8A5");
yield return new LauncherItem("헤더", string.Join(" | ", headerRow.Take(8)), null,
("copy", string.Join(",", headerRow)), Symbol: "\uE8A5");
// 첫 데이터 행 미리보기
if (rows.Count > 1)
{
var first = rows[1];
yield return new LauncherItem(
"첫 번째 행",
string.Join(" | ", first.Take(6)),
null,
("copy", string.Join(",", first)),
Symbol: "\uE8A5");
}
}
private static IEnumerable<LauncherItem> ExtractColumn(List<List<string>> rows, int colIdx)
{
if (rows.Count == 0)
{
yield return new LauncherItem("데이터 없음", "", null, null, Symbol: "\uE783");
yield break;
}
var maxCol = rows.Max(r => r.Count) - 1;
colIdx = Math.Clamp(colIdx, 0, maxCol);
var header = rows[0].Count > colIdx ? rows[0][colIdx] : $"컬럼{colIdx+1}";
var values = rows.Skip(1).Where(r => r.Count > colIdx).Select(r => r[colIdx]).ToList();
var allText = string.Join("\n", values);
yield return new LauncherItem(
$"[{colIdx+1}] {header} ({values.Count}개 값)",
"전체 복사: Enter",
null, ("copy", allText), Symbol: "\uE8A5");
foreach (var v in values.Take(15))
yield return new LauncherItem(v, $"컬럼: {header}", null, ("copy", v), Symbol: "\uE8A5");
}
private static IEnumerable<LauncherItem> ExtractRow(List<List<string>> rows, int rowIdx)
{
rowIdx = Math.Clamp(rowIdx, 0, rows.Count - 1);
var row = rows[rowIdx];
var header = rows[0];
var allText = string.Join(",", row);
yield return new LauncherItem(
$"행 {rowIdx+1} ({row.Count}개 값)",
allText.Length > 80 ? allText[..80] + "…" : allText,
null, ("copy", allText), Symbol: "\uE8A5");
for (var i = 0; i < row.Count; i++)
{
var colName = header.Count > i ? header[i] : $"컬럼{i+1}";
yield return new LauncherItem($"[{colName}]", row[i], null, ("copy", row[i]), Symbol: "\uE8A5");
}
}
private static IEnumerable<LauncherItem> BuildStatsItems(List<List<string>> rows)
{
if (rows.Count < 2)
{
yield return new LauncherItem("데이터 없음", "헤더 포함 2행 이상 필요", null, null, Symbol: "\uE783");
yield break;
}
var header = rows[0];
var data = rows.Skip(1).ToList();
for (var col = 0; col < header.Count; col++)
{
var colName = header.Count > col ? header[col] : $"컬럼{col+1}";
var nums = data
.Where(r => r.Count > col)
.Select(r => r[col])
.Where(v => double.TryParse(v, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out _))
.Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture))
.ToList();
if (nums.Count == 0) continue;
var sum = nums.Sum();
var avg = nums.Average();
var min = nums.Min();
var max = nums.Max();
yield return new LauncherItem(
$"[{colName}] 합계: {sum:N2}",
$"평균: {avg:N2} 최소: {min:N2} 최대: {max:N2} ({nums.Count}개)",
null,
("copy", $"{colName}: sum={sum:N2} avg={avg:N2} min={min:N2} max={max:N2}"),
Symbol: "\uE8A5");
}
}
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
private static string ConvertToTsv(List<List<string>> rows)
{
var sb = new StringBuilder();
foreach (var row in rows)
{
sb.AppendLine(string.Join("\t", row.Select(EscapeTsv)));
}
return sb.ToString().TrimEnd();
}
private static string EscapeTsv(string v) => v.Replace("\t", " ").Replace("\r", "").Replace("\n", " ");
// ── CSV 파서 ─────────────────────────────────────────────────────────────
private static List<List<string>> ParseCsv(string text)
{
var result = new List<List<string>>();
// 구분자 자동 감지 (탭 또는 쉼표)
var firstLine = text.Split('\n')[0];
var delimiter = firstLine.Count(c => c == '\t') > firstLine.Count(c => c == ',') ? '\t' : ',';
using var reader = new System.IO.StringReader(text);
string? line;
while ((line = reader.ReadLine()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
result.Add(ParseCsvLine(line, delimiter));
}
return result;
}
private static List<string> ParseCsvLine(string line, char delim)
{
var fields = new List<string>();
var sb = new StringBuilder();
var inQuote = false;
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuote)
{
if (c == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{ sb.Append('"'); i++; }
else
{ inQuote = false; }
}
else { sb.Append(c); }
}
else
{
if (c == '"') { inQuote = true; }
else if (c == delim){ fields.Add(sb.ToString()); sb.Clear(); }
else { sb.Append(c); }
}
}
fields.Add(sb.ToString());
return fields;
}
private static bool LooksCsv(string text)
{
var firstLine = text.Split('\n').FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)) ?? "";
return firstLine.Contains(',') || firstLine.Contains('\t');
}
private static string GetClipboard()
{
try
{
return System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
}
catch { return ""; }
}
}

View File

@@ -0,0 +1,213 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L15-2: 환율 변환기 핸들러. "currency" 프리픽스로 사용합니다.
///
/// 예: currency → 주요 통화 기준환율 목록
/// currency 100 usd → 100 USD → KRW
/// currency 100 usd eur → 100 USD → EUR
/// currency 50000 krw usd → 50,000 KRW → USD
/// currency rates → 전체 환율표
/// Enter → 결과를 클립보드에 복사.
///
/// 내장 기준환율 사용 (사내 모드). 사외 모드에서는 동일하게 동작.
/// </summary>
public class CurrencyHandler : IActionHandler
{
public string? Prefix => "currency";
public PluginMetadata Metadata => new(
"Currency",
"환율 변환기 — KRW·USD·EUR·JPY·CNY 등 주요 통화 변환",
"1.0",
"AX");
// 내장 기준환율 (KRW 기준, 2025년 1분기 평균 참조값)
private static readonly Dictionary<string, (string Name, string Symbol, double RateToKrw)> Rates =
new(StringComparer.OrdinalIgnoreCase)
{
["KRW"] = ("한국 원", "₩", 1.0),
["USD"] = ("미국 달러", "$", 1370.0),
["EUR"] = ("유로", "€", 1480.0),
["JPY"] = ("일본 엔", "¥", 9.2),
["CNY"] = ("중국 위안", "¥", 189.0),
["GBP"] = ("영국 파운드", "£", 1730.0),
["HKD"] = ("홍콩 달러", "HK$",175.0),
["TWD"] = ("대만 달러", "NT$", 42.0),
["SGD"] = ("싱가포르 달러", "S$", 1020.0),
["AUD"] = ("호주 달러", "A$", 870.0),
["CAD"] = ("캐나다 달러", "C$", 995.0),
["CHF"] = ("스위스 프랑", "Fr", 1540.0),
["MYR"] = ("말레이시아 링깃", "RM", 310.0),
["THB"] = ("태국 바트", "฿", 38.5),
["VND"] = ("베트남 동", "₫", 0.054),
};
// 통화 별칭
private static readonly Dictionary<string, string> Aliases =
new(StringComparer.OrdinalIgnoreCase)
{
["달러"] = "USD", ["엔"] = "JPY", ["위안"] = "CNY", ["유로"] = "EUR",
["파운드"] = "GBP", ["원"] = "KRW", ["엔화"] = "JPY", ["달러화"] = "USD",
["프랑"] = "CHF", ["바트"] = "THB", ["동"] = "VND", ["링깃"] = "MYR",
};
// 주요 통화 표시 순서
private static readonly string[] MainCurrencies = ["USD", "EUR", "JPY", "CNY", "GBP", "HKD", "SGD", "AUD"];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("환율 변환기",
"예: currency 100 usd / currency 50000 krw eur / currency rates",
null, null, Symbol: "\uE8C7"));
items.Add(new LauncherItem("── 주요 통화 (KRW 기준) ──", "", null, null, Symbol: "\uE8C7"));
foreach (var code in MainCurrencies)
{
if (!Rates.TryGetValue(code, out var info)) continue;
items.Add(new LauncherItem(
$"1 {code} = {info.RateToKrw:N0} KRW",
$"{info.Name} ({info.Symbol})",
null, ("copy", $"1 {code} = {info.RateToKrw:N0} KRW"),
Symbol: "\uE8C7"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// rates → 전체 환율표
if (parts[0].Equals("rates", StringComparison.OrdinalIgnoreCase) ||
parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem($"전체 환율표 ({Rates.Count}개 통화)", "KRW 기준 내장 환율",
null, null, Symbol: "\uE8C7"));
foreach (var (code, info) in Rates.OrderBy(r => r.Key))
{
items.Add(new LauncherItem(
$"1 {code} = {info.RateToKrw:N2} KRW",
$"{info.Symbol} {info.Name}",
null, ("copy", $"1 {code} = {info.RateToKrw:N2} KRW"),
Symbol: "\uE8C7"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 금액 파싱
if (!TryParseAmount(parts[0], out var amount))
{
items.Add(new LauncherItem("금액 형식 오류",
"예: currency 100 usd", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 통화 코드 파싱
var fromCode = parts.Length >= 2 ? ResolveCode(parts[1]) : "KRW";
var toCode = parts.Length >= 3 ? ResolveCode(parts[2]) : null;
if (fromCode == null)
{
items.Add(new LauncherItem("알 수 없는 통화",
$"'{parts[1]}' 코드를 찾을 수 없습니다. 예: USD EUR JPY CNY",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (!Rates.TryGetValue(fromCode, out var fromInfo))
{
items.Add(new LauncherItem("지원하지 않는 통화", fromCode, null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var amountKrw = amount * fromInfo.RateToKrw;
// 특정 대상 통화 지정
if (toCode != null)
{
if (!Rates.TryGetValue(toCode, out var toInfo))
{
items.Add(new LauncherItem("지원하지 않는 대상 통화", toCode, null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var converted = amountKrw / toInfo.RateToKrw;
var label = $"{FormatAmount(amount, fromCode)} = {FormatAmount(converted, toCode)}";
items.Add(new LauncherItem(label,
$"{fromInfo.Name} → {toInfo.Name} (Enter 복사)",
null, ("copy", label), Symbol: "\uE8C7"));
items.Add(new LauncherItem($"{FormatAmount(converted, toCode)}", $"변환 결과",
null, ("copy", $"{converted:N2}"), Symbol: "\uE8C7"));
}
else
{
// 주요 통화들로 일괄 변환
items.Add(new LauncherItem(
$"{FormatAmount(amount, fromCode)} 변환 결과",
"주요 통화 기준 (내장 환율)",
null, null, Symbol: "\uE8C7"));
var targets = fromCode == "KRW" ? MainCurrencies : (new[] { "KRW" }).Concat(MainCurrencies.Where(c => c != fromCode)).ToArray();
foreach (var tc in targets)
{
if (!Rates.TryGetValue(tc, out var tInfo)) continue;
var conv = amountKrw / tInfo.RateToKrw;
var label = $"{FormatAmount(conv, tc)}";
items.Add(new LauncherItem(label,
$"{tInfo.Name} ({tInfo.Symbol})",
null, ("copy", label), Symbol: "\uE8C7"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Currency", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string? ResolveCode(string input)
{
if (Rates.ContainsKey(input)) return input.ToUpperInvariant();
if (Aliases.TryGetValue(input, out var code)) return code;
return null;
}
private static bool TryParseAmount(string s, out double result)
{
result = 0;
s = s.Replace(",", "").Trim();
return double.TryParse(s, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out result);
}
private static string FormatAmount(double amount, string code)
{
if (!Rates.TryGetValue(code, out var info)) return $"{amount:N2} {code}";
// 소수점 자릿수: JPY/KRW/VND = 0, 기타 = 2
var decimals = code is "JPY" or "KRW" or "VND" ? 0 : 2;
var fmt = decimals == 0 ? $"{amount:N0}" : $"{amount:N2}";
return $"{info.Symbol}{fmt} {code}";
}
}

View File

@@ -0,0 +1,201 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L29-1: 오프라인 국어사전 핸들러. "dict" 프리픽스로 사용합니다.
///
/// 예: dict → 사용법 안내
/// dict 부럽다 → "부럽다" 검색 (표제어·뜻풀이)
/// dict en hello → 영한 사전 검색
/// dict 유의어 돕다 → 유의어 검색
/// Enter → 뜻풀이 클립보드 복사.
/// 내장 데이터 기반 (인터넷 불필요). 혼동어·유의어·반의어 중심.
/// </summary>
public class DictHandler : IActionHandler
{
public string? Prefix => "dict";
public PluginMetadata Metadata => new(
"국어사전",
"오프라인 국어·영한 사전 — 뜻풀이·유의어·혼동어",
"1.0",
"AX");
// ─── 내장 국어 사전 데이터 ────────────────────────────────────────────────
private sealed record KorEntry(string Word, string Pos, string Def, string? Synonym = null, string? Antonym = null, string? Note = null);
private static readonly KorEntry[] KorDict =
[
// ── 혼동어 ────────────────────────────────────────────────────────────
new("가르치다", "동사", "지식·기술을 알려 주다", Note: "'가리키다'와 혼동 주의. 가리키다=손가락으로 방향을 보여 주다"),
new("가리키다", "동사", "손가락 등으로 방향이나 대상을 보여 주다", Note: "'가르치다'와 혼동 주의"),
new("다르다", "형용사", "비교 대상이 같지 아니하다", Synonym: "상이하다", Antonym: "같다", Note: "'틀리다'와 혼동 주의. 틀리다=옳지 않다"),
new("틀리다", "동사", "사실이나 이치에 맞지 아니하다, 잘못되다", Synonym: "그르다", Antonym: "맞다", Note: "'다르다'와 혼동 주의"),
new("바라다", "동사", "생각대로 되기를 기대하다", Note: "'바래다(햇볕에 색이 변하다)'와 혼동 주의"),
new("바래다", "동사", "1. 햇볕·비에 색이 변하다 2. 어떤 곳까지 배웅하다"),
new("늘이다", "동사", "길이를 길게 하다 (고무줄을 늘이다)", Note: "'늘리다'와 혼동 주의. 늘리다=수량·범위를 크게 하다"),
new("늘리다", "동사", "수량·범위·세력을 크게 하다 (매출을 늘리다)", Note: "'늘이다'와 혼동 주의. 늘이다=길이를 길게 하다"),
new("부치다", "동사", "1. 편지를 보내다 2. 부침개를 만들다 3. 세금을 매기다"),
new("붙이다", "동사", "1. 떨어지지 않게 접합하다 2. 이름을 정하다 3. 조건을 달다"),
new("맞추다", "동사", "1. 서로 대어 비교하다 2. 정답을 알아 내다", Note: "'맞히다(맞다의 사동사)'와 혼동 주의"),
new("맞히다", "동사", "'맞다'의 사동사. 1. 적중시키다 2. 정답을 고르게 하다"),
new("받치다", "동사", "1. 밑에서 떠받치다 2. 감정이 치밀다", Note: "'바치다(드리다)'와 혼동 주의"),
new("바치다", "동사", "웃어른에게 물건이나 정성을 드리다"),
new("반듯이", "부사", "모양이 기울거나 비뚤어지지 않게", Note: "'반드시'와 혼동 주의"),
new("반드시", "부사", "틀림없이, 꼭", Synonym: "기필코, 필히"),
new("어이없다", "형용사", "뜻밖에 기가 막혀 말이 나오지 않다", Note: "'어의(御醫)없다'가 아님"),
new("설레다", "동사", "마음이 가라앉지 아니하고 들뜨다", Note: "'설레이다'는 비표준"),
new("깨끗이", "부사", "깨끗하게", Note: "'-이'로 적음 (깨끗히 ×)"),
new("일찍이", "부사", "일찍", Note: "'-이'로 적음 (일찍히 ×)"),
// ── 업무 용어 ─────────────────────────────────────────────────────────
new("품의", "명사", "윗사람에게 어떤 일의 처리를 의논하여 품함"),
new("결재", "명사", "윗사람이 아랫사람이 제출한 안건을 승인함", Note: "'결제(대금 지불)'와 혼동 주의"),
new("결제", "명사", "대금을 주고받아 거래를 끝맺음", Note: "'결재(승인)'와 혼동 주의"),
new("수립", "명사", "계획이나 정책을 세움", Synonym: "설정, 설립"),
new("이관", "명사", "관할이나 담당을 다른 곳으로 옮김"),
new("선결", "명사", "다른 것보다 앞서 먼저 해결함"),
new("귀결", "명사", "어떤 결론에 다다름"),
new("전결", "명사", "위임을 받아 대신 결재함"),
new("대조", "명사", "둘 이상을 맞대어 비교함", Synonym: "비교"),
new("취합", "명사", "여러 곳의 자료를 모아서 합침", Synonym: "수합"),
new("회람", "명사", "문서를 여러 사람이 돌려 봄"),
new("시달", "명사", "상급 기관이 하급 기관에 지시를 내림"),
new("협조", "명사", "힘을 합하여 도움", Synonym: "협력"),
new("검토", "명사", "자세히 살펴서 따져 봄", Synonym: "심사, 분석"),
new("조율", "명사", "서로 다른 의견을 맞추어 조정함", Synonym: "조정"),
new("후속", "명사", "앞에 한 일의 뒤를 이음"),
new("단축", "명사", "시간·거리를 줄임", Antonym: "연장"),
new("지양", "명사", "바람직하지 않은 것을 피하거나 그만둠", Note: "'지향(나아감)'과 혼동 주의"),
new("지향", "명사", "목표를 향하여 나아감", Note: "'지양(피함)'과 혼동 주의"),
// ── 자주 헷갈리는 한자어 ──────────────────────────────────────────────
new("이상", "명사", "1. 보통 정도를 넘어선 상태 (以上) 2. 생각하는 것 중 가장 완전한 상태 (理想)"),
new("이하", "명사", "기준이 되는 정도에 미치지 못하는 상태", Antonym: "이상(以上)"),
new("과반", "명사", "절반을 넘는 수"),
new("역할", "명사", "자기가 마땅히 하여야 할 임무나 직책", Note: "'역활'은 비표준"),
new("요지", "명사", "중요한 내용의 핵심", Synonym: "골자, 핵심"),
new("명시", "명사", "분명하게 드러내어 보임"),
new("고지", "명사", "1. 알려 줌 2. 높은 곳의 땅"),
new("기한", "명사", "미리 정해 놓은 시한", Synonym: "시한, 마감"),
];
// ─── 내장 영한 사전 데이터 (업무 필수 영어) ────────────────────────────────
private sealed record EngEntry(string Word, string Pos, string Kor, string? Example = null);
private static readonly EngEntry[] EngDict =
[
new("agenda", "명사", "의제, 안건", "Let's move to the next agenda item."),
new("align", "동사", "맞추다, 조율하다", "We need to align our goals."),
new("approve", "동사", "승인하다", "The manager approved the budget."),
new("assign", "동사", "배정하다, 할당하다", "I'll assign this task to you."),
new("brief", "동사", "간략히 보고하다", "Can you brief me on the project?"),
new("deadline", "명사", "마감일, 기한", "The deadline is next Friday."),
new("delegate", "동사", "위임하다", "You should delegate this to the team."),
new("deploy", "동사", "배포하다, 배치하다", "We'll deploy the update tonight."),
new("escalate", "동사", "상부에 보고하다, 확대하다", "Let's escalate this issue."),
new("feasible", "형용사","실현 가능한", "Is this plan feasible?"),
new("follow-up", "명사", "후속 조치", "I'll send a follow-up email."),
new("implement", "동사", "구현하다, 시행하다", "We need to implement this feature."),
new("initiative", "명사", "주도적 행동, 계획", "This is a company-wide initiative."),
new("milestone", "명사", "중요 시점, 이정표", "We've reached a major milestone."),
new("onboard", "동사", "입사시키다, 적응시키다", "We'll onboard new hires next week."),
new("optimize", "동사", "최적화하다", "Let's optimize the workflow."),
new("prioritize", "동사", "우선순위를 정하다", "We need to prioritize these tasks."),
new("provisional", "형용사","잠정적인, 임시의", "This is a provisional plan."),
new("regarding", "전치사","~에 관하여", "Regarding your request..."),
new("scope", "명사", "범위", "This is out of scope."),
new("stakeholder", "명사", "이해관계자", "All stakeholders should attend."),
new("sync", "동사", "동기화하다, 맞추다", "Let's sync on this topic."),
new("tentative", "형용사","잠정적인, 시험적인", "This is a tentative schedule."),
new("throughput", "명사", "처리량", "We need to improve throughput."),
new("viable", "형용사","실행 가능한", "Is this a viable option?"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("국어·영한 사전",
$"국어 {KorDict.Length}개 · 영한 {EngDict.Length}개 · dict {{단어}} / dict en {{word}}",
null, null, Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 영한 사전 ─────────────────────────────────────────────────────────
if (q.StartsWith("en ", StringComparison.OrdinalIgnoreCase))
{
var word = q[3..].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(word))
{
items.Add(new LauncherItem("dict en {영단어}", "예: dict en deadline", null, null, Symbol: "\uE774"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var found = EngDict.Where(e => e.Word.Contains(word, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count == 0)
{
items.Add(new LauncherItem($"'{word}' 검색 결과 없음",
"내장 업무 영어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
}
else
{
foreach (var e in found)
{
var sub = $"[{e.Pos}] {e.Kor}";
if (e.Example != null) sub += $" · {e.Example}";
items.Add(new LauncherItem(e.Word, sub, null, ("copy", $"{e.Word}: {e.Kor}"), Symbol: "\uE774"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 국어 사전 검색 ────────────────────────────────────────────────────
var kw = q;
var results = KorDict.Where(e =>
e.Word.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
e.Def.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
(e.Synonym?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false) ||
(e.Note?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false)).ToList();
if (results.Count == 0)
{
items.Add(new LauncherItem($"'{kw}' 검색 결과 없음",
"내장 국어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
}
else
{
foreach (var e in results.Take(12))
{
var title = $"{e.Word} [{e.Pos}]";
var sub = e.Def;
if (e.Synonym != null) sub += $" · 유의어: {e.Synonym}";
if (e.Antonym != null) sub += $" · 반의어: {e.Antonym}";
if (e.Note != null) sub += $" · 💡 {e.Note}";
items.Add(new LauncherItem(title, sub, null, ("copy", $"{e.Word}: {e.Def}"), Symbol: "\uE82D"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("dict", "뜻풀이가 클립보드에 복사되었습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,264 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L13-1: DNS 레코드 조회 핸들러. "dns" 프리픽스로 사용합니다.
///
/// 예: dns google.com → A/AAAA 레코드 조회
/// dns google.com mx → MX 레코드
/// dns google.com txt → TXT 레코드
/// dns google.com ns → NS 레코드
/// dns 8.8.8.8 → PTR(역방향) 조회
/// Enter → 결과를 클립보드에 복사.
///
/// ⚠ 사내 모드: 내부 호스트만 조회 허용.
/// </summary>
public class DnsQueryHandler : IActionHandler
{
public string? Prefix => "dns";
public PluginMetadata Metadata => new(
"DNS",
"DNS 레코드 조회 — A · AAAA · MX · TXT · NS · PTR",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("DNS 레코드 조회",
"예: dns google.com / dns google.com mx / dns 8.8.8.8",
null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("dns localhost", "로컬 A 레코드", null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("dns example.com mx", "MX 레코드", null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("dns example.com txt","TXT 레코드", null, null, Symbol: "\uE968"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var host = parts[0];
var recType = parts.Length > 1 ? parts[1].ToUpperInvariant() : "A";
// 사내 모드 확인
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
var isInternal = settings?.InternalModeEnabled ?? true;
if (isInternal && !IsInternalHost(host))
{
items.Add(new LauncherItem(
"사내 모드 제한",
$"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
$"{host} [{recType}]",
"Enter를 눌러 조회 실행",
null,
("query", $"{host}|{recType}"),
Symbol: "\uE968"));
// 레코드 타입 빠른 선택 힌트
if (parts.Length == 1)
{
foreach (var t in new[] { "A", "AAAA", "MX", "TXT", "NS" })
items.Add(new LauncherItem($"dns {host} {t}", $"{t} 레코드 조회", null,
("query", $"{host}|{t}"), Symbol: "\uE968"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("DNS", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
return;
}
if (item.Data is not ("query", string queryData)) return;
var idx = queryData.IndexOf('|');
var host = queryData[..idx];
var recType = queryData[(idx + 1)..];
NotificationService.Notify("DNS", $"{host} [{recType}] 조회 중…");
try
{
var results = await QueryDnsAsync(host, recType, ct);
if (results.Count == 0)
{
NotificationService.Notify("DNS", $"{host} [{recType}] — 레코드 없음");
return;
}
var summary = string.Join("\n", results);
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
NotificationService.Notify("DNS", $"{results.Count}개 레코드 조회됨 · 클립보드 복사");
}
catch (OperationCanceledException)
{
NotificationService.Notify("DNS", "조회 취소됨");
}
catch (Exception ex)
{
NotificationService.Notify("DNS", $"오류: {ex.Message}");
}
}
// ── DNS 조회 ─────────────────────────────────────────────────────────────
private static async Task<List<string>> QueryDnsAsync(string host, string type, CancellationToken ct)
{
return type switch
{
"A" or "AAAA" => await QueryAAsync(host, type, ct),
"PTR" => await QueryPtrAsync(host, ct),
_ => await QueryViaNslookupAsync(host, type, ct),
};
}
private static async Task<List<string>> QueryAAsync(string host, string type, CancellationToken ct)
{
var family = type == "AAAA" ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
var addrs = await Dns.GetHostAddressesAsync(host, family, ct);
return addrs.Select(a => a.ToString()).ToList();
}
private static async Task<List<string>> QueryPtrAsync(string ip, CancellationToken ct)
{
if (!IPAddress.TryParse(ip, out _))
return [$"'{ip}'은 유효한 IP 주소가 아닙니다"];
try
{
var entry = await Dns.GetHostEntryAsync(ip, ct);
return [entry.HostName];
}
catch
{
return [$"PTR 레코드 없음: {ip}"];
}
}
/// <summary>MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회</summary>
private static async Task<List<string>> QueryViaNslookupAsync(string host, string type, CancellationToken ct)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "nslookup",
Arguments = $"-type={type} {host}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
};
using var proc = new System.Diagnostics.Process { StartInfo = psi };
proc.Start();
var stdout = await proc.StandardOutput.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return ParseNslookupOutput(stdout, type);
}
private static List<string> ParseNslookupOutput(string output, string type)
{
var results = new List<string>();
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// 서버 응답 헤더 건너뜀 (첫 2줄)
var skip = true;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (skip)
{
if (trimmed.StartsWith("Non-authoritative", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("Name:", StringComparison.OrdinalIgnoreCase) ||
(type == "MX" && trimmed.Contains("mail exchanger")) ||
(type == "TXT" && trimmed.Contains("text =")) ||
(type == "NS" && trimmed.Contains("nameserver")))
skip = false;
else
continue;
}
if (string.IsNullOrWhiteSpace(trimmed)) continue;
// MX: "... mail exchanger = 10 aspmx.l.google.com"
if (type == "MX" && trimmed.Contains("mail exchanger"))
{
var idx = trimmed.IndexOf('=');
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
continue;
}
// TXT: "... text = "v=spf1 …""
if (type == "TXT" && trimmed.Contains("text ="))
{
var idx = trimmed.IndexOf("text =", StringComparison.OrdinalIgnoreCase);
if (idx >= 0) results.Add(trimmed[(idx + 6)..].Trim().Trim('"'));
continue;
}
// NS: "nameserver = ns1.google.com"
if (type == "NS" && trimmed.Contains("nameserver"))
{
var idx = trimmed.IndexOf('=');
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
continue;
}
// CNAME: "canonical name = …"
if (type == "CNAME" && trimmed.Contains("canonical name"))
{
var idx = trimmed.IndexOf('=');
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
continue;
}
// Address: 주소 행
if (trimmed.StartsWith("Address:", StringComparison.OrdinalIgnoreCase))
{
var idx = trimmed.IndexOf(':');
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
}
}
return results.Count > 0 ? results : [$"조회 결과 없음 ({type})"];
}
private static bool IsInternalHost(string host)
{
if (host is "localhost" or "127.0.0.1") return true;
if (IPAddress.TryParse(host, out var addr))
{
var s = addr.ToString();
return s.StartsWith("192.168.") || s.StartsWith("10.") ||
System.Text.RegularExpressions.Regex.IsMatch(s,
@"^172\.(1[6-9]|2\d|3[01])\.");
}
// 도메인 이름은 사내 모드에서 외부로 간주
return false;
}
}

View File

@@ -0,0 +1,375 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다.
///
/// 예: docker → 실행 중 컨테이너 목록
/// docker all → 모든 컨테이너 (중지 포함)
/// docker images → 로컬 이미지 목록
/// docker ps → 컨테이너 목록 (docker ps 동일)
/// docker stop <name> → 컨테이너 중지
/// docker start <name> → 컨테이너 시작
/// docker logs <name> → 컨테이너 로그 (터미널)
/// docker shell <name> → 컨테이너 shell 접속
/// Enter → 명령 실행 또는 컨테이너 ID 복사.
/// </summary>
public class DockerHandler : IActionHandler
{
public string? Prefix => "docker";
public PluginMetadata Metadata => new(
"Docker",
"Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (!IsDockerAvailable())
{
items.Add(new LauncherItem("Docker를 찾을 수 없습니다",
"Docker Desktop이 설치되어 있는지 확인하세요", null, null, Symbol: "\uE756"));
items.Add(new LauncherItem("Docker Desktop 설치",
"https://www.docker.com/products/docker-desktop",
null, ("open_url", "https://www.docker.com/products/docker-desktop"), Symbol: "\uE756"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(q))
{
var containers = GetContainers(running: true);
items.Add(new LauncherItem(
$"실행 중 컨테이너 {containers.Count}개",
"docker ps / docker all / docker images",
null, null, Symbol: "\uE756"));
if (containers.Count == 0)
items.Add(new LauncherItem("실행 중인 컨테이너 없음", "docker all → 전체 목록", null, null, Symbol: "\uE946"));
else
foreach (var c in containers)
items.Add(MakeContainerItem(c));
items.Add(new LauncherItem("docker images", "로컬 이미지 목록", null, ("sub", "images"), Symbol: "\uE756"));
items.Add(new LauncherItem("docker all", "모든 컨테이너 목록", null, ("sub", "all"), Symbol: "\uE756"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "all":
case "ps":
{
var all = sub == "all";
var containers = GetContainers(running: !all);
items.Add(new LauncherItem(
$"{(all ? "" : " ")} 컨테이너 {containers.Count}개",
"", null, null, Symbol: "\uE756"));
foreach (var c in containers)
items.Add(MakeContainerItem(c));
if (containers.Count == 0)
items.Add(new LauncherItem("컨테이너 없음", "", null, null, Symbol: "\uE946"));
break;
}
case "images":
case "image":
case "img":
{
var images = GetImages();
items.Add(new LauncherItem($"로컬 이미지 {images.Count}개", "", null, null, Symbol: "\uE756"));
foreach (var img in images)
items.Add(MakeImageItem(img));
if (images.Count == 0)
items.Add(new LauncherItem("이미지 없음", "docker pull <이름> 으로 받기", null, null, Symbol: "\uE946"));
break;
}
case "stop":
{
var name = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(name))
{
// 실행 중 컨테이너 목록 표시 → 클릭 시 stop
var running = GetContainers(running: true);
items.Add(new LauncherItem("중지할 컨테이너 선택", "Enter → 중지", null, null, Symbol: "\uE756"));
foreach (var c in running)
items.Add(new LauncherItem($"중지: {c.Name}", c.Image,
null, ("stop", c.Id), Symbol: "\uE756"));
}
else
{
items.Add(new LauncherItem($"컨테이너 중지: {name}",
$"docker stop {name} · Enter 실행",
null, ("stop", name), Symbol: "\uE756"));
}
break;
}
case "start":
{
var name = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(name))
{
var stopped = GetContainers(running: false, stopped: true);
items.Add(new LauncherItem("시작할 컨테이너 선택", "Enter → 시작", null, null, Symbol: "\uE756"));
foreach (var c in stopped)
items.Add(new LauncherItem($"시작: {c.Name}", c.Image,
null, ("start", c.Id), Symbol: "\uE756"));
}
else
{
items.Add(new LauncherItem($"컨테이너 시작: {name}",
$"docker start {name} · Enter 실행",
null, ("start", name), Symbol: "\uE756"));
}
break;
}
case "logs":
case "log":
{
var name = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(name))
{
var running = GetContainers(running: true);
foreach (var c in running)
items.Add(new LauncherItem($"로그: {c.Name}", "Enter → 터미널에서 로그 보기",
null, ("logs", c.Name), Symbol: "\uE756"));
}
else
{
items.Add(new LauncherItem($"로그: {name}", $"docker logs -f {name}",
null, ("logs", name), Symbol: "\uE756"));
}
break;
}
case "shell":
case "exec":
case "sh":
{
var name = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(name))
{
var running = GetContainers(running: true);
foreach (var c in running)
items.Add(new LauncherItem($"쉘: {c.Name}", "Enter → 컨테이너 shell 접속",
null, ("shell", c.Name), Symbol: "\uE756"));
}
else
{
items.Add(new LauncherItem($"쉘 접속: {name}", $"docker exec -it {name} sh",
null, ("shell", name), Symbol: "\uE756"));
}
break;
}
default:
{
// 컨테이너 이름 검색
var all = GetContainers(running: false, stopped: true, all: true);
var found = all.Where(c =>
c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
c.Image.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count > 0)
foreach (var c in found)
items.Add(MakeContainerItem(c));
else
items.Add(new LauncherItem($"'{q}' 컨테이너 없음",
"docker all → 전체 목록", null, null, Symbol: "\uE946"));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("stop", string id):
RunDockerSilent($"stop {id}");
NotificationService.Notify("Docker", $"중지: {id}");
break;
case ("start", string id):
RunDockerSilent($"start {id}");
NotificationService.Notify("Docker", $"시작: {id}");
break;
case ("logs", string name):
RunInTerminal($"docker logs -f {name}");
break;
case ("shell", string name):
RunInTerminal($"docker exec -it {name} sh");
break;
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("Docker", "복사됨");
}
catch { /* 비핵심 */ }
break;
case ("open_url", string url):
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{ FileName = url, UseShellExecute = true });
}
catch { /* 비핵심 */ }
break;
}
return Task.CompletedTask;
}
// ── Docker 조회 ──────────────────────────────────────────────────────────
private record DockerContainer(string Id, string Name, string Image, string Status, string Ports);
private record DockerImage(string Repository, string Tag, string Id, string Size, string Created);
private static List<DockerContainer> GetContainers(bool running = true, bool stopped = false, bool all = false)
{
var result = new List<DockerContainer>();
try
{
var filter = all || (!running && stopped) ? "-a" : (running ? "" : "--filter status=exited");
var output = RunDockerOutput($"ps {filter} --format \"{{{{.ID}}}}\\t{{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}\"");
foreach (var line in output.Split('\n'))
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed)) continue;
var cols = trimmed.Split('\t');
if (cols.Length < 4) continue;
result.Add(new DockerContainer(
Id: cols[0],
Name: cols[1],
Image: cols[2],
Status: cols[3],
Ports: cols.Length > 4 ? cols[4] : ""));
}
}
catch { /* Docker 없음 */ }
return result;
}
private static List<DockerImage> GetImages()
{
var result = new List<DockerImage>();
try
{
var output = RunDockerOutput("images --format \"{{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}\\t{{.CreatedSince}}\"");
foreach (var line in output.Split('\n'))
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed)) continue;
var cols = trimmed.Split('\t');
if (cols.Length < 4) continue;
result.Add(new DockerImage(
Repository: cols[0],
Tag: cols.Length > 1 ? cols[1] : "latest",
Id: cols.Length > 2 ? cols[2] : "",
Size: cols.Length > 3 ? cols[3] : "",
Created: cols.Length > 4 ? cols[4] : ""));
}
}
catch { /* Docker 없음 */ }
return result;
}
private static string RunDockerOutput(string args)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "docker",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null) return "";
var output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit(5000);
return output;
}
private static void RunDockerSilent(string args)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "docker", Arguments = args,
UseShellExecute = false, CreateNoWindow = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
proc?.WaitForExit(10000);
}
catch { /* 비핵심 */ }
}
private static bool IsDockerAvailable()
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "docker", Arguments = "version --format json",
UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
proc?.WaitForExit(3000);
return proc?.ExitCode == 0;
}
catch { return false; }
}
private static LauncherItem MakeContainerItem(DockerContainer c)
{
var isRunning = c.Status.StartsWith("Up", StringComparison.OrdinalIgnoreCase);
var icon = isRunning ? "\uE768" : "\uE71A";
var ports = string.IsNullOrWhiteSpace(c.Ports) ? "" : $" · {c.Ports}";
return new LauncherItem(c.Name,
$"{c.Status}{ports} · {c.Image}",
null, ("copy", c.Id), Symbol: icon);
}
private static LauncherItem MakeImageItem(DockerImage img)
{
var name = img.Tag == "<none>" ? img.Repository : $"{img.Repository}:{img.Tag}";
return new LauncherItem(name, $"{img.Size} · {img.Created} · {img.Id[..Math.Min(12, img.Id.Length)]}",
null, ("copy", name), Symbol: "\uE756");
}
private static void RunInTerminal(string cmd)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd", Arguments = $"/K {cmd}", UseShellExecute = true,
});
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,202 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다.
///
/// 예: drive → 전체 드라이브 목록 + 용량 요약
/// drive C → C 드라이브 상세 정보
/// drive C:\ → 경로 형식도 지원
/// drive large → 사용량 많은 순서로 정렬
/// Enter → 드라이브 정보를 클립보드에 복사.
/// </summary>
public class DriveHandler : IActionHandler
{
public string? Prefix => "drive";
public PluginMetadata Metadata => new(
"Drive",
"드라이브 정보 — 용량 · 파일시스템 · 여유공간",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var drives = GetDrives();
if (string.IsNullOrWhiteSpace(q))
{
var totalSize = drives.Sum(d => d.TotalSize);
var totalFree = drives.Sum(d => d.AvailableFree);
var totalUsed = totalSize - totalFree;
items.Add(new LauncherItem(
$"드라이브 {drives.Count}개",
$"전체 {FormatBytes(totalSize)} · 사용 {FormatBytes(totalUsed)} · 여유 {FormatBytes(totalFree)}",
null, null, Symbol: "\uEDA2"));
foreach (var d in drives.OrderBy(d => d.Name))
items.Add(MakeDriveSummaryItem(d));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var sub = q.ToUpperInvariant().TrimEnd(':', '\\', '/');
if (sub == "LARGE")
{
// 사용량 많은 순
foreach (var d in drives.OrderByDescending(d => d.UsedSpace))
items.Add(MakeDriveSummaryItem(d));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 특정 드라이브 상세
var target = drives.FirstOrDefault(d =>
d.Name.StartsWith(sub, StringComparison.OrdinalIgnoreCase));
if (target == null)
{
items.Add(new LauncherItem("드라이브 없음", $"'{q}' 드라이브를 찾을 수 없습니다", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.AddRange(BuildDetailItems(target));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Drive", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 드라이브 정보 수집 ────────────────────────────────────────────────────
private record DriveInfo2(
string Name,
string VolumeLabel,
string DriveFormat,
DriveType DriveType,
long TotalSize,
long AvailableFree,
long UsedSpace,
bool IsReady);
private static List<DriveInfo2> GetDrives()
{
return DriveInfo.GetDrives()
.Select(d =>
{
if (!d.IsReady)
return new DriveInfo2(d.Name, "", "", d.DriveType, 0, 0, 0, false);
try
{
return new DriveInfo2(
d.Name,
d.VolumeLabel,
d.DriveFormat,
d.DriveType,
d.TotalSize,
d.AvailableFreeSpace,
d.TotalSize - d.AvailableFreeSpace,
true);
}
catch
{
return new DriveInfo2(d.Name, "", d.DriveFormat, d.DriveType, 0, 0, 0, false);
}
})
.ToList();
}
private static IEnumerable<LauncherItem> BuildDetailItems(DriveInfo2 d)
{
var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
var bar = MakeBar(usagePercent, 20);
var summary = $"""
드라이브: {d.Name}
볼륨 레이블: {(string.IsNullOrEmpty(d.VolumeLabel) ? "()" : d.VolumeLabel)}
: {d.DriveFormat}
: {DriveTypeName(d.DriveType)}
: {FormatBytes(d.TotalSize)}
: {FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)
: {FormatBytes(d.AvailableFree)}
""";
yield return new LauncherItem(
$"{d.Name} {FormatBytes(d.TotalSize)}",
$"사용 {usagePercent:F0}% {bar} 여유 {FormatBytes(d.AvailableFree)}",
null, ("copy", summary), Symbol: "\uEDA2");
yield return new LauncherItem("볼륨 레이블", string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel, null, null, Symbol: "\uEDA2");
yield return new LauncherItem("파일 시스템", d.DriveFormat, null, null, Symbol: "\uEDA2");
yield return new LauncherItem("드라이브 종류", DriveTypeName(d.DriveType), null, null, Symbol: "\uEDA2");
yield return new LauncherItem("전체 용량", FormatBytes(d.TotalSize), null, ("copy", FormatBytes(d.TotalSize)), Symbol: "\uEDA2");
yield return new LauncherItem("사용 중", $"{FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)", null, ("copy", FormatBytes(d.UsedSpace)), Symbol: "\uEDA2");
yield return new LauncherItem("여유 공간", FormatBytes(d.AvailableFree), null, ("copy", FormatBytes(d.AvailableFree)), Symbol: "\uEDA2");
}
private static LauncherItem MakeDriveSummaryItem(DriveInfo2 d)
{
if (!d.IsReady)
return new LauncherItem(d.Name, $"준비 안됨 ({DriveTypeName(d.DriveType)})", null, null, Symbol: "\uEDA2");
var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
var bar = MakeBar(usagePercent, 12);
var label = string.IsNullOrEmpty(d.VolumeLabel) ? d.Name : $"{d.Name} ({d.VolumeLabel})";
return new LauncherItem(
label,
$"{bar} {usagePercent:F0}% · 여유 {FormatBytes(d.AvailableFree)} / {FormatBytes(d.TotalSize)}",
null,
("copy", $"{d.Name} {FormatBytes(d.TotalSize)} 사용{usagePercent:F0}% 여유{FormatBytes(d.AvailableFree)}"),
Symbol: "\uEDA2");
}
// ── 유틸 ─────────────────────────────────────────────────────────────────
private static string MakeBar(double percent, int width)
{
var filled = (int)(percent / 100.0 * width);
filled = Math.Clamp(filled, 0, width);
return "[" + new string('█', filled) + new string('░', width - filled) + "]";
}
private static string DriveTypeName(DriveType dt) => dt switch
{
DriveType.Fixed => "고정 디스크",
DriveType.Removable => "이동식 디스크",
DriveType.Network => "네트워크 드라이브",
DriveType.CDRom => "CD/DVD",
DriveType.Ram => "RAM 디스크",
DriveType.NoRootDirectory => "루트 없음",
_ => "알 수 없음",
};
private static string FormatBytes(long bytes) => bytes switch
{
>= 1024L * 1024 * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024 / 1024:F2} TB",
>= 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024:F1} GB",
>= 1024L * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
>= 1024L => $"{bytes / 1024.0:F0} KB",
_ => $"{bytes} B",
};
}

View File

@@ -0,0 +1,157 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다.
///
/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산)
/// evt error → 오류(Error) 이벤트만
/// evt warn → 경고(Warning) 이벤트만
/// evt app → Application 로그
/// evt sys → System 로그
/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트
/// Enter → 이벤트 내용을 클립보드에 복사.
/// </summary>
public class EventLogHandler : IActionHandler
{
public string? Prefix => "evt";
public PluginMetadata Metadata => new(
"EventLog",
"Windows 이벤트 로그 — 오류 · 경고 · 소스별 조회",
"1.0",
"AX");
private const int MaxItems = 20;
private const int LookbackH = 24; // 최근 24시간
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
try
{
// 필터 결정
var logName = "System";
var level = EventLogEntryType.Error; // 기본 오류
bool allLevel = false;
string keyword = "";
if (q == "app") { logName = "Application"; allLevel = false; }
else if (q == "sys") { logName = "System"; allLevel = false; }
else if (q == "error") { allLevel = false; level = EventLogEntryType.Error; }
else if (q is "warn" or "warning") { allLevel = false; level = EventLogEntryType.Warning; }
else if (string.IsNullOrEmpty(q)) { allLevel = false; /* Error 기본 */ }
else { keyword = q; allLevel = true; }
// System + Application 병합 또는 단일 로그
var logNames = string.IsNullOrEmpty(q) || q is "error" or "warn" or "warning"
? new[] { "System", "Application" }
: logName == "Application" ? new[] { "Application" } : new[] { logName };
var entries = new List<EventLogEntry>();
var cutoff = DateTime.Now.AddHours(-LookbackH);
foreach (var ln in logNames)
{
try
{
using var log = new EventLog(ln);
for (int i = log.Entries.Count - 1; i >= 0 && entries.Count < MaxItems * 2; i--)
{
var entry = log.Entries[i];
if (entry.TimeGenerated < cutoff) break;
bool matchLevel = allLevel
? true
: entry.EntryType == level;
bool matchKeyword = string.IsNullOrEmpty(keyword)
? true
: (entry.Source?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true
|| entry.Message?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true);
if (matchLevel && matchKeyword)
entries.Add(entry);
}
}
catch { /* 특정 로그 접근 실패 시 무시 */ }
}
if (entries.Count == 0)
{
items.Add(new LauncherItem(
"이벤트 없음",
$"최근 {LookbackH}시간 내 해당 이벤트가 없습니다",
null, null, Symbol: "\uE73E"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 정렬 (최신 순) 및 중복 제거
var sorted = entries
.OrderByDescending(e => e.TimeGenerated)
.Take(MaxItems)
.ToList();
foreach (var entry in sorted)
{
var icon = entry.EntryType == EventLogEntryType.Error ? "\uE783" :
entry.EntryType == EventLogEntryType.Warning ? "\uE7BA" : "\uE946";
var level2 = entry.EntryType == EventLogEntryType.Error ? "오류" :
entry.EntryType == EventLogEntryType.Warning ? "경고" : "정보";
var msg = entry.Message ?? "";
if (msg.Length > 80) msg = msg[..80].Replace('\n', ' ').Replace('\r', ' ') + "…";
items.Add(new LauncherItem(
$"[{level2}] {entry.Source}",
$"{entry.TimeGenerated:MM-dd HH:mm} · {msg}",
null,
("copy_event", FormatEvent(entry)),
Symbol: icon));
}
}
catch (Exception ex)
{
items.Add(new LauncherItem(
"이벤트 로그 접근 실패",
ex.Message,
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy_event", string eventText))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(eventText));
NotificationService.Notify("EventLog", "이벤트 정보를 클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string FormatEvent(EventLogEntry e) =>
$"""
[이벤트 ID] {e.InstanceId}
[시각] {e.TimeGenerated:yyyy-MM-dd HH:mm:ss}
[유형] {e.EntryType}
[소스] {e.Source}
[메시지]
{e.Message}
""";
}

View File

@@ -0,0 +1,186 @@
using System.IO;
using System.Text.RegularExpressions;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L4-1: 인라인 파일 탐색기 핸들러.
/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다.
/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동.
/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작.
/// </summary>
public class FileBrowserHandler : IActionHandler
{
public string? Prefix => null; // 경로 패턴 직접 감지
public PluginMetadata Metadata => new(
"FileBrowser",
"파일 탐색기 — 경로 입력 후 → 키로 탐색",
"1.0",
"AX");
// C:\, D:\path, \\server\share, ~\ 패턴 감지
private static readonly Regex PathPattern = new(
@"^([A-Za-z]:\\|\\\\|~\\|~\/|\/)",
RegexOptions.Compiled);
/// <summary>쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다.</summary>
public static bool IsPathQuery(string query)
=> !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim());
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = ExpandPath(query.Trim());
// 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리)
if (!IsPathQuery(query.Trim()))
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
// 입력이 존재하는 디렉터리이면 그 내용 표시
if (Directory.Exists(q))
return Task.FromResult(ListDirectory(q));
// 부분 경로: 마지막 세그먼트를 필터로 사용
var parent = Path.GetDirectoryName(q);
var filter = Path.GetFileName(q).ToLowerInvariant();
if (parent != null && Directory.Exists(parent))
return Task.FromResult(ListDirectory(parent, filter));
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("경로를 찾을 수 없습니다", q, null, null, Symbol: Symbols.Error)
});
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is FileBrowserEntry { IsFolder: true } dir)
{
// 폴더: 탐색기로 열기
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo("explorer.exe", dir.Path)
{ UseShellExecute = true });
}
else if (item.Data is FileBrowserEntry { IsFolder: false } file)
{
// 파일: 기본 앱으로 열기
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(file.Path)
{ UseShellExecute = true });
}
return Task.CompletedTask;
}
// ─── 디렉터리 내용 나열 ─────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> ListDirectory(string dir, string filter = "")
{
var items = new List<LauncherItem>();
// 상위 폴더 항목 (루트가 아닐 때)
var parent = Path.GetDirectoryName(dir.TrimEnd('\\', '/'));
if (!string.IsNullOrEmpty(parent))
{
items.Add(new LauncherItem(
".. (상위 폴더)",
parent,
IconCacheService.GetIconPath(parent, true),
new FileBrowserEntry(parent, true),
Symbol: "\uE74A")); // Back 아이콘
}
try
{
// 폴더 먼저
var dirs = Directory.GetDirectories(dir)
.Where(d => string.IsNullOrEmpty(filter) ||
Path.GetFileName(d).Contains(filter, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)
.Take(40);
foreach (var d in dirs)
{
var name = Path.GetFileName(d);
items.Add(new LauncherItem(
name,
d,
IconCacheService.GetIconPath(d, true),
new FileBrowserEntry(d, true),
Symbol: Symbols.Folder));
}
// 파일
var files = Directory.GetFiles(dir)
.Where(f => string.IsNullOrEmpty(filter) ||
Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
.Take(30);
foreach (var f in files)
{
var name = Path.GetFileName(f);
var ext = Path.GetExtension(f).ToLowerInvariant();
var size = FormatSize(new FileInfo(f).Length);
items.Add(new LauncherItem(
name,
$"{size} · {ext.TrimStart('.')} 파일",
IconCacheService.GetIconPath(f),
new FileBrowserEntry(f, false),
Symbol: ExtToSymbol(ext)));
}
}
catch (UnauthorizedAccessException)
{
items.Add(new LauncherItem("접근 권한 없음", dir, null, null, Symbol: Symbols.Error));
}
catch (Exception ex)
{
items.Add(new LauncherItem("읽기 오류", ex.Message, null, null, Symbol: Symbols.Error));
}
if (items.Count == 0 || (items.Count == 1 && items[0].Symbol == "\uE74A"))
items.Add(new LauncherItem("(빈 폴더)", dir, null, null, Symbol: Symbols.Folder));
return items;
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string ExpandPath(string path)
{
if (path.StartsWith("~")) path = "%USERPROFILE%" + path[1..];
return Environment.ExpandEnvironmentVariables(path);
}
private static string FormatSize(long bytes) => bytes switch
{
< 1_024L => $"{bytes} B",
< 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB",
< 1_024L * 1_024 * 1_024 => $"{bytes / 1_048_576.0:F1} MB",
_ => $"{bytes / 1_073_741_824.0:F1} GB",
};
private static string ExtToSymbol(string ext) => ext switch
{
".exe" or ".msi" => Symbols.App,
".pdf" => "\uEA90",
".docx" or ".doc" => "\uE8A5",
".xlsx" or ".xls" => "\uE9F9",
".pptx" or ".ppt" => "\uE8A5",
".zip" or ".7z" or ".rar" => "\uED25",
".mp4" or ".avi" or ".mkv" => "\uE714",
".mp3" or ".wav" or ".flac" => "\uE767",
".png" or ".jpg" or ".jpeg" or ".gif" => "\uEB9F",
".txt" or ".md" or ".log" => "\uE8A5",
".cs" or ".py" or ".js" or ".ts" => "\uE8A5",
".lnk" => "\uE71B",
_ => "\uE7C3",
};
}
/// <summary>파일 탐색기 핸들러에서 사용하는 항목 데이터</summary>
public record FileBrowserEntry(string Path, bool IsFolder);

View File

@@ -0,0 +1,273 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L8-1: 파일 해시 검증 핸들러. "hash" 프리픽스로 사용합니다.
///
/// 예: hash → 사용법 안내
/// hash C:\file.zip → SHA256 (기본) 계산
/// hash md5 C:\file.zip → MD5 계산
/// hash sha1 C:\file.zip → SHA1 계산
/// hash sha512 C:\file.zip → SHA512 계산
/// hash check <기대값> → 클립보드의 해시값과 비교
/// 경로 미입력 시 클립보드에서 파일 경로 자동 감지.
/// Enter → 해시 결과를 클립보드에 복사.
/// </summary>
public class FileHashHandler : IActionHandler
{
public string? Prefix => "hash";
public PluginMetadata Metadata => new(
"FileHash",
"파일 해시 검증 — MD5 · SHA1 · SHA256 · SHA512",
"1.0",
"AX");
private static readonly string[] Algos = ["md5", "sha1", "sha256", "sha512"];
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드에 파일 경로가 있으면 자동 감지
var clipPath = GetClipboardFilePath();
if (!string.IsNullOrEmpty(clipPath))
{
items.Add(new LauncherItem(
$"SHA256: {Path.GetFileName(clipPath)}",
clipPath,
null,
("compute", "sha256", clipPath),
Symbol: "\uE8C4"));
foreach (var algo in Algos)
items.Add(new LauncherItem(
$"hash {algo}",
$"{algo.ToUpperInvariant()} 계산",
null,
("compute", algo, clipPath),
Symbol: "\uE8C4"));
}
else
{
items.Add(new LauncherItem(
"파일 해시 계산",
"hash <경로> 또는 hash md5|sha1|sha256|sha512 <경로>",
null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem(
"hash check <기대 해시값>",
"클립보드의 해시와 비교 검증",
null, null, Symbol: "\uE73E"));
}
return items;
}
// "check <hashValue>" — 클립보드 해시 비교
if (q.StartsWith("check ", StringComparison.OrdinalIgnoreCase))
{
var expected = q[6..].Trim();
var clipText = GetClipboardText()?.Trim();
if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(expected))
{
var match = expected.Equals(clipText, StringComparison.OrdinalIgnoreCase);
items.Add(new LauncherItem(
match ? "✓ 해시 일치" : "✗ 해시 불일치",
$"기대값: {Truncate(expected, 40)}",
null, null,
Symbol: match ? "\uE73E" : "\uE711"));
if (!match)
items.Add(new LauncherItem(
"클립보드",
Truncate(clipText, 60),
null, null, Symbol: "\uE8C8"));
}
else
{
items.Add(new LauncherItem(
"비교 대상 없음",
"먼저 해시 계산 결과를 클립보드에 복사하세요",
null, null, Symbol: "\uE783"));
}
return items;
}
// 알고리즘 + 경로 파싱
string algo2 = "sha256";
string filePath = q;
var parts = q.Split(' ', 2);
if (parts.Length == 2 && Algos.Contains(parts[0].ToLowerInvariant()))
{
algo2 = parts[0].ToLowerInvariant();
filePath = parts[1].Trim().Trim('"');
}
else
{
// 알고리즘 없이 경로만 → 모든 알고리즘 표시
filePath = q.Trim('"');
}
if (!File.Exists(filePath))
{
// 클립보드 경로 시도
var clipPath = GetClipboardFilePath();
if (!string.IsNullOrEmpty(clipPath) && File.Exists(clipPath))
filePath = clipPath;
else
{
items.Add(new LauncherItem(
"파일을 찾을 수 없음",
filePath,
null, null, Symbol: "\uE783"));
return items;
}
}
var fileName = Path.GetFileName(filePath);
var fileSize = new FileInfo(filePath).Length;
var sizeMb = fileSize / 1024.0 / 1024.0;
if (algo2 == "sha256" && parts.Length == 1)
{
// 경로만 입력 → 모든 알고리즘 항목 표시
items.Add(new LauncherItem(
fileName,
$"{sizeMb:F1} MB",
null, null, Symbol: "\uE8F4"));
foreach (var a in Algos)
{
items.Add(new LauncherItem(
a.ToUpperInvariant(),
"계산 중... (Enter로 실행)",
null,
("compute", a, filePath),
Symbol: "\uE8C4"));
}
}
else
{
// 특정 알고리즘 계산
items.Add(new LauncherItem(
$"계산 중: {algo2.ToUpperInvariant()}",
$"{fileName} ({sizeMb:F1} MB)",
null,
("compute", algo2, filePath),
Symbol: "\uE8C4"));
try
{
var hash = await ComputeHashAsync(filePath, algo2, ct);
items.Clear();
items.Add(new LauncherItem(
hash,
$"{algo2.ToUpperInvariant()} · {fileName}",
null,
("copy", hash),
Symbol: "\uE8C4"));
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
items.Add(new LauncherItem("해시 계산 실패", ex.Message, null, null, Symbol: "\uE783"));
}
}
return items;
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string hash):
TryCopyToClipboard(hash);
NotificationService.Notify("FileHash", "해시를 클립보드에 복사했습니다.");
break;
case ("compute", string algo, string filePath):
try
{
var hash = await ComputeHashAsync(filePath, algo, ct);
TryCopyToClipboard(hash);
NotificationService.Notify(
$"{algo.ToUpperInvariant()} 완료",
$"{Path.GetFileName(filePath)}: {Truncate(hash, 32)}…");
}
catch (Exception ex)
{
NotificationService.Notify("FileHash 오류", ex.Message);
}
break;
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static async Task<string> ComputeHashAsync(
string filePath, string algo, CancellationToken ct)
{
using HashAlgorithm hasher = algo.ToLowerInvariant() switch
{
"md5" => MD5.Create(),
"sha1" => SHA1.Create(),
"sha512" => SHA512.Create(),
_ => SHA256.Create(),
};
await using var stream = File.OpenRead(filePath);
var hashBytes = await Task.Run(() => hasher.ComputeHash(stream), ct);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string? GetClipboardFilePath()
{
try
{
string? text = null;
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
text = Clipboard.GetText()?.Trim().Trim('"');
});
return !string.IsNullOrEmpty(text) && File.Exists(text) ? text : null;
}
catch { return null; }
}
private static string? GetClipboardText()
{
try
{
string? text = null;
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText()) text = Clipboard.GetText();
});
return text;
}
catch { return null; }
}
private static void TryCopyToClipboard(string text)
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { /* 비핵심 */ }
}
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max];
}

View File

@@ -0,0 +1,539 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다.
///
/// 예: fix gksrmf → 안녕 (영타→한글 변환)
/// fix → 클립보드 텍스트 자동 교정
/// Enter → 교정 결과 클립보드 복사
/// </summary>
public class FixHandler : IActionHandler
{
public string? Prefix => "fix";
public PluginMetadata Metadata => new(
"타이핑 교정",
"영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정",
"1.0",
"AX");
// ── 두벌식 영→자모 매핑 ──────────────────────────────────────────────────
// 소문자
private static readonly Dictionary<char, char> EngToJamo = new()
{
{'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'},
{'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'},
{'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'},
{'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'},
{'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'},
{'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'},
// 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도)
{'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'},
{'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'},
{'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'},
{'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'},
{'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'},
{'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'},
};
// ── 초성 배열 (19개) ──────────────────────────────────────────────────────
private static readonly char[] Choseong =
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
// ── 중성 배열 (21개) ──────────────────────────────────────────────────────
private static readonly char[] Jungseong =
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
// ── 종성 배열 (28개, 0=없음) ─────────────────────────────────────────────
private static readonly char[] Jongseong =
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
// ── 복합 모음 ─────────────────────────────────────────────────────────────
private static readonly Dictionary<(char, char), char> CompoundVowel = new()
{
{('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'},
{('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'},
{('ㅡ','ㅣ'), 'ㅢ'},
};
// ── 복합 종성 ─────────────────────────────────────────────────────────────
private static readonly Dictionary<(char, char), char> CompoundJong = new()
{
{('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'},
{('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'},
{('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'},
{('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'},
};
// 복합 종성 분리 (모음이 올 때 jong → cho + remain)
private static readonly Dictionary<char, (char First, char Second)> SplitJong = new()
{
{'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')},
{'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')},
{'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')},
{'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')},
};
// ── 인덱스 헬퍼 ──────────────────────────────────────────────────────────
private static int ChoIdx(char c) => Array.IndexOf(Choseong, c);
private static int JungIdx(char c) => Array.IndexOf(Jungseong, c);
private static int JongIdx(char c) => Array.IndexOf(Jongseong, c);
private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0;
private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0;
private static char MakeSyllable(int cho, int jung, int jong) =>
(char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong);
// ── 한글 조합기 (두벌식 상태 기계) ───────────────────────────────────────
private sealed class HangulComposer
{
private int _cho = -1;
private int _jung = -1;
private int _jong = -1;
private readonly StringBuilder _sb = new();
public string Result => _sb.ToString();
public void Flush()
{
if (_cho < 0) return;
if (_jung < 0)
{
// 초성만
_sb.Append(Choseong[_cho]);
}
else
{
_sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong));
}
_cho = _jung = _jong = -1;
}
public void Feed(char jamo)
{
if (IsVowel(jamo))
{
FeedVowel(jamo);
}
else
{
FeedConsonant(jamo);
}
}
private void FeedVowel(char v)
{
var vi = JungIdx(v);
if (_cho < 0)
{
// 초성 없음 → ㅇ + 모음
_sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0));
return;
}
if (_jung < 0)
{
// 초성만 있음 → 중성 결합
_jung = vi;
return;
}
// 초성+중성 있음
if (_jong < 0)
{
// 복합 모음 시도
var curVowel = Jungseong[_jung];
if (CompoundVowel.TryGetValue((curVowel, v), out var compound))
{
_jung = JungIdx(compound);
}
else
{
// 현재 음절 확정, 새 음절 시작 (ㅇ + 모음)
Flush();
_cho = ChoIdx('ㅇ');
_jung = vi;
}
return;
}
// 초성+중성+종성 있음 → 종성을 새 음절의 초성으로
var jongChar = Jongseong[_jong];
if (SplitJong.TryGetValue(jongChar, out var split))
{
// 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성
var newCho = ChoIdx(split.Second);
if (newCho < 0) newCho = 0;
var remainJong = JongIdx(split.First);
// 현재 음절 (jong=split.First)
_sb.Append(MakeSyllable(_cho, _jung, remainJong));
_cho = newCho;
_jung = vi;
_jong = -1;
}
else
{
// 단일 종성 → 새 초성으로
var newCho = ChoIdx(jongChar);
if (newCho < 0) newCho = 0;
_sb.Append(MakeSyllable(_cho, _jung, 0));
_cho = newCho;
_jung = vi;
_jong = -1;
}
}
private void FeedConsonant(char c)
{
var ci = ChoIdx(c);
if (_cho < 0)
{
// 처음 자음
_cho = ci >= 0 ? ci : 0;
return;
}
if (_jung < 0)
{
// 초성만 있음 → 이전 초성 출력, 새 초성
_sb.Append(Choseong[_cho]);
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
return;
}
if (_jong < 0)
{
// 초성+중성 → 종성 후보
_jong = JongIdx(c);
if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리
if (_jong == 0)
{
// 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ)
Flush();
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
}
return;
}
// 초성+중성+종성 있음
var curJongChar = Jongseong[_jong];
if (CompoundJong.TryGetValue((curJongChar, c), out var compJ))
{
// 복합 종성 가능
var cji = JongIdx(compJ);
if (cji > 0)
{
_jong = cji;
return;
}
}
// 복합 종성 불가 → 현재 음절 확정, 새 초성
Flush();
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
}
}
// ── 영타→한글 변환 ────────────────────────────────────────────────────────
private static string EngToKorean(string input)
{
var composer = new HangulComposer();
var sb = new StringBuilder();
foreach (var ch in input)
{
if (EngToJamo.TryGetValue(ch, out var jamo))
{
if (composer.Result.Length > 0 || jamo != '\0')
{
// 현재 변환 중인 조합기에 피드
}
composer.Feed(jamo);
}
else
{
// 변환 불가 문자 → 조합기 플러시 후 그대로
var cur = composer.Result;
// 지금까지 쌓인 결과 덤프
composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush
// 아래 로직: 조합기는 Flush()로만 비워짐
sb.Append(ch);
}
}
// 위 로직을 단순화: 문자별 처리
return ConvertEngToKor(input);
}
private static string ConvertEngToKor(string input)
{
// 먼저 자모 문자열로 변환
var jamoSeq = new List<char>();
foreach (var ch in input)
{
if (EngToJamo.TryGetValue(ch, out var jamo))
jamoSeq.Add(jamo);
else
jamoSeq.Add(ch); // 변환 불가 문자는 그대로
}
// 자모 시퀀스를 한글 음절로 조합
var result = new StringBuilder();
var i = 0;
while (i < jamoSeq.Count)
{
var ch = jamoSeq[i];
// 변환 불가 문자 (공백, 숫자, 특수문자 등)
if (!IsKorJamo(ch))
{
result.Append(ch);
i++;
continue;
}
// 자모 덩어리 추출
var jamoBlock = new List<char>();
var j = i;
while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j]))
jamoBlock.Add(jamoSeq[j++]);
// 자모 블록을 한글로 조합
result.Append(ComposeHangul(jamoBlock));
i = j;
}
return result.ToString();
}
private static bool IsKorJamo(char c)
{
return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ');
}
private static string ComposeHangul(List<char> jamos)
{
var sb = new StringBuilder();
var idx = 0;
while (idx < jamos.Count)
{
var c = jamos[idx];
if (IsVowel(c))
{
// 단독 모음 → ㅇ + 모음
sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0));
idx++;
continue;
}
// 자음: 초성 후보
var cho = c;
var choI = ChoIdx(cho);
if (choI < 0) { sb.Append(c); idx++; continue; }
idx++;
if (idx >= jamos.Count || IsConsonantOnly(jamos[idx]))
{
// 단독 초성
sb.Append(cho);
continue;
}
// 중성
var v1 = jamos[idx];
var jungI = JungIdx(v1);
if (jungI < 0) { sb.Append(cho); continue; }
idx++;
// 복합 모음 시도
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv))
{
jungI = JungIdx(cv);
idx++;
}
}
// 종성 후보
if (idx >= jamos.Count)
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
var next = jamos[idx];
if (IsVowel(next))
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
// 종성 자음
var jongI = JongIdx(next);
if (jongI <= 0)
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
idx++;
// 다음 모음 있으면 종성→초성 이동
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
// 복합 종성 분리 확인
if (idx + 1 < jamos.Count && IsVowel(jamos[idx]))
{
// 분리 없음: next 자음 → 다음 음절 초성
}
sb.Append(MakeSyllable(choI, jungI, 0));
idx--; // next 자음을 다음 루프에서 초성으로 사용
continue;
}
// 복합 종성 시도
if (idx < jamos.Count && !IsVowel(jamos[idx]))
{
var next2 = jamos[idx];
var jongChar = Jongseong[jongI];
if (CompoundJong.TryGetValue((jongChar, next2), out var cj))
{
var cji = JongIdx(cj);
if (cji > 0)
{
// 다음에 모음이 있으면 복합 종성 분리
if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1]))
{
sb.Append(MakeSyllable(choI, jungI, jongI));
// next2는 다음 음절 초성으로
idx--; // next2를 다시 처리
idx++;
continue;
}
jongI = cji;
idx++;
}
}
// 다음에 모음이 있으면 종성→초성
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
sb.Append(MakeSyllable(choI, jungI, 0));
idx -= 2;
idx++;
continue;
}
}
sb.Append(MakeSyllable(choI, jungI, jongI));
}
return sb.ToString();
}
// 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가)
private static bool IsConsonantOnly(char c)
{
if (!IsKorJamo(c)) return false;
if (IsVowel(c)) return false;
return JongIdx(c) <= 0;
}
// ── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string inputText;
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드에서 읽기
string? clipText = null;
try
{
Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipText = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(clipText))
{
items.Add(new LauncherItem("한/영 타이핑 교정",
"fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력",
null, null, Symbol: "\uE8AC"));
items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
inputText = clipText;
}
else
{
inputText = q;
}
var converted = ConvertEngToKor(inputText);
if (converted == inputText)
{
items.Add(new LauncherItem("변환할 영타 오류가 없습니다",
$"입력: {inputText}", null, null, Symbol: "\uE8AC"));
}
else
{
items.Add(new LauncherItem(
converted,
$"영타 교정 결과 · Enter: 클립보드 복사",
null, ("copy", converted), Symbol: "\uE8AC"));
items.Add(new LauncherItem(
$"원본: {(inputText.Length > 50 ? inputText[..50] + "" : inputText)}",
"", null, null, Symbol: "\uE8AC"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,237 @@
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L29-2: 명령 체인(워크플로우) 핸들러. "flow" 프리픽스로 사용합니다.
///
/// 예: flow → 등록된 플로우 목록
/// flow add 출근준비 "remind 09:00 회의" > "today" > "todo list" → 플로우 추가
/// flow 출근준비 → 플로우 실행
/// flow del 출근준비 → 플로우 삭제
/// flow edit 출근준비 → 플로우 명령 목록 표시 (클립보드 복사)
/// Enter → 저장된 명령들을 순서대로 런처에 실행 (클립보드에 명령 목록 복사).
/// Alfred 워크플로우 경량 대응.
/// 저장: %APPDATA%\AxCopilot\flows.json
/// </summary>
public class FlowHandler : IActionHandler
{
public string? Prefix => "flow";
public PluginMetadata Metadata => new(
"명령 체인",
"여러 명령을 묶어 순서대로 실행 (워크플로우)",
"1.0",
"AX");
private sealed record FlowEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("commands")] List<string> Commands,
[property: JsonPropertyName("created")] DateTime Created);
private static readonly string DataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "flows.json");
private static readonly JsonSerializerOptions JsonOpt = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var flows = Load();
// ── add 명령 ──────────────────────────────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx < 1)
{
items.Add(new LauncherItem("사용법: flow add {이름} {명령1} > {명령2} > ...",
"예: flow add 출근 \"today\" > \"todo list\" > \"remind 09:00 회의\"",
null, null, Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var name = rest[..spaceIdx];
var cmdStr = rest[(spaceIdx + 1)..].Trim();
var commands = ParseCommands(cmdStr);
if (commands.Count == 0)
{
items.Add(new LauncherItem("명령을 > 로 구분해 입력하세요",
"예: \"today\" > \"todo list\"",
null, null, Symbol: Themes.Symbols.Warning));
}
else
{
items.Add(new LauncherItem(
$"플로우 저장: {name} ({commands.Count}개 명령)",
string.Join(" → ", commands),
null, ("add", name, commands), Symbol: "\uE710"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del 명령 ──────────────────────────────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var name = q[4..].Trim();
var found = flows.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
items.Add(new LauncherItem($"플로우 삭제: {found.Name}",
$"{found.Commands.Count}개 명령 · {string.Join(" ", found.Commands)}",
null, ("del", found.Name), Symbol: "\uE74D"));
}
else
{
items.Add(new LauncherItem($"'{name}' 플로우를 찾을 수 없습니다",
"flow del {이름}", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
if (flows.Count == 0)
{
items.Add(new LauncherItem("등록된 명령 체인이 없습니다",
"flow add {이름} {명령1} > {명령2} > ... 로 추가하세요",
null, null, Symbol: "\uE8A0"));
items.Add(new LauncherItem("예시: flow add 출근 \"today\" > \"todo list\"",
"오늘 업무 뷰 → 할일 목록 순서대로 실행",
null, null, Symbol: Themes.Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"명령 체인 {flows.Count}개",
"Enter: 명령 목록 클립보드 복사 · flow add/del 로 관리",
null, null, Symbol: "\uE8A0"));
foreach (var f in flows)
{
items.Add(new LauncherItem(
$"▶ {f.Name} ({f.Commands.Count}단계)",
string.Join(" → ", f.Commands),
null, ("run", f), Symbol: "\uE768"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 이름 검색 → 실행 ──────────────────────────────────────────────────
var match = flows.FirstOrDefault(f => f.Name.Equals(q, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
items.Add(new LauncherItem(
$"▶ {match.Name} 실행",
string.Join(" → ", match.Commands),
null, ("run", match), Symbol: "\uE768"));
for (int i = 0; i < match.Commands.Count; i++)
items.Add(new LauncherItem($" {i + 1}. {match.Commands[i]}", "",
null, null, Symbol: Themes.Symbols.Terminal));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 부분 매칭
var searched = flows.Where(f =>
f.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
f.Commands.Any(c => c.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
if (searched.Count > 0)
{
foreach (var f in searched)
items.Add(new LauncherItem($"▶ {f.Name} ({f.Commands.Count}단계)",
string.Join(" → ", f.Commands),
null, ("run", f), Symbol: "\uE768"));
}
else
{
items.Add(new LauncherItem($"'{q}' 플로우를 찾을 수 없습니다",
"flow add {이름} {명령} > {명령} 으로 추가하세요",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("add", string name, List<string> commands))
{
var flows = Load();
flows.RemoveAll(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
flows.Add(new FlowEntry(name, commands, DateTime.Now));
Save(flows);
NotificationService.Notify("flow", $"'{name}' 플로우가 저장되었습니다. ({commands.Count}단계)");
}
else if (item.Data is ("del", string delName))
{
var flows = Load();
flows.RemoveAll(f => f.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
Save(flows);
NotificationService.Notify("flow", $"'{delName}' 플로우가 삭제되었습니다.");
}
else if (item.Data is ("run", FlowEntry flow))
{
// 명령 목록을 클립보드에 복사 (사용자가 순서대로 런처에 입력)
var text = string.Join("\n", flow.Commands.Select((c, i) => $"{i + 1}. {c}"));
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("flow", $"'{flow.Name}' 명령 {flow.Commands.Count}개가 클립보드에 복사되었습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ─── 명령 파싱 ────────────────────────────────────────────────────────────
private static List<string> ParseCommands(string input)
{
// "cmd1" > "cmd2" > "cmd3" 또는 cmd1 > cmd2 > cmd3
return input.Split('>')
.Select(s => s.Trim().Trim('"').Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
}
// ─── JSON I/O ─────────────────────────────────────────────────────────────
private static List<FlowEntry> Load()
{
try
{
if (!File.Exists(DataPath)) return [];
var json = File.ReadAllText(DataPath);
return JsonSerializer.Deserialize<List<FlowEntry>>(json) ?? [];
}
catch { return []; }
}
private static void Save(List<FlowEntry> list)
{
try
{
var dir = Path.GetDirectoryName(DataPath)!;
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
}
catch { }
}
}

View File

@@ -0,0 +1,136 @@
using System.Windows;
using System.Windows.Media;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다.
///
/// 예: font → 설치된 폰트 전체 목록
/// font 맑은 → "맑은" 포함 폰트 검색
/// font malgun → 영문 이름으로 검색
/// font mono → "mono" 포함 폰트 목록
/// font nanum → 나눔 폰트 목록
/// Enter → 폰트 이름을 클립보드에 복사.
/// </summary>
public class FontHandler : IActionHandler
{
public string? Prefix => "font";
public PluginMetadata Metadata => new(
"Font",
"시스템 폰트 목록 — 검색 · 이름 복사",
"1.0",
"AX");
// 폰트 목록 캐시 (최초 1회 로드)
private static List<string>? _fontCache;
private static readonly object _lock = new();
private static List<string> GetFonts()
{
if (_fontCache != null) return _fontCache;
lock (_lock)
{
if (_fontCache != null) return _fontCache;
try
{
_fontCache = Fonts.SystemFontFamilies
.Select(f => f.Source)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch
{
_fontCache = new List<string>();
}
return _fontCache;
}
}
// 주목할 만한 폰트 그룹 키워드
private static readonly (string Label, string Keyword)[] FontGroups =
[
("한글 폰트", "malgun"),
("나눔 폰트", "nanum"),
("코딩용 폰트", "mono"),
("Arial 계열", "arial"),
("Times 계열", "times"),
("Consolas", "consolas"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var fonts = GetFonts();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"설치된 폰트 {fonts.Count}개",
"font <검색어> 로 필터링",
null, null, Symbol: "\uE8D2"));
// 그룹 힌트
foreach (var (label, kw) in FontGroups)
{
var cnt = fonts.Count(f => f.Contains(kw, StringComparison.OrdinalIgnoreCase));
if (cnt > 0)
items.Add(new LauncherItem(label, $"{cnt}개 · font {kw}", null, null, Symbol: "\uE8D2"));
}
// 첫 15개 표시
foreach (var f in fonts.Take(15))
items.Add(MakeFontItem(f));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 검색
var filtered = fonts
.Where(f => f.Contains(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (filtered.Count == 0)
{
items.Add(new LauncherItem("결과 없음",
$"'{q}' 포함 폰트가 없습니다", null, null, Symbol: "\uE946"));
}
else
{
items.Add(new LauncherItem(
$"'{q}' 검색 결과 {filtered.Count}개",
"전체 복사: 첫 항목 Enter",
null,
("copy", string.Join("\n", filtered)),
Symbol: "\uE8D2"));
foreach (var f in filtered.Take(30))
items.Add(MakeFontItem(f));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Font", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
private static LauncherItem MakeFontItem(string fontName) =>
new(fontName, "폰트 이름 · Enter 복사", null, ("copy", fontName), Symbol: "\uE8D2");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
using System.Diagnostics;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-1: Git 빠른 조회 핸들러. "git" 프리픽스로 사용합니다.
///
/// 예: git → 최근 작업 폴더(또는 현재 앱 폴더)의 git 상태 요약
/// git status → git status --short 출력
/// git log → 최근 커밋 10개
/// git branch → 브랜치 목록 (현재 브랜치 강조)
/// git stash → stash 목록
/// git diff → git diff --stat 요약
/// git pull → git pull 실행
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class GitHandler : IActionHandler
{
public string? Prefix => "git";
public PluginMetadata Metadata => new(
"Git",
"Git 빠른 조회 — git status · log · branch · stash",
"1.0",
"AX");
// ── 서브커맨드 정의 ──────────────────────────────────────────────────────
private static readonly (string Sub, string Args, string Label, string Icon)[] SubCommands =
[
("status", "status --short", "변경 파일 목록", "\uE9F5"),
("log", "log --oneline -10", "최근 커밋 10개", "\uE81C"),
("branch", "branch -a", "브랜치 목록", "\uE8FB"),
("stash", "stash list", "Stash 목록", "\uE7C4"),
("diff", "diff --stat", "변경 통계", "\uE8A1"),
("pull", "pull", "git pull 실행", "\uE8AF"),
];
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 작업 디렉토리 결정
var workDir = FindGitRoot();
if (string.IsNullOrEmpty(q))
{
// 빠른 상태 요약
if (!string.IsNullOrEmpty(workDir))
{
var branch = await RunGitAsync("branch --show-current", workDir, ct);
var statusOut = await RunGitAsync("status --short", workDir, ct);
var changed = statusOut?.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length ?? 0;
items.Add(new LauncherItem(
$"Git: {branch?.Trim() ?? "unknown"}",
changed == 0 ? "변경 없음" : $"{changed}개 파일 변경됨 · {System.IO.Path.GetFileName(workDir)}",
null,
("status_summary", workDir),
Symbol: "\uE9F5"));
}
else
{
items.Add(new LauncherItem("Git 저장소 없음",
"현재 작업 폴더에 .git 디렉토리가 없습니다", null, null,
Symbol: "\uE783"));
}
// 서브커맨드 목록
foreach (var (sub, args, label, icon) in SubCommands)
{
items.Add(new LauncherItem(
$"git {sub}",
label,
null,
(sub, workDir ?? ""),
Symbol: icon));
}
return items;
}
// 서브커맨드 매칭
var matched = SubCommands
.Where(sc => sc.Sub.StartsWith(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matched.Count > 0)
{
foreach (var (sub, args, label, icon) in matched)
{
string? preview = null;
if (!string.IsNullOrEmpty(workDir))
preview = await RunGitAsync(args, workDir, ct);
var subtitle = preview != null
? TruncateLines(preview, 3)
: label;
items.Add(new LauncherItem(
$"git {sub}",
subtitle,
null,
(sub, workDir ?? "", args),
Symbol: icon));
}
}
else
{
// 자유 명령 실행 (git <query>)
string? output = null;
if (!string.IsNullOrEmpty(workDir))
output = await RunGitAsync(q, workDir, ct);
items.Add(new LauncherItem(
$"git {query.Trim()}",
output != null ? TruncateLines(output, 3) : "실행 후 결과 클립보드 복사",
null,
("custom", workDir ?? "", query.Trim()),
Symbol: "\uE9F5"));
}
return items;
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
string? result = null;
switch (item.Data)
{
// 상태 요약 항목
case ("status_summary", string workDir):
result = await RunGitAsync("status", workDir, ct);
break;
// 서브커맨드 항목 (sub, workDir, args)
case (string sub, string workDir, string args):
if (sub == "pull")
{
// pull은 별도 터미널 창으로 실행
if (!string.IsNullOrEmpty(workDir))
{
Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -Command \"cd '{workDir}'; git pull; Read-Host 'Enter '\"",
UseShellExecute = true,
});
}
return;
}
result = await RunGitAsync(args, workDir, ct);
break;
// 서브커맨드 항목 (sub, workDir) — args 없는 경우
case (string sub2, string workDir2):
var found = SubCommands.FirstOrDefault(sc => sc.Sub == sub2);
if (found != default)
result = await RunGitAsync(found.Args, workDir2, ct);
break;
default:
break;
}
if (!string.IsNullOrWhiteSpace(result))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(result));
NotificationService.Notify("Git", "결과를 클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
/// <summary>현재 앱 설정의 작업 폴더에서 .git root를 찾습니다.</summary>
private static string? FindGitRoot()
{
var app = System.Windows.Application.Current as App;
var workDir = app?.SettingsService?.Settings.Llm.WorkFolder ?? "";
if (string.IsNullOrEmpty(workDir) || !System.IO.Directory.Exists(workDir))
workDir = AppDomain.CurrentDomain.BaseDirectory;
// .git 폴더를 찾아 상위로 이동
var dir = new System.IO.DirectoryInfo(workDir);
while (dir != null)
{
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
/// <summary>git 명령을 비동기로 실행하고 출력을 반환합니다.</summary>
private static async Task<string?> RunGitAsync(string args, string workDir, CancellationToken ct)
{
if (string.IsNullOrEmpty(workDir)) return null;
try
{
var psi = new ProcessStartInfo("git", args)
{
WorkingDirectory = workDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var output = await proc.StandardOutput.ReadToEndAsync(ct);
var error = await proc.StandardError.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
var text = output.Trim();
if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(error))
text = error.Trim();
return string.IsNullOrWhiteSpace(text) ? "(출력 없음)" : text;
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
return $"오류: {ex.Message}";
}
}
/// <summary>긴 출력을 maxLines줄로 자릅니다.</summary>
private static string TruncateLines(string text, int maxLines)
{
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length <= maxLines) return string.Join(" · ", lines.Take(maxLines)).Trim();
return string.Join(" · ", lines.Take(maxLines)) + $" … (+{lines.Length - maxLines}줄)";
}
}

View File

@@ -0,0 +1,536 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L17-4: .gitignore 생성기 핸들러. "gitignore" 프리픽스로 사용합니다.
///
/// 예: gitignore → 지원 언어·프레임워크 목록
/// gitignore node → Node.js .gitignore 생성
/// gitignore python → Python .gitignore 생성
/// gitignore csharp → C# / .NET .gitignore 생성
/// gitignore java → Java .gitignore 생성
/// gitignore react → React (Node 기반) .gitignore 생성
/// gitignore node python → 여러 템플릿 병합
/// Enter → .gitignore 내용을 클립보드에 복사.
/// </summary>
public class GitignoreHandler : IActionHandler
{
public string? Prefix => "gitignore";
public PluginMetadata Metadata => new(
"Gitignore",
".gitignore 생성기 — Node·Python·C#·Java·Go·Rust 등 내장 템플릿",
"1.0",
"AX");
// ── 내장 템플릿 ──────────────────────────────────────────────────────────
private static readonly Dictionary<string, (string[] Aliases, string Description, string Content)> Templates =
new(StringComparer.OrdinalIgnoreCase)
{
["node"] = (
["nodejs", "npm", "javascript", "js"],
"Node.js / npm",
"""
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.npm/
.yarn/
package-lock.json
yarn.lock
pnpm-lock.yaml
.env
.env.local
.env.*.local
dist/
build/
.cache/
.parcel-cache/
.vite/
coverage/
.nyc_output/
*.log
.DS_Store
Thumbs.db
"""),
["python"] = (
["py", "django", "flask", "fastapi"],
"Python",
"""
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.env
.venv
env/
venv/
ENV/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.log
.DS_Store
"""),
["csharp"] = (
["cs", "dotnet", ".net", "net", "aspnet", "aspnetcore"],
"C# / .NET",
"""
# C# / .NET
bin/
obj/
*.user
*.suo
.vs/
.vscode/
*.userprefs
*.pidb
*.booproj
*.svd
*.userprefs
packages/
*.nupkg
**/[Bb]in/
**/[Oo]bj/
**/[Ll]og/
**/[Ll]ogs/
TestResults/
[Tt]est[Rr]esult*/
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
.DS_Store
"""),
["java"] = (
["gradle", "maven", "mvn", "spring"],
"Java / Maven / Gradle",
"""
# Java / Maven / Gradle
*.class
*.log
*.ctxt
.mtj.tmp/
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
replay_pid*
target/
build/
out/
.gradle/
.mvn/
!.mvn/wrapper/maven-wrapper.jar
!.mvn/wrapper/maven-wrapper.properties
.idea/
*.iws
*.iml
*.ipr
.classpath
.project
.settings/
.DS_Store
"""),
["go"] = (
["golang"],
"Go (Golang)",
"""
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
go.work.sum
vendor/
.env
dist/
bin/
.DS_Store
"""),
["rust"] = (
["cargo"],
"Rust / Cargo",
"""
# Rust / Cargo
/target/
Cargo.lock
**/*.rs.bk
*.pdb
.env
.DS_Store
"""),
["react"] = (
["nextjs", "next", "vue", "vite", "svelte"],
"React / Next.js / Vue / Vite",
"""
# React / Next.js / Vite
node_modules/
.next/
out/
build/
dist/
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
Thumbs.db
.cache/
.parcel-cache/
coverage/
*.log
"""),
["flutter"] = (
["dart"],
"Flutter / Dart",
"""
# Flutter / Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
flutter_*.png
linked_*.ds
unlinked.ds
unlinked_spec.ds
*.log
.DS_Store
"""),
["android"] = (
["kotlin", "gradle-android"],
"Android",
"""
# Android
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.jks
*.keystore
google-services.json
"""),
["ios"] = (
["swift", "xcode", "objc", "objective-c"],
"iOS / Swift / Xcode",
"""
# iOS / Swift / Xcode
build/
DerivedData/
.build/
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
xcuserdata/
*.xcworkspace
!default.xcworkspace
.swiftpm/
Packages/
*.resolved
*.xccheckout
*.moved-aside
*.xcuserstate
.DS_Store
"""),
["unity"] = (
[],
"Unity",
"""
# Unity
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
/[Uu]ser[Ss]ettings/
/[Mm]emoryCaptures/
/[Rr]ecordings/
/[Pp]rofiles/
/[Pp]rofile[Ss]
/[Aa]ssets/Plugins/EditorVR.meta
/[Pp]ackages/
!/[Pp]ackages/manifest.json
!/[Pp]ackages/packages-lock.json
/*.sln
/*.csproj
/.vs/
.DS_Store
"""),
["windows"] = (
["win", "powershell", "ps"],
"Windows 공통",
"""
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
"""),
["macos"] = (
["mac", "osx"],
"macOS 공통",
"""
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
"""),
["linux"] = (
[],
"Linux 공통",
"""
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
*.swp
*.swo
"""),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(".gitignore 생성기",
"예: gitignore node / gitignore python / gitignore csharp / gitignore node python",
null, null, Symbol: "\uEA3C"));
items.Add(new LauncherItem($"── 지원 템플릿 {Templates.Count}개 ──", "", null, null, Symbol: "\uEA3C"));
foreach (var (key, (aliases, desc, _)) in Templates.OrderBy(t => t.Key))
{
var aliasStr = aliases.Length > 0 ? $" ({string.Join(", ", aliases.Take(3))})" : "";
items.Add(new LauncherItem(key, $"{desc}{aliasStr}", null, ("gen", key), Symbol: "\uEA3C"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 여러 키워드 → 병합
var keywords = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var matched = new List<string>(); // 템플릿 키 목록
foreach (var kw in keywords)
{
var found = FindTemplate(kw);
if (found != null && !matched.Contains(found))
matched.Add(found);
}
if (matched.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 템플릿을 찾을 수 없습니다",
$"지원: {string.Join(", ", Templates.Keys.Take(10))}…",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 템플릿 생성
if (matched.Count == 1)
{
var key = matched[0];
var (_, desc, content) = Templates[key];
var trimmed = content.Trim();
items.Add(new LauncherItem($".gitignore [{key}]",
$"{desc} · {trimmed.Split('\n').Length}줄 · Enter → 복사",
null, ("copy", trimmed), Symbol: "\uEA3C"));
// 미리보기
foreach (var line in trimmed.Split('\n').Take(12))
items.Add(new LauncherItem(line, "", null, null, Symbol: "\uEA3C"));
if (trimmed.Split('\n').Length > 12)
items.Add(new LauncherItem($"… 외 {trimmed.Split('\n').Length - 12}줄",
"전체는 첫 항목 Enter로 복사", null, null, Symbol: "\uEA3C"));
}
else
{
// 다중 병합
var sb = new System.Text.StringBuilder();
var totalLines = 0;
foreach (var key in matched)
{
var (_, desc, content) = Templates[key];
sb.AppendLine($"# ===== {desc} =====");
sb.AppendLine(content.Trim());
sb.AppendLine();
totalLines += content.Split('\n').Length;
}
var merged = sb.ToString().TrimEnd();
items.Add(new LauncherItem(
$".gitignore 병합 [{string.Join(" + ", matched)}]",
$"{totalLines}줄 · Enter → 복사",
null, ("copy", merged), Symbol: "\uEA3C"));
foreach (var key in matched)
{
var (_, desc, _) = Templates[key];
items.Add(new LauncherItem($"[{key}]", desc, null, ("gen", key), Symbol: "\uEA3C"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Gitignore", ".gitignore 내용을 복사했습니다.");
}
catch { /* 비핵심 */ }
break;
case ("gen", string key):
if (Templates.TryGetValue(key, out var t))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(t.Content.Trim()));
NotificationService.Notify("Gitignore", $"[{key}] .gitignore 복사됨");
}
catch { /* 비핵심 */ }
}
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string? FindTemplate(string keyword)
{
// 직접 키 일치
if (Templates.ContainsKey(keyword)) return keyword;
// 별칭 검색
foreach (var (key, (aliases, _, _)) in Templates)
{
if (aliases.Any(a => a.Equals(keyword, StringComparison.OrdinalIgnoreCase)))
return key;
}
// 부분 일치
var partial = Templates.Keys
.FirstOrDefault(k => k.Contains(keyword, StringComparison.OrdinalIgnoreCase));
return partial;
}
}

View File

@@ -0,0 +1,315 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-1: 16진수·바이트 변환기 핸들러. "hex" 프리픽스로 사용합니다.
///
/// 예: hex → 클립보드 텍스트 → hex 변환
/// hex hello → "hello" → 68 65 6C 6C 6F
/// hex 68656c6c6f → hex → "hello" 디코딩
/// hex dump hello world → 헥스 덤프 형식 (오프셋·hex·ASCII)
/// hex 0xFF → 0xFF = 255 (십진수·이진수·문자)
/// hex add 0x1A 0x2B → hex 덧셈
/// hex xor 0xAB 0xCD → bitwise XOR
/// hex and 0xFF 0x0F → bitwise AND
/// hex or 0xA0 0x0F → bitwise OR
/// hex not 0xFF → bitwise NOT (8비트)
/// hex bytes <n> → n바이트 크기 단위 표시 (KB·MB·GB)
/// Enter → 결과 복사.
/// </summary>
public class HexHandler : IActionHandler
{
public string? Prefix => "hex";
public PluginMetadata Metadata => new(
"Hex",
"16진수·바이트 변환기 — 텍스트↔hex·덤프·비트연산·크기 단위",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 읽기
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText().Trim();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("16진수·바이트 변환기",
"hex <텍스트> / hex <hexstr> / hex dump <text> / hex 0xFF / hex bytes <n>",
null, null, Symbol: "\uE8EF"));
if (!string.IsNullOrWhiteSpace(clipboard))
items.AddRange(BuildFromText(clipboard!, brief: true));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// hex dump
if (sub == "dump")
{
var text = parts.Length > 1 ? string.Join(" ", parts[1..]) : clipboard ?? "";
if (string.IsNullOrEmpty(text))
{ items.Add(ErrorItem("텍스트를 입력하거나 클립보드에 복사하세요")); }
else
items.AddRange(BuildDump(text));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// hex bytes <n>
if (sub == "bytes" && parts.Length >= 2 &&
long.TryParse(parts[1], out var byteCount))
{
items.AddRange(BuildByteSize(byteCount));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// hex add/xor/and/or/not (비트 연산)
if (sub is "add" or "xor" or "and" or "or" or "not")
{
items.AddRange(BuildBitOp(sub, parts));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 단일 hex 값 (0xFF, 0xAB, FF, AB...)
if (TryParseHexValue(parts[0], out var hexVal))
{
items.AddRange(BuildFromHexValue(hexVal, parts[0]));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 순수 hex 문자열인지 판단 (2자 이상, 모두 hex digit, 짝수 길이)
var raw = parts[0].Replace(" ", "").Replace("-", "").Replace(":", "");
if (raw.Length >= 2 && raw.Length % 2 == 0 && IsAllHex(raw))
{
items.AddRange(BuildFromHexString(raw));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 일반 텍스트 → hex 변환
var input = string.Join(" ", parts);
items.AddRange(BuildFromText(input, brief: false));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Hex", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildFromText(string text, bool brief)
{
var bytes = Encoding.UTF8.GetBytes(text);
var hex = BitConverter.ToString(bytes).Replace("-", "");
var spaced = string.Join(" ", Enumerable.Range(0, hex.Length / 2)
.Select(i => hex.Substring(i * 2, 2)));
yield return new LauncherItem(spaced,
$"텍스트 → Hex ({bytes.Length} bytes) · Enter 복사",
null, ("copy", spaced), Symbol: "\uE8EF");
if (!brief)
{
yield return CopyItem("공백 없음", hex);
yield return CopyItem("소문자", hex.ToLowerInvariant());
yield return CopyItem("0x 접두사", string.Join(" ", Enumerable.Range(0, hex.Length / 2)
.Select(i => "0x" + hex.Substring(i * 2, 2))));
yield return CopyItem("바이트 수", $"{bytes.Length} bytes");
var b64 = Convert.ToBase64String(bytes);
yield return CopyItem("Base64", b64);
}
}
private static IEnumerable<LauncherItem> BuildFromHexString(string hex)
{
hex = hex.ToUpperInvariant();
byte[]? bytes = null;
string? parseError = null;
try
{
bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => Convert.ToByte(hex.Substring(i * 2, 2), 16))
.ToArray();
}
catch { parseError = "올바른 hex 문자열이 아닙니다"; }
if (parseError != null)
{
yield return ErrorItem(parseError);
yield break;
}
var safeBytes = bytes!;
var utf8 = TrySafeUtf8(safeBytes);
var ascii = TrySafeAscii(safeBytes);
yield return new LauncherItem($"Hex → 텍스트",
$"{safeBytes.Length} bytes · UTF-8 디코딩",
null, null, Symbol: "\uE8EF");
if (utf8 != null) yield return CopyItem("UTF-8 텍스트", utf8);
if (ascii != null && ascii != utf8) yield return CopyItem("ASCII 텍스트", ascii);
yield return CopyItem("바이트 수", $"{safeBytes.Length}");
// 숫자 해석 (최대 8바이트)
if (safeBytes.Length <= 8)
{
var padded = new byte[8];
Buffer.BlockCopy(safeBytes, 0, padded, 8 - safeBytes.Length, safeBytes.Length);
var bigEndian = BitConverter.IsLittleEndian
? BitConverter.ToUInt64(padded.Reverse().ToArray())
: BitConverter.ToUInt64(padded);
yield return CopyItem($"정수 (big-endian)", bigEndian.ToString());
}
}
private static IEnumerable<LauncherItem> BuildFromHexValue(ulong val, string original)
{
yield return new LauncherItem($"{original} = {val}",
$"16진수 → 십진수 · Enter 복사",
null, ("copy", val.ToString()), Symbol: "\uE8EF");
yield return CopyItem("십진수", val.ToString());
yield return CopyItem("16진수", $"0x{val:X}");
yield return CopyItem("8진수", $"0o{Convert.ToString((long)val, 8)}");
yield return CopyItem("이진수", $"0b{Convert.ToString((long)val, 2)}");
if (val <= 127) yield return CopyItem("ASCII 문자", ((char)val).ToString());
yield return CopyItem("NOT (64bit)", $"0x{(~val):X16}");
}
private static IEnumerable<LauncherItem> BuildDump(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var lines = new List<string>();
var sb = new StringBuilder();
for (int i = 0; i < bytes.Length; i += 16)
{
var chunk = bytes.Skip(i).Take(16).ToArray();
var hexPart = string.Join(" ", chunk.Select(b => $"{b:X2}")).PadRight(47);
var asciiPart = new string(chunk.Select(b => b >= 32 && b < 127 ? (char)b : '.').ToArray());
var line = $"{i:X8} {hexPart} |{asciiPart}|";
lines.Add(line);
}
var dump = string.Join("\n", lines);
yield return new LauncherItem("헥스 덤프",
$"{bytes.Length} bytes · {lines.Count} 행", null, ("copy", dump), Symbol: "\uE8EF");
foreach (var line in lines.Take(8))
yield return new LauncherItem(line, "", null, ("copy", line), Symbol: "\uE8EF");
if (lines.Count > 8)
yield return new LauncherItem($"... ({lines.Count - 8}행 더 있음)",
"전체 복사하려면 상단 항목 Enter", null, null, Symbol: "\uE8EF");
}
private static IEnumerable<LauncherItem> BuildBitOp(string op, string[] parts)
{
if (op == "not")
{
if (parts.Length < 2 || !TryParseHexValue(parts[1], out var v))
{ yield return ErrorItem("예: hex not 0xFF"); yield break; }
var r8 = (byte)(~(byte)v);
var r16 = (ushort)(~(ushort)v);
yield return new LauncherItem($"NOT 0x{v:X} = 0x{r8:X2} (8bit) / 0x{r16:X4} (16bit)",
"비트 반전", null, ("copy", $"0x{r8:X2}"), Symbol: "\uE8EF");
yield return CopyItem("NOT 8bit", $"0x{r8:X2} ({r8})");
yield return CopyItem("NOT 16bit", $"0x{r16:X4} ({r16})");
yield return CopyItem("NOT 64bit", $"0x{(~v):X16}");
yield break;
}
if (parts.Length < 3 || !TryParseHexValue(parts[1], out var a) || !TryParseHexValue(parts[2], out var b))
{ yield return ErrorItem($"예: hex {op} 0xAB 0xCD"); yield break; }
ulong result = op switch
{
"add" => a + b,
"xor" => a ^ b,
"and" => a & b,
"or" => a | b,
_ => 0
};
var symbol = op switch { "add" => "+", "xor" => "^", "and" => "&", "or" => "|", _ => "?" };
yield return new LauncherItem($"0x{a:X} {symbol} 0x{b:X} = 0x{result:X}",
$"{a} {symbol} {b} = {result} · Enter 복사",
null, ("copy", $"0x{result:X}"), Symbol: "\uE8EF");
yield return CopyItem("16진수", $"0x{result:X}");
yield return CopyItem("십진수", result.ToString());
yield return CopyItem("이진수", $"0b{Convert.ToString((long)result, 2)}");
}
private static IEnumerable<LauncherItem> BuildByteSize(long bytes)
{
yield return new LauncherItem($"{bytes:N0} bytes",
"크기 단위 변환 · Enter 복사", null, ("copy", bytes.ToString()), Symbol: "\uE8EF");
yield return CopyItem("Bytes", $"{bytes:N0}");
yield return CopyItem("KB (1000)", $"{bytes / 1000.0:F3}");
yield return CopyItem("KiB (1024)",$"{bytes / 1024.0:F3}");
yield return CopyItem("MB (1000)", $"{bytes / 1_000_000.0:F3}");
yield return CopyItem("MiB (1024)",$"{bytes / 1_048_576.0:F3}");
yield return CopyItem("GB (1000)", $"{bytes / 1_000_000_000.0:F4}");
yield return CopyItem("GiB (1024)",$"{bytes / 1_073_741_824.0:F4}");
if (bytes >= 1_000_000_000_000L)
yield return CopyItem("TB (1000)", $"{bytes / 1_000_000_000_000.0:F4}");
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static bool TryParseHexValue(string s, out ulong val)
{
val = 0;
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val);
if (s.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val);
return false;
}
private static bool IsAllHex(string s) =>
s.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F');
private static string? TrySafeUtf8(byte[] b)
{
try { var s = Encoding.UTF8.GetString(b); return s; }
catch { return null; }
}
private static string? TrySafeAscii(byte[] b)
{
try { return Encoding.ASCII.GetString(b); }
catch { return null; }
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8EF");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,253 @@
using System.IO;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다.
///
/// 예: hosts → 현재 hosts 파일 항목 목록
/// hosts search dev → "dev" 포함 항목 필터
/// hosts open → hosts 파일을 메모장으로 열기
/// hosts copy → 전체 hosts 내용 클립보드 복사
/// Enter → 해당 항목을 클립보드에 복사.
/// </summary>
public class HostsHandler : IActionHandler
{
public string? Prefix => "hosts";
public PluginMetadata Metadata => new(
"Hosts",
"Windows hosts 파일 뷰어 — 항목 조회 · 검색 · 복사",
"1.0",
"AX");
private static readonly string HostsPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
@"drivers\etc\hosts");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var entries = ReadHostsEntries();
if (string.IsNullOrWhiteSpace(q))
{
var activeCount = entries.Count(e => !e.IsComment);
var commentCount = entries.Count(e => e.IsComment && e.IsDisabledEntry);
items.Add(new LauncherItem(
$"hosts 파일 활성 {activeCount}개" + (commentCount > 0 ? $" · 비활성 {commentCount}개" : ""),
HostsPath,
null,
("copy_path", HostsPath),
Symbol: "\uE8D2"));
items.Add(new LauncherItem("파일 열기 (메모장)", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
items.Add(new LauncherItem("전체 내용 복사", $"{entries.Count}줄", null, ("copy_all", ""), Symbol: "\uE8A5"));
// 유효 항목 목록
foreach (var e in entries.Where(e => !e.IsComment).Take(20))
items.Add(MakeEntryItem(e));
// 비활성 항목 (주석 처리된 IP 항목)
var disabled = entries.Where(e => e.IsDisabledEntry).Take(5).ToList();
if (disabled.Count > 0)
{
items.Add(new LauncherItem($"── 비활성 항목 {disabled.Count}개 ──", "", null, null, Symbol: "\uE8D2"));
foreach (var e in disabled)
items.Add(MakeEntryItem(e));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "open":
items.Add(new LauncherItem("메모장으로 열기", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
break;
case "copy":
case "copy_all":
{
var content = ReadHostsRaw();
items.Add(new LauncherItem("전체 내용 복사", $"{content.Split('\n').Length}줄 · Enter 복사",
null, ("copy", content), Symbol: "\uE8A5"));
break;
}
case "search":
case "find":
{
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(keyword))
{
items.Add(new LauncherItem("검색어 입력", "예: hosts search dev", null, null, Symbol: "\uE783"));
break;
}
var filtered = entries.Where(e =>
e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
else
foreach (var e in filtered.Take(20))
items.Add(MakeEntryItem(e));
break;
}
default:
{
// 검색어로 처리
var keyword = q.ToLowerInvariant();
var filtered = entries.Where(e =>
e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
else
foreach (var e in filtered.Take(20))
items.Add(MakeEntryItem(e));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("Hosts", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
break;
case ("copy_path", string path):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
NotificationService.Notify("Hosts", "경로가 복사되었습니다.");
}
catch { /* 비핵심 */ }
break;
case ("copy_all", _):
try
{
var content = ReadHostsRaw();
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(content));
NotificationService.Notify("Hosts", $"hosts 파일 내용 복사됨 ({content.Split('\n').Length}줄)");
}
catch { /* 비핵심 */ }
break;
case ("open", string path):
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "notepad.exe",
Arguments = $"\"{path}\"",
UseShellExecute = false,
});
}
catch (Exception ex)
{
NotificationService.Notify("Hosts", $"열기 실패: {ex.Message}");
}
break;
}
return Task.CompletedTask;
}
// ── hosts 파일 파서 ───────────────────────────────────────────────────────
private record HostsEntry(string IpAddress, string Hostname, string RawLine,
bool IsComment, bool IsDisabledEntry);
private static List<HostsEntry> ReadHostsEntries()
{
var result = new List<HostsEntry>();
string[] lines;
try { lines = File.ReadAllLines(HostsPath, Encoding.UTF8); }
catch { return result; }
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
// 순수 주석 (# 으로 시작, IP 없음)
if (line.StartsWith('#'))
{
// 비활성 IP 항목인지 확인 (예: "# 127.0.0.1 example.com")
var inner = line[1..].Trim();
if (TryParseIpEntry(inner, out var disIp, out var disHost))
{
result.Add(new HostsEntry(disIp, disHost, rawLine, true, true));
}
// 순수 주석은 목록에서 제외
continue;
}
// IP 항목 (인라인 주석 포함 가능)
var withoutComment = line.Contains('#') ? line[..line.IndexOf('#')].Trim() : line;
if (TryParseIpEntry(withoutComment, out var ip, out var host))
{
result.Add(new HostsEntry(ip, host, rawLine, false, false));
}
}
return result;
}
private static bool TryParseIpEntry(string line, out string ip, out string host)
{
ip = host = "";
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2) return false;
// 첫 토큰이 IP 주소 형태인지 간단히 확인
if (!System.Net.IPAddress.TryParse(parts[0], out _)) return false;
ip = parts[0];
host = parts[1];
return true;
}
private static string ReadHostsRaw()
{
try { return File.ReadAllText(HostsPath, Encoding.UTF8); }
catch { return "(hosts 파일을 읽을 수 없습니다)"; }
}
private static LauncherItem MakeEntryItem(HostsEntry e)
{
var prefix = e.IsComment ? "# " : "";
var icon = e.IsComment ? "\uE946" : "\uE8D2";
var subtitle = e.IsComment ? "비활성 항목" : e.IpAddress;
return new LauncherItem(
$"{prefix}{e.Hostname}",
subtitle,
null,
("copy", $"{e.IpAddress}\t{e.Hostname}"),
Symbol: icon);
}
}

View File

@@ -0,0 +1,155 @@
using AxCopilot.Core;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-1: 전용 핫키 관리 핸들러.
/// 예: hotkey → 등록된 전용 핫키 목록 표시
/// hotkey 1doc → 라벨 또는 대상에 "1doc" 포함 항목 필터
/// Enter 시 해당 핫키 항목의 대상을 실행합니다.
/// </summary>
public class HotkeyHandler : IActionHandler
{
private readonly SettingsService? _settings;
public HotkeyHandler(SettingsService? settings = null)
{
_settings = settings;
}
public string? Prefix => "hotkey";
public PluginMetadata Metadata => new(
"HotkeyManager",
"전용 핫키 목록 관리",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var items = new List<LauncherItem>();
var hotkeys = _settings?.Settings.CustomHotkeys ?? new List<Models.HotkeyAssignment>();
if (hotkeys.Count == 0)
{
items.Add(new LauncherItem(
"등록된 전용 핫키 없음",
"설정 → 전용 핫키 탭에서 항목별 글로벌 단축키를 등록하세요",
null,
"__open_settings__",
Symbol: "\uE713"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var filter = query.Trim().ToLowerInvariant();
foreach (var h in hotkeys)
{
if (!string.IsNullOrEmpty(filter) &&
!h.Label.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!h.Hotkey.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!h.Target.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var typeSymbol = h.Type switch
{
"url" => Symbols.Globe,
"folder" => Symbols.Folder,
"command" => "\uE756",
_ => Symbols.App
};
var label = string.IsNullOrWhiteSpace(h.Label)
? System.IO.Path.GetFileNameWithoutExtension(h.Target)
: h.Label;
items.Add(new LauncherItem(
$"[{h.Hotkey}] {label}",
h.Target,
null,
h,
Symbol: typeSymbol));
}
// 설정 단축키 안내 항목
items.Add(new LauncherItem(
"전용 핫키 설정 열기",
"설정 → 전용 핫키 탭에서 핫키를 추가하거나 제거합니다",
null,
"__open_settings__",
Symbol: "\uE713"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s && s == "__open_settings__")
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var app = System.Windows.Application.Current as AxCopilot.App;
app?.OpenSettingsFromChat();
});
return Task.CompletedTask;
}
if (item.Data is Models.HotkeyAssignment ha)
{
ExecuteHotkeyTarget(ha.Target, ha.Type);
}
return Task.CompletedTask;
}
/// <summary>전용 핫키 대상을 타입에 따라 실행합니다.</summary>
internal static void ExecuteHotkeyTarget(string target, string type)
{
try
{
switch (type)
{
case "url":
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = target,
UseShellExecute = true
});
break;
case "folder":
System.Diagnostics.Process.Start("explorer.exe", target);
break;
case "command":
var parts = target.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var cmdFile = parts[0];
var cmdArgs = parts.Length > 1 ? parts[1] : "";
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = cmdFile,
Arguments = cmdArgs,
UseShellExecute = true
});
break;
default: // app
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = target,
UseShellExecute = true
});
break;
}
Services.LogService.Info($"전용 핫키 실행: {target} ({type})");
}
catch (Exception ex)
{
Services.LogService.Error($"전용 핫키 실행 오류: {target} — {ex.Message}");
}
}
}

View File

@@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L12-1: HTTP 요청 테스터 핸들러. "http" 프리픽스로 사용합니다.
///
/// 예: http example.com → GET 요청 (http:// 자동 추가)
/// http https://api.example → GET 요청 + 응답 코드·시간
/// http head https://example → HEAD 요청
/// http post https://example → POST (빈 바디)
/// http 192.168.1.1 → 내부 IP GET
/// Enter → 응답 요약을 클립보드에 복사.
///
/// ⚠ 외부 URL: 사내 모드 차단. 내부 IP(10./192.168./172.16-31.)는 허용.
/// </summary>
public class HttpTesterHandler : IActionHandler
{
public string? Prefix => "http";
public PluginMetadata Metadata => new(
"HTTP",
"HTTP 요청 테스터 — GET · HEAD · POST · 응답 코드",
"1.0",
"AX");
private static readonly HttpClient _client = new(new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 3,
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
})
{
Timeout = TimeSpan.FromSeconds(10),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("HTTP 요청 테스터",
"예: http example.com / http head https://api / http post https://url",
null, null, Symbol: "\uE774"));
items.Add(new LauncherItem("http localhost", "로컬 서버 GET", null, null, Symbol: "\uE774"));
items.Add(new LauncherItem("http 192.168.1.1", "내부 IP GET", null, null, Symbol: "\uE774"));
items.Add(new LauncherItem("http head https://…", "HEAD 요청", null, null, Symbol: "\uE774"));
items.Add(new LauncherItem("http post https://…", "POST 요청", null, null, Symbol: "\uE774"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
string method, url;
if (parts[0].ToUpperInvariant() is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" or "OPTIONS")
{
method = parts[0].ToUpperInvariant();
url = parts.Length > 1 ? parts[1].Trim() : "";
}
else
{
method = "GET";
url = q;
}
if (string.IsNullOrWhiteSpace(url))
{
items.Add(new LauncherItem("URL을 입력하세요", $"예: http {method.ToLower()} https://example.com", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 스키마 자동 추가
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = "http://" + url;
}
// 사내 모드 확인
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
var isInternal = settings?.InternalModeEnabled ?? true;
if (isInternal && !IsInternalUrl(url))
{
items.Add(new LauncherItem(
"사내 모드 제한",
$"외부 URL '{url}'은 차단됩니다. 설정에서 사외 모드를 활성화하세요.",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
$"{method} {TruncateUrl(url)}",
"Enter를 눌러 요청 실행",
null,
("request", $"{method}|{url}"),
Symbol: "\uE774"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("HTTP", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
return;
}
if (item.Data is not ("request", string reqData)) return;
var idx = reqData.IndexOf('|');
var method = reqData[..idx];
var url = reqData[(idx + 1)..];
NotificationService.Notify("HTTP", $"{method} {TruncateUrl(url)} 요청 중…");
try
{
var sw = Stopwatch.StartNew();
HttpResponseMessage resp;
using var reqMsg = new HttpRequestMessage(new HttpMethod(method), url);
reqMsg.Headers.TryAddWithoutValidation("User-Agent", "AX-Copilot/2.0 HTTP-Tester");
resp = await _client.SendAsync(reqMsg, HttpCompletionOption.ResponseHeadersRead, ct);
sw.Stop();
var sb = new StringBuilder();
sb.AppendLine($"URL: {url}");
sb.AppendLine($"메서드: {method}");
sb.AppendLine($"상태 코드: {(int)resp.StatusCode} {resp.ReasonPhrase}");
sb.AppendLine($"응답 시간: {sw.ElapsedMilliseconds}ms");
sb.AppendLine($"Content-Type: {resp.Content.Headers.ContentType}");
sb.AppendLine($"Content-Length: {resp.Content.Headers.ContentLength?.ToString() ?? "unknown"}");
// 주요 헤더
foreach (var h in new[] { "Server", "X-Powered-By", "Cache-Control", "ETag", "Last-Modified" })
if (resp.Headers.TryGetValues(h, out var vals))
sb.AppendLine($"{h}: {string.Join(", ", vals)}");
var summary = sb.ToString().TrimEnd();
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
var status = (int)resp.StatusCode;
var emoji = status < 300 ? "✔" : status < 400 ? "↪" : "✘";
NotificationService.Notify("HTTP",
$"{emoji} {status} {resp.ReasonPhrase} ({sw.ElapsedMilliseconds}ms) · 결과 복사됨");
}
catch (TaskCanceledException)
{
NotificationService.Notify("HTTP", "요청 타임아웃 (10초 초과)");
}
catch (HttpRequestException ex)
{
NotificationService.Notify("HTTP", $"요청 오류: {ex.Message}");
}
catch (Exception ex)
{
NotificationService.Notify("HTTP", $"오류: {ex.Message}");
}
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool IsInternalUrl(string url)
{
var lower = url.ToLowerInvariant();
return lower.Contains("://localhost") ||
lower.Contains("://127.0.0.1") ||
lower.Contains("://192.168.") ||
lower.Contains("://10.") ||
System.Text.RegularExpressions.Regex.IsMatch(lower,
@"://172\.(1[6-9]|2\d|3[01])\.");
}
private static string TruncateUrl(string url) =>
url.Length > 60 ? url[..60] + "…" : url;
}

View File

@@ -0,0 +1,367 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-3: IP 주소 유틸리티 핸들러. "ip" 프리픽스로 사용합니다.
///
/// 예: ip → 로컬 IP 주소 목록
/// ip my → 전체 어댑터 IP 목록
/// ip 192.168.1.100 → IP 분류·타입·이진 표현
/// ip 10.0.0.0/8 → CIDR 네트워크 정보
/// ip 192.168.1.0/24 → 네트워크 주소·브로드캐스트·호스트 범위·수
/// ip range 192.168.1.1 192.168.1.100 → 범위 내 IP 수
/// ip bin 192.168.1.1 → 이진 표현
/// ip hex 192.168.1.1 → 16진수 표현
/// ip int 192.168.1.1 → 정수(uint32) 표현
/// ip from 3232235777 → 정수 → IP 주소
/// Enter → 값 복사.
/// </summary>
public class IpInfoHandler : IActionHandler
{
public string? Prefix => "ip";
public PluginMetadata Metadata => new(
"IP",
"IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("IP 주소 유틸리티",
"ip my / ip 192.168.1.1 / ip 10.0.0.0/8 / ip bin/hex/int <IP>",
null, null, Symbol: "\uE968"));
BuildLocalIpItems(items, brief: true);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// ip my
if (sub is "my" or "local" or "내" or "로컬")
{
items.Add(new LauncherItem("로컬 네트워크 어댑터 IP 목록", "", null, null, Symbol: "\uE968"));
BuildLocalIpItems(items, brief: false);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip range <start> <end>
if (sub == "range" && parts.Length >= 3)
{
if (IPAddress.TryParse(parts[1], out var start) &&
IPAddress.TryParse(parts[2], out var end) &&
start.AddressFamily == AddressFamily.InterNetwork &&
end.AddressFamily == AddressFamily.InterNetwork)
{
var s = IpToUint(start);
var e = IpToUint(end);
if (s > e) (s, e) = (e, s);
var count = e - s + 1;
items.Add(new LauncherItem($"{UintToIp(s)} ~ {UintToIp(e)}",
$"IP 수: {count:N0}개", null, null, Symbol: "\uE968"));
items.Add(CopyItem("시작 IP", UintToIp(s)));
items.Add(CopyItem("끝 IP", UintToIp(e)));
items.Add(CopyItem("IP 개수", count.ToString("N0")));
}
else
{
items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip from <uint>
if (sub == "from" && parts.Length >= 2)
{
if (uint.TryParse(parts[1], out var uval))
{
var ip = UintToIp(uval);
items.Add(new LauncherItem($"{uval} → {ip}", "정수 → IPv4 변환", null, ("copy", ip), Symbol: "\uE968"));
items.Add(CopyItem("IP 주소", ip));
items.Add(CopyItem("이진", ToBinary(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
}
else items.Add(ErrorItem("올바른 32비트 정수를 입력하세요"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip bin/hex/int <IP>
if (sub is "bin" or "binary" or "이진" or "hex" or "int" or "integer")
{
if (parts.Length >= 2 && IPAddress.TryParse(parts[1], out var ip4) &&
ip4.AddressFamily == AddressFamily.InterNetwork)
{
BuildConversionItems(items, ip4);
}
else items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// CIDR: 10.0.0.0/8
if (parts[0].Contains('/'))
{
BuildCidrItems(items, parts[0]);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 단순 IP 주소
if (IPAddress.TryParse(parts[0], out var addr))
{
if (addr.AddressFamily == AddressFamily.InterNetwork)
BuildIpInfoItems(items, addr);
else if (addr.AddressFamily == AddressFamily.InterNetworkV6)
BuildIpv6Items(items, addr);
else
items.Add(ErrorItem("IPv4 또는 IPv6 주소를 입력하세요"));
}
else
{
items.Add(new LauncherItem($"인식할 수 없는 입력: '{parts[0]}'",
"ip 192.168.1.1 / ip 10.0.0.0/8 / ip my / ip bin 1.2.3.4",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("IP", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static void BuildLocalIpItems(List<LauncherItem> items, bool brief)
{
try
{
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.ToList();
if (ifaces.Count == 0)
{
items.Add(new LauncherItem("활성 네트워크 어댑터 없음", "", null, null, Symbol: "\uE968"));
return;
}
foreach (var nic in ifaces)
{
var ipProps = nic.GetIPProperties();
var ipv4s = ipProps.UnicastAddresses
.Where(u => u.Address.AddressFamily == AddressFamily.InterNetwork)
.ToList();
if (ipv4s.Count == 0) continue;
if (!brief)
items.Add(new LauncherItem($"── {nic.Name} ──",
$"{nic.Description} ({nic.Speed / 1_000_000} Mbps)", null, null, Symbol: "\uE968"));
foreach (var uni in ipv4s)
{
var mask = uni.IPv4Mask?.ToString() ?? "";
var cidr = MaskToCidr(uni.IPv4Mask);
var label = brief ? uni.Address.ToString() : $"{uni.Address}/{cidr}";
items.Add(new LauncherItem(label,
brief ? nic.Name : $"마스크: {mask} CIDR: /{cidr} ({ClassifyIp(uni.Address)})",
null, ("copy", uni.Address.ToString()), Symbol: "\uE968"));
}
if (!brief)
{
var gateways = ipProps.GatewayAddresses
.Where(g => g.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(g => g.Address.ToString()).ToList();
if (gateways.Count > 0)
items.Add(new LauncherItem("게이트웨이",
string.Join(", ", gateways), null, ("copy", gateways[0]), Symbol: "\uE968"));
}
}
}
catch (Exception ex)
{
items.Add(ErrorItem($"네트워크 정보 조회 오류: {ex.Message}"));
}
}
private static void BuildIpInfoItems(List<LauncherItem> items, IPAddress ip)
{
var type = ClassifyIp(ip);
var uval = IpToUint(ip);
items.Add(new LauncherItem($"{ip} ({type})",
$"클래스: {GetClass(uval)} · Enter 복사", null, ("copy", ip.ToString()), Symbol: "\uE968"));
items.Add(CopyItem("IP 주소", ip.ToString()));
items.Add(CopyItem("분류", type));
items.Add(CopyItem("클래스", GetClass(uval)));
items.Add(CopyItem("이진", ToBinary(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
items.Add(CopyItem("정수", uval.ToString()));
BuildConversionItems(items, ip);
}
private static void BuildConversionItems(List<LauncherItem> items, IPAddress ip)
{
var uval = IpToUint(ip);
var bin = ToBinary(uval);
items.Add(new LauncherItem("── 변환 ──", "", null, null, Symbol: "\uE968"));
items.Add(CopyItem("이진 (32bit)", bin));
items.Add(CopyItem("이진 (점구분)", ToBinaryDotted(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
items.Add(CopyItem("정수 (uint32)", uval.ToString()));
}
private static void BuildIpv6Items(List<LauncherItem> items, IPAddress ip)
{
items.Add(new LauncherItem($"{ip}", "IPv6 주소", null, ("copy", ip.ToString()), Symbol: "\uE968"));
items.Add(CopyItem("전체 표기", ip.ToString()));
var expanded = ExpandIpv6(ip);
if (expanded != ip.ToString())
items.Add(CopyItem("확장 표기", expanded));
}
private static void BuildCidrItems(List<LauncherItem> items, string cidr)
{
var slash = cidr.IndexOf('/');
if (slash < 0) { items.Add(ErrorItem("올바른 CIDR 형식: 10.0.0.0/8")); return; }
var ipStr = cidr[..slash];
var lenStr = cidr[(slash + 1)..];
if (!IPAddress.TryParse(ipStr, out var addr) || addr.AddressFamily != AddressFamily.InterNetwork ||
!int.TryParse(lenStr, out var prefix) || prefix < 0 || prefix > 32)
{
items.Add(ErrorItem("올바른 IPv4 CIDR을 입력하세요 (예: 192.168.1.0/24)"));
return;
}
var mask = CidrToMask(prefix);
var maskIp = UintToIp(mask);
var wildcard = ~mask;
var network = IpToUint(addr) & mask;
var broadcast = network | wildcard;
var hostCount = prefix < 31 ? (broadcast - network - 1) : (broadcast - network + 1);
var firstHost = prefix < 31 ? network + 1 : network;
var lastHost = prefix < 31 ? broadcast - 1 : broadcast;
items.Add(new LauncherItem($"{cidr} → {IpToHostCount(prefix)}",
$"네트워크: {UintToIp(network)} 브로드캐스트: {UintToIp(broadcast)}", null, null, Symbol: "\uE968"));
items.Add(CopyItem("네트워크 주소", UintToIp(network)));
items.Add(CopyItem("브로드캐스트 주소", UintToIp(broadcast)));
items.Add(CopyItem("서브넷 마스크", maskIp.ToString()));
items.Add(CopyItem("와일드카드 마스크", UintToIp(wildcard)));
items.Add(CopyItem("첫 번째 호스트", UintToIp(firstHost)));
items.Add(CopyItem("마지막 호스트", UintToIp(lastHost)));
items.Add(CopyItem("호스트 수", $"{hostCount:N0}"));
items.Add(CopyItem("CIDR 표기", $"{UintToIp(network)}/{prefix}"));
items.Add(CopyItem("이진 마스크", ToBinaryDotted(mask)));
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string ClassifyIp(IPAddress ip)
{
var u = IpToUint(ip);
if (u == 0x7F000001u) return "루프백 (localhost)";
if ((u & 0xFF000000u) == 0x7F000000u) return "루프백";
if ((u & 0xFF000000u) == 0x0A000000u) return "사설 (Class A: 10.x.x.x)";
if ((u & 0xFFF00000u) == 0xAC100000u) return "사설 (Class B: 172.16-31.x.x)";
if ((u & 0xFFFF0000u) == 0xC0A80000u) return "사설 (Class C: 192.168.x.x)";
if ((u & 0xFFFF0000u) == 0xA9FE0000u) return "링크-로컬 (APIPA: 169.254.x.x)";
if ((u & 0xF0000000u) == 0xE0000000u) return "멀티캐스트 (224.x.x.x)";
if (u == 0xFFFFFFFFu) return "브로드캐스트";
if (u == 0u) return "0.0.0.0 (비지정)";
return "공인 IP";
}
private static string GetClass(uint u)
{
var b = (u >> 24) & 0xFF;
return b switch
{
<= 127 => "Class A",
<= 191 => "Class B",
<= 223 => "Class C",
<= 239 => "Class D (멀티캐스트)",
_ => "Class E (예약)"
};
}
private static uint IpToUint(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
if (bytes.Length != 4) return 0;
return (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]);
}
private static string UintToIp(uint u) =>
$"{(u >> 24) & 0xFF}.{(u >> 16) & 0xFF}.{(u >> 8) & 0xFF}.{u & 0xFF}";
private static string ToBinary(uint u) => Convert.ToString((long)u, 2).PadLeft(32, '0');
private static string ToBinaryDotted(uint u)
{
var b = ToBinary(u);
return $"{b[..8]}.{b[8..16]}.{b[16..24]}.{b[24..]}";
}
private static uint CidrToMask(int prefix) =>
prefix == 0 ? 0u : (0xFFFFFFFFu << (32 - prefix));
private static int MaskToCidr(IPAddress? mask)
{
if (mask == null) return 0;
var u = IpToUint(mask);
int c = 0;
while ((u & 0x80000000u) != 0) { c++; u <<= 1; }
return c;
}
private static string IpToHostCount(int prefix) =>
prefix switch
{
32 => "1 IP (단일 호스트)",
31 => "2 IP (P2P 링크)",
30 => "4 IP (2 호스트)",
_ => $"{(1L << (32 - prefix)):N0} IP ({(1L << (32 - prefix)) - 2:N0} 호스트)"
};
private static string ExpandIpv6(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
var groups = new string[8];
for (int i = 0; i < 8; i++)
groups[i] = $"{bytes[i * 2]:X2}{bytes[i * 2 + 1]:X2}";
return string.Join(":", groups).ToLowerInvariant();
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE968");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,298 @@
using System.Text;
using System.Text.Json;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다.
///
/// 예: jwt → 클립보드의 JWT 자동 분석
/// jwt eyJhbGci... → 토큰 직접 입력
/// jwt header → 클립보드 JWT 헤더만 표시
/// jwt payload → 클립보드 JWT 페이로드만 표시
/// Enter → 결과를 클립보드에 복사.
/// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용.
/// </summary>
public class JwtHandler : IActionHandler
{
public string? Prefix => "jwt";
public PluginMetadata Metadata => new(
"JWT",
"JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
var clip = GetClipboard();
if (LooksJwt(clip))
{
items.AddRange(DecodeJwt(clip, "all"));
}
else
{
items.Add(new LauncherItem("JWT 디코더",
"JWT 토큰을 클립보드에 복사하거나 직접 입력하세요",
null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("jwt eyJ…", "토큰 직접 입력", null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("jwt header", "헤더만 표시", null, null, Symbol: "\uE72E"));
items.Add(new LauncherItem("jwt payload", "페이로드만 표시", null, null, Symbol: "\uE72E"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "header":
{
var src = GetTokenSource(parts);
items.AddRange(DecodeJwt(src, "header"));
break;
}
case "payload":
case "claims":
case "body":
{
var src = GetTokenSource(parts);
items.AddRange(DecodeJwt(src, "payload"));
break;
}
default:
{
// 토큰 자체 입력 (eyJ로 시작)
var token = LooksJwt(q) ? q : GetClipboard();
if (!LooksJwt(token))
{
items.Add(new LauncherItem("JWT 형식 아님",
"eyJ…로 시작하는 JWT 토큰을 입력하거나 클립보드에 복사하세요",
null, null, Symbol: "\uE783"));
}
else
{
items.AddRange(DecodeJwt(token, "all"));
}
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("JWT", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── JWT 디코딩 ────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> DecodeJwt(string token, string mode)
{
if (!LooksJwt(token))
{
yield return new LauncherItem("JWT 없음",
"클립보드에 eyJ…로 시작하는 JWT가 없습니다", null, null, Symbol: "\uE783");
yield break;
}
var parts = token.Split('.');
if (parts.Length < 2)
{
yield return new LauncherItem("형식 오류", "JWT는 최소 2개의 점(.)으로 구성됩니다", null, null, Symbol: "\uE783");
yield break;
}
// 헤더 디코딩
if (mode is "header" or "all")
{
var headerJson = TryDecodeBase64Url(parts[0]);
if (headerJson != null)
{
var pretty = TryPrettyJson(headerJson) ?? headerJson;
yield return new LauncherItem("─ 헤더 ─", "", null, null, Symbol: "\uE72E");
foreach (var item in ExtractJsonFields(headerJson, "헤더"))
yield return item;
yield return new LauncherItem("헤더 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E");
}
}
// 페이로드 디코딩
if (mode is "payload" or "all")
{
if (parts.Length < 2)
{
yield return new LauncherItem("페이로드 없음", "JWT에 페이로드가 없습니다", null, null, Symbol: "\uE783");
yield break;
}
var payloadJson = TryDecodeBase64Url(parts[1]);
if (payloadJson != null)
{
yield return new LauncherItem("─ 페이로드 ─", "", null, null, Symbol: "\uE72E");
// 만료일(exp) 특별 처리
var expItem = ExtractExpiry(payloadJson);
if (expItem != null) yield return expItem;
foreach (var item in ExtractJsonFields(payloadJson, "페이로드"))
yield return item;
var pretty = TryPrettyJson(payloadJson) ?? payloadJson;
yield return new LauncherItem("페이로드 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E");
}
}
// 서명 유무
if (mode == "all")
{
var hasSig = parts.Length >= 3 && !string.IsNullOrEmpty(parts[2]);
yield return new LauncherItem(
"서명",
hasSig ? "있음 (검증 미지원 — 분석 전용)" : "없음 (alg:none)",
null, null, Symbol: "\uE72E");
}
}
private static IEnumerable<LauncherItem> ExtractJsonFields(string json, string section)
{
JsonDocument doc;
try { doc = JsonDocument.Parse(json); }
catch { yield break; }
using (doc)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
var val = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString() ?? "",
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => prop.Value.GetRawText(),
};
// exp, iat, nbf 는 타임스탬프 → 날짜 변환해서 별도 표시
if (prop.Name is "exp" or "iat" or "nbf")
{
if (long.TryParse(val, out var ts))
{
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime();
var label = prop.Name switch { "exp" => "만료(exp)", "iat" => "발급(iat)", _ => "유효 시작(nbf)" };
yield return new LauncherItem(label, dt.ToString("yyyy-MM-dd HH:mm:ss"), null, ("copy", dt.ToString("o")), Symbol: "\uE72E");
continue;
}
}
var display = val.Length > 60 ? val[..60] + "…" : val;
yield return new LauncherItem(prop.Name, display, null, ("copy", val), Symbol: "\uE72E");
}
}
}
private static LauncherItem? ExtractExpiry(string payloadJson)
{
try
{
var doc = JsonDocument.Parse(payloadJson);
if (!doc.RootElement.TryGetProperty("exp", out var expProp)) return null;
if (!expProp.TryGetInt64(out var exp)) return null;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var dt = DateTimeOffset.FromUnixTimeSeconds(exp).ToLocalTime();
var remain = exp - now;
string status;
if (remain < 0)
status = $"만료됨 ({Math.Abs(remain / 60)}분 전)";
else if (remain < 60)
status = $"곧 만료 ({remain}초 남음)";
else if (remain < 3600)
status = $"유효 ({remain / 60}분 남음)";
else if (remain < 86400)
status = $"유효 ({remain / 3600}시간 남음)";
else
status = $"유효 ({remain / 86400}일 남음)";
return new LauncherItem(
$"만료 상태: {status}",
dt.ToString("yyyy-MM-dd HH:mm:ss"),
null, null, Symbol: remain < 0 ? "\uE783" : "\uE73E");
}
catch { return null; }
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string? TryDecodeBase64Url(string input)
{
try
{
// Base64Url → Base64
var base64 = input.Replace('-', '+').Replace('_', '/');
var pad = (4 - base64.Length % 4) % 4;
base64 += new string('=', pad);
var bytes = Convert.FromBase64String(base64);
return Encoding.UTF8.GetString(bytes);
}
catch { return null; }
}
private static string? TryPrettyJson(string json)
{
try
{
var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement,
new JsonSerializerOptions { WriteIndented = true });
}
catch { return null; }
}
private static bool LooksJwt(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return false;
s = s.Trim();
return s.StartsWith("eyJ", StringComparison.Ordinal) && s.Contains('.');
}
private static string GetTokenSource(string[] parts)
{
// parts[1]에 토큰이 있으면 사용, 없으면 클립보드
if (parts.Length > 1 && LooksJwt(parts[1])) return parts[1];
return GetClipboard();
}
private static string GetClipboard()
{
try
{
return System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.ContainsText() ? Clipboard.GetText().Trim() : "");
}
catch { return ""; }
}
}

View File

@@ -0,0 +1,391 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L21-4: 키보드 단축키 참조 사전 핸들러. "key" 프리픽스로 사용합니다.
///
/// 예: key → 앱 카테고리 목록
/// key win → Windows 단축키
/// key vscode → VS Code 단축키
/// key chrome → Chrome/Edge 단축키
/// key vim → Vim 명령
/// key excel → Excel 단축키
/// key terminal → Windows Terminal 단축키
/// key <키워드> → 전체 단축키에서 키워드 검색 (예: key 찾기, key find)
/// Enter → 단축키 복사.
/// </summary>
public class KeyHandler : IActionHandler
{
public string? Prefix => "key";
public PluginMetadata Metadata => new(
"Key",
"키보드 단축키 참조 사전 — Windows·VS Code·Chrome·Vim·Excel",
"1.0",
"AX");
private record Shortcut(string Keys, string Description, string App);
private static readonly Shortcut[] All =
[
// Windows
new("Win + D", "바탕화면 보기/복원", "win"),
new("Win + E", "파일 탐색기 열기", "win"),
new("Win + L", "PC 잠금", "win"),
new("Win + R", "실행 대화상자", "win"),
new("Win + S", "Windows 검색", "win"),
new("Win + V", "클립보드 히스토리", "win"),
new("Win + X", "빠른 링크 메뉴 (전원 메뉴)", "win"),
new("Win + Tab", "작업 보기(가상 데스크톱)", "win"),
new("Win + Ctrl + D", "새 가상 데스크톱 추가", "win"),
new("Win + Ctrl + →/←", "가상 데스크톱 전환", "win"),
new("Win + ↑", "창 최대화", "win"),
new("Win + ↓", "창 최소화/복원", "win"),
new("Win + ←/→", "창 좌/우 절반 스냅", "win"),
new("Win + Shift + S", "화면 부분 캡처 (Snipping Tool)", "win"),
new("Alt + Tab", "실행 중인 앱 전환", "win"),
new("Alt + F4", "현재 창 닫기", "win"),
new("Ctrl + Shift + Esc", "작업 관리자 직접 열기", "win"),
new("Ctrl + Alt + Del", "보안 옵션 화면", "win"),
new("F2", "선택 항목 이름 변경", "win"),
new("F5", "새로고침", "win"),
new("F11", "전체화면 토글", "win"),
new("Win + . (점)", "이모지 피커", "win"),
new("Win + P", "프로젝션 모드 선택 (다중 모니터)", "win"),
new("Win + I", "Windows 설정", "win"),
new("PrtSc", "전체 화면 캡처 → 클립보드", "win"),
new("Alt + PrtSc", "현재 창 캡처 → 클립보드", "win"),
// VS Code
new("Ctrl + P", "파일 빠른 열기", "vscode"),
new("Ctrl + Shift + P", "명령 팔레트", "vscode"),
new("Ctrl + `", "통합 터미널 열기/닫기", "vscode"),
new("Ctrl + B", "사이드바 토글", "vscode"),
new("Ctrl + J", "패널 토글(터미널·출력)", "vscode"),
new("Ctrl + F", "파일 내 찾기", "vscode"),
new("Ctrl + H", "파일 내 찾아 바꾸기", "vscode"),
new("Ctrl + Shift + F", "전체 검색", "vscode"),
new("Ctrl + Shift + H", "전체 찾아 바꾸기", "vscode"),
new("Ctrl + G", "줄 이동", "vscode"),
new("Ctrl + /", "줄 주석 토글", "vscode"),
new("Alt + ↑/↓", "현재 줄 위/아래 이동", "vscode"),
new("Alt + Shift + ↑/↓","현재 줄 위/아래 복사", "vscode"),
new("Ctrl + D", "다음 동일 단어 선택", "vscode"),
new("Ctrl + Shift + L", "동일 단어 모두 선택", "vscode"),
new("Ctrl + Shift + K", "현재 줄 삭제", "vscode"),
new("Ctrl + Enter", "아래에 새 줄 추가", "vscode"),
new("Ctrl + Shift + Enter", "위에 새 줄 추가", "vscode"),
new("F12", "정의로 이동", "vscode"),
new("Alt + F12", "정의 미리보기", "vscode"),
new("Shift + F12", "모든 참조 찾기", "vscode"),
new("F2", "기호 이름 변경(리팩터링)", "vscode"),
new("Ctrl + .", "빠른 수정 (Quick Fix)", "vscode"),
new("Ctrl + K Ctrl + F","선택 영역 포맷", "vscode"),
new("Shift + Alt + F", "전체 파일 포맷", "vscode"),
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "vscode"),
new("Ctrl + W", "현재 탭 닫기", "vscode"),
new("Ctrl + Tab", "에디터 탭 전환", "vscode"),
new("Ctrl + \\", "에디터 분할", "vscode"),
new("Ctrl + 1/2/3", "에디터 그룹 포커스", "vscode"),
// Chrome / Edge
new("Ctrl + T", "새 탭 열기", "chrome"),
new("Ctrl + W", "현재 탭 닫기", "chrome"),
new("Ctrl + Shift + T", "닫은 탭 다시 열기", "chrome"),
new("Ctrl + Tab", "다음 탭으로 이동", "chrome"),
new("Ctrl + Shift + Tab","이전 탭으로 이동", "chrome"),
new("Ctrl + 1~8", "번호로 탭 이동", "chrome"),
new("Ctrl + L", "주소창 포커스", "chrome"),
new("Ctrl + D", "현재 페이지 북마크", "chrome"),
new("Ctrl + F", "페이지 내 찾기", "chrome"),
new("Ctrl + H", "방문 기록", "chrome"),
new("Ctrl + J", "다운로드 목록", "chrome"),
new("Ctrl + Shift + J", "개발자 도구 콘솔", "chrome"),
new("F12", "개발자 도구 토글", "chrome"),
new("F5 / Ctrl + R", "페이지 새로고침", "chrome"),
new("Ctrl + Shift + R", "캐시 무시 새로고침", "chrome"),
new("Ctrl + +/-", "페이지 확대/축소", "chrome"),
new("Ctrl + 0", "확대/축소 기본값 복원", "chrome"),
new("Alt + ← / →", "뒤로/앞으로", "chrome"),
new("Ctrl + N", "새 창 열기", "chrome"),
new("Ctrl + Shift + N", "시크릿 창", "chrome"),
new("Ctrl + Shift + B", "북마크 바 토글", "chrome"),
new("Ctrl + U", "페이지 소스 보기", "chrome"),
// Vim
new("i", "삽입 모드 진입", "vim"),
new("Esc", "노멀 모드로 복귀", "vim"),
new(":w", "저장", "vim"),
new(":q", "종료", "vim"),
new(":wq / :x", "저장 후 종료", "vim"),
new(":q!", "저장 없이 강제 종료", "vim"),
new("h/j/k/l", "좌/하/상/우 이동", "vim"),
new("w / b", "단어 앞/뒤로 이동", "vim"),
new("gg / G", "파일 처음 / 끝으로 이동", "vim"),
new("0 / $", "줄 처음 / 끝으로 이동", "vim"),
new("dd", "현재 줄 삭제", "vim"),
new("yy", "현재 줄 복사(yank)", "vim"),
new("p / P", "붙여넣기 (다음/현재 위치)", "vim"),
new("u / Ctrl + R", "실행 취소 / 다시 실행", "vim"),
new("/keyword", "앞으로 검색", "vim"),
new("n / N", "다음/이전 검색 결과", "vim"),
new(":%s/old/new/g", "전체 치환", "vim"),
new("v / V", "비주얼 모드 (문자/줄)", "vim"),
new("Ctrl + V", "비주얼 블록 모드", "vim"),
new(":split / :vsplit", "수평/수직 창 분할", "vim"),
new("Ctrl + W + W", "다음 창으로 포커스 이동", "vim"),
new(":noh", "검색 하이라이트 제거", "vim"),
new(">> / <<", "들여쓰기 추가/제거", "vim"),
// Excel
new("Ctrl + Arrow", "데이터 끝으로 이동", "excel"),
new("Ctrl + Shift + Arrow","데이터 끝까지 선택", "excel"),
new("Ctrl + Home / End","셀 A1 / 마지막 셀로 이동", "excel"),
new("Ctrl + Space", "열 전체 선택", "excel"),
new("Shift + Space", "행 전체 선택", "excel"),
new("Ctrl + Shift + +", "행/열 삽입", "excel"),
new("Ctrl + -", "행/열 삭제", "excel"),
new("Ctrl + D", "아래로 채우기", "excel"),
new("Ctrl + R", "오른쪽으로 채우기", "excel"),
new("Alt + =", "SUM 자동 합계", "excel"),
new("F4", "참조 절대/상대 전환 ($)", "excel"),
new("Ctrl + 1", "셀 서식 창", "excel"),
new("Ctrl + ;", "현재 날짜 입력", "excel"),
new("Ctrl + Shift + ;", "현재 시간 입력", "excel"),
new("F2", "셀 편집 모드", "excel"),
new("Alt + Enter", "셀 내 줄바꿈", "excel"),
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "excel"),
new("Ctrl + F", "찾기", "excel"),
new("Ctrl + H", "찾아 바꾸기", "excel"),
new("Ctrl + Shift + L", "필터 자동 설정 토글", "excel"),
// Windows Terminal
new("Ctrl + Shift + T", "새 탭 열기", "terminal"),
new("Ctrl + Shift + W", "탭 닫기", "terminal"),
new("Ctrl + Tab", "다음 탭 이동", "terminal"),
new("Ctrl + Shift + 1~9","번호 탭으로 이동", "terminal"),
new("Ctrl + Shift + D", "창 복제", "terminal"),
new("Alt + Shift + D", "창 분할 (자동 방향)", "terminal"),
new("Alt + Shift + +", "수평 분할", "terminal"),
new("Alt + Shift + -", "수직 분할", "terminal"),
new("Alt + Arrow", "분할된 창 간 포커스 이동", "terminal"),
new("Ctrl + +/-", "폰트 크기 변경", "terminal"),
new("Ctrl + F", "터미널 내 텍스트 검색", "terminal"),
new("Ctrl + Shift + P", "명령 팔레트", "terminal"),
new("Ctrl + Shift + F", "전체 화면 토글", "terminal"),
// Word
new("Ctrl + B", "굵게", "word"),
new("Ctrl + I", "기울임꼴", "word"),
new("Ctrl + U", "밑줄", "word"),
new("Ctrl + Z", "실행 취소", "word"),
new("Ctrl + Y", "다시 실행", "word"),
new("Ctrl + S", "저장", "word"),
new("Ctrl + P", "인쇄", "word"),
new("Ctrl + F", "찾기", "word"),
new("Ctrl + H", "찾아 바꾸기", "word"),
new("Ctrl + A", "전체 선택", "word"),
new("Ctrl + C / X / V", "복사 / 잘라내기 / 붙여넣기", "word"),
new("Ctrl + K", "하이퍼링크 삽입", "word"),
new("Ctrl + Enter", "페이지 나누기 삽입", "word"),
new("Alt + Shift + D", "현재 날짜 삽입", "word"),
new("Ctrl + Shift + >/<","글꼴 크기 늘리기/줄이기", "word"),
new("Ctrl + ] / [", " +1 / -1", "word"),
new("Ctrl + L/E/R/J", "왼쪽/가운데/오른쪽/양쪽 정렬", "word"),
new("Ctrl + 1 / 2 / 5", "줄 간격 1 / 2 / 1.5배", "word"),
new("Ctrl + Alt + 1/2/3","제목1 / 제목2 / 제목3 스타일", "word"),
new("Ctrl + Shift + N", "기본 스타일 적용", "word"),
new("F7", "맞춤법 및 문법 검사", "word"),
new("Ctrl + Shift + C", "서식 복사", "word"),
new("Ctrl + Shift + V", "서식 붙여넣기", "word"),
new("Shift + F3", "대/소문자 전환", "word"),
new("Ctrl + Home / End","문서 처음 / 끝으로 이동", "word"),
// PowerPoint
new("F5", "처음부터 슬라이드 쇼 시작", "ppt"),
new("Shift + F5", "현재 슬라이드부터 시작", "ppt"),
new("Esc", "슬라이드 쇼 종료", "ppt"),
new("B", "슬라이드 쇼 중 화면 검정", "ppt"),
new("W", "슬라이드 쇼 중 화면 흰색", "ppt"),
new("Ctrl + M", "새 슬라이드 삽입", "ppt"),
new("Ctrl + D", "슬라이드 복제", "ppt"),
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "ppt"),
new("Ctrl + S", "저장", "ppt"),
new("Ctrl + G", "개체 그룹화", "ppt"),
new("Ctrl + Shift + G", "그룹 해제", "ppt"),
new("Tab", "다음 개체로 포커스 이동", "ppt"),
new("Shift + Tab", "이전 개체로 포커스 이동", "ppt"),
new("Ctrl + A", "모든 개체 선택", "ppt"),
new("Alt + Shift + ←/→","슬라이드 내 개체 정렬 (왼쪽/오른쪽)","ppt"),
new("Ctrl + Shift + >/<","글꼴 크기 늘리기/줄이기", "ppt"),
new("Arrow Keys", "슬라이드 쇼 중 다음/이전", "ppt"),
new("숫자 + Enter", "슬라이드 쇼 중 특정 슬라이드로 이동", "ppt"),
// Teams
new("Ctrl + E", "검색창 포커스", "teams"),
new("Ctrl + /", "명령 팔레트 열기", "teams"),
new("Ctrl + N", "새 채팅 시작", "teams"),
new("Ctrl + Shift + M", "오디오 뮤트 토글", "teams"),
new("Ctrl + Shift + O", "비디오 카메라 토글", "teams"),
new("Ctrl + Shift + H", "회의 종료", "teams"),
new("Ctrl + Shift + K", "손 들기/내리기", "teams"),
new("Ctrl + Shift + B", "배경 흐림 토글", "teams"),
new("Ctrl + Shift + F", "전체 화면 토글", "teams"),
new("Ctrl + Shift + A", "팀 활동 피드 열기", "teams"),
new("Ctrl + 1", "활동 탭", "teams"),
new("Ctrl + 2", "채팅 탭", "teams"),
new("Ctrl + 3", "팀 탭", "teams"),
new("Ctrl + 4", "캘린더 탭", "teams"),
new("Ctrl + 5", "통화 탭", "teams"),
new("Alt + ↑/↓", "채널 목록 위/아래 이동", "teams"),
new("Ctrl + R", "메시지에 답장", "teams"),
new("Enter", "회의 참가 (알림에서)", "teams"),
// Outlook
new("Ctrl + N", "새 이메일 작성", "outlook"),
new("Ctrl + R", "답장", "outlook"),
new("Ctrl + Shift + R", "전체 답장", "outlook"),
new("Ctrl + F", "이메일 전달", "outlook"),
new("Ctrl + Enter", "이메일 전송", "outlook"),
new("Ctrl + S", "초안 저장", "outlook"),
new("Delete", "이메일 삭제", "outlook"),
new("Ctrl + Z", "실행 취소", "outlook"),
new("Ctrl + 1", "메일 보기", "outlook"),
new("Ctrl + 2", "일정 보기", "outlook"),
new("Ctrl + 3", "연락처 보기", "outlook"),
new("Ctrl + 4", "작업 보기", "outlook"),
new("F9", "모든 계정 보내기/받기", "outlook"),
new("Ctrl + Shift + I", "받은 편지함으로 이동", "outlook"),
new("Ctrl + Shift + V", "이메일 이동", "outlook"),
new("Ctrl + Shift + G", "플래그 설정", "outlook"),
new("Ctrl + Q", "읽음 표시", "outlook"),
new("Ctrl + U", "읽지 않음 표시", "outlook"),
new("Ctrl + Shift + J", "정크 메일 처리", "outlook"),
new("Ctrl + K", "이름 확인 (주소 자동 완성)", "outlook"),
new("Alt + S", "이메일 보내기", "outlook"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("키보드 단축키 참조 사전",
"key win / vscode / chrome / vim / excel / terminal / word / ppt / teams / outlook / key <키워드 검색>",
null, null, Symbol: "\uE92E"));
AddAppOverview(items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// 앱 카테고리 조회
var catKeys = new Dictionary<string, string[]>
{
["win"] = ["win", "windows"],
["vscode"] = ["vscode", "code", "vs"],
["chrome"] = ["chrome", "edge", "browser"],
["vim"] = ["vim", "vi", "neovim"],
["excel"] = ["excel", "spreadsheet"],
["terminal"] = ["terminal", "wt"],
["word"] = ["word", "워드"],
["ppt"] = ["ppt", "powerpoint", "파워포인트", "프레젠테이션"],
["teams"] = ["teams", "팀즈"],
["outlook"] = ["outlook", "아웃룩", "mail"],
};
foreach (var (cat, aliases) in catKeys)
{
if (aliases.Contains(sub))
{
var catItems = All.Where(s => s.App == cat).ToList();
var catName = cat switch
{
"win" => "Windows",
"vscode" => "VS Code",
"chrome" => "Chrome / Edge",
"vim" => "Vim",
"excel" => "Excel",
"terminal" => "Windows Terminal",
"word" => "Word",
"ppt" => "PowerPoint",
"teams" => "Teams",
"outlook" => "Outlook",
_ => cat
};
items.Add(new LauncherItem($"{catName} 단축키 {catItems.Count}개", "", null, null, Symbol: "\uE92E"));
foreach (var s in catItems)
items.Add(new LauncherItem(s.Keys, s.Description, null, ("copy", s.Keys), Symbol: "\uE92E"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 키워드 검색 (전체)
var keyword = string.Join(" ", parts);
var found = All.Where(s =>
s.Keys.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
s.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count > 0)
{
items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE92E"));
foreach (var s in found)
{
var appLabel = s.App.ToUpperInvariant();
items.Add(new LauncherItem(s.Keys, $"[{appLabel}] {s.Description}",
null, ("copy", s.Keys), Symbol: "\uE92E"));
}
}
else
{
items.Add(new LauncherItem($"'{keyword}' 결과 없음",
"key win / vscode / chrome / vim / excel / terminal / word / ppt / teams / outlook",
null, null, Symbol: "\uE783"));
AddAppOverview(items);
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Key", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
private static void AddAppOverview(List<LauncherItem> items)
{
var apps = new (string Key, string Label, int Count)[]
{
("win", "Windows 단축키", All.Count(s => s.App == "win")),
("vscode", "VS Code 단축키", All.Count(s => s.App == "vscode")),
("chrome", "Chrome / Edge 단축키", All.Count(s => s.App == "chrome")),
("vim", "Vim 명령", All.Count(s => s.App == "vim")),
("excel", "Excel 단축키", All.Count(s => s.App == "excel")),
("terminal", "Windows Terminal 단축키", All.Count(s => s.App == "terminal")),
("word", "Word 단축키", All.Count(s => s.App == "word")),
("ppt", "PowerPoint 단축키", All.Count(s => s.App == "ppt")),
("teams", "Teams 단축키", All.Count(s => s.App == "teams")),
("outlook", "Outlook 단축키", All.Count(s => s.App == "outlook")),
};
foreach (var (key, label, count) in apps)
items.Add(new LauncherItem($"key {key}", $"{label} ({count}개)",
null, null, Symbol: "\uE92E"));
}
}

View File

@@ -0,0 +1,323 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다.
///
/// 예: leave → 잔여 연차 현황
/// leave set 15 → 연간 연차 일수 설정
/// leave use 2026-04-10 → 1일 연차 기록
/// leave use 2026-04-10 0.5 → 반차 기록
/// leave use 2026-04-10 0.5 반차메모 → 메모 포함
/// leave del 2026-04-10 → 기록 삭제
/// leave remaining → 잔여 연차
/// leave list → 올해 사용 이력
/// leave clear → 올해 기록 초기화
/// Enter → 저장 실행 또는 복사
/// 저장: %APPDATA%\AxCopilot\leave.json
/// </summary>
public class LeaveHandler : IActionHandler
{
public string? Prefix => "leave";
public PluginMetadata Metadata => new(
"연차 관리",
"연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력",
"1.0",
"AX");
private static readonly string DataPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "leave.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private sealed class LeaveData
{
[JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15;
[JsonPropertyName("records")] public List<LeaveRecord> Records { get; set; } = [];
}
private sealed class LeaveRecord
{
[JsonPropertyName("date")] public string Date { get; set; } = "";
[JsonPropertyName("days")] public double Days { get; set; } = 1;
[JsonPropertyName("note")] public string Note { get; set; } = "";
}
// ── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var data = LoadData();
var year = DateTime.Today.Year;
var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days);
var left = data.AnnualDays - used;
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일",
"leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining",
null, null, Symbol: "\uE716"));
items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716"));
items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716"));
items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716"));
items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// set N
if (sub == "set")
{
if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0)
{
items.Add(new LauncherItem(
$"연간 연차를 {n}일로 설정",
$"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장",
null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Symbol: "\uE716"));
}
else
{
items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// use 날짜 [일수] [메모]
if (sub == "use")
{
if (parts.Length < 2)
{
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var dateStr = parts[1];
if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" },
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out _))
{
items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var useDays = 1.0;
var note = "";
if (parts.Length >= 3 && double.TryParse(parts[2],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var pd))
{
useDays = pd;
if (parts.Length >= 4)
note = string.Join(" ", parts[3..]);
}
else if (parts.Length >= 3)
{
note = string.Join(" ", parts[2..]);
}
var newLeft = left - useDays;
var label = note.Length > 0
? $"{dateStr} 연차 {useDays}일 기록 [{note}]"
: $"{dateStr} 연차 {useDays}일 기록";
items.Add(new LauncherItem(
label,
$"잔여: {newLeft}일 · Enter: 저장",
null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"),
Symbol: "\uE716"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// del 날짜
if (sub == "del")
{
if (parts.Length < 2)
{
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var delDate = parts[1];
var target = data.Records.FirstOrDefault(r => r.Date == delDate);
if (target == null)
{
items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783"));
}
else
{
items.Add(new LauncherItem(
$"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})",
"Enter: 삭제",
null, ("del", delDate), Symbol: "\uE716"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// remaining
if (sub == "remaining")
{
items.Add(new LauncherItem(
$"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
"Enter: 클립보드 복사",
null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// list
if (sub == "list")
{
var yearRecords = data.Records
.Where(r => r.Date.StartsWith(year.ToString()))
.OrderBy(r => r.Date)
.ToList();
items.Add(new LauncherItem(
$"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일",
$"잔여: {left}일", null, null, Symbol: "\uE716"));
if (yearRecords.Count == 0)
{
items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716"));
}
else
{
foreach (var r in yearRecords)
{
var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : "";
items.Add(new LauncherItem(
$"{r.Date} {r.Days}일{noteStr}",
"Enter: 삭제",
null, ("del", r.Date), Symbol: "\uE716"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// clear
if (sub == "clear")
{
var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString()));
items.Add(new LauncherItem(
$"{year}년 연차 기록 초기화 ({cnt}건)",
"Enter: 확인",
null, ("clear", year.ToString()), Symbol: "\uE716"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 기본 — 안내
items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
"leave set · use · del · remaining · list · clear",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
var data = LoadData();
switch (item.Data)
{
case ("set", string nStr) when double.TryParse(nStr,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var n):
data.AnnualDays = n;
SaveData(data);
NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다.");
break;
case ("use", string payload):
{
var parts = payload.Split('|');
if (parts.Length >= 2 &&
double.TryParse(parts[1],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var days))
{
var dateStr = parts[0];
var note = parts.Length >= 3 ? parts[2] : "";
// 중복 날짜 처리 — 누적
var existing = data.Records.FirstOrDefault(r => r.Date == dateStr);
if (existing != null)
{
existing.Days += days;
if (note.Length > 0) existing.Note = note;
}
else
{
data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note });
}
data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
SaveData(data);
NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다.");
}
break;
}
case ("del", string delDate):
{
var removed = data.Records.RemoveAll(r => r.Date == delDate);
if (removed > 0)
{
SaveData(data);
NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다.");
}
break;
}
case ("clear", string yearStr) when int.TryParse(yearStr, out var y):
{
var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString()));
SaveData(data);
NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다.");
break;
}
case ("copy", string text):
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("연차 관리", "클립보드에 복사했습니다.");
}
catch { }
break;
}
return Task.CompletedTask;
}
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
private static LeaveData LoadData()
{
try
{
if (!System.IO.File.Exists(DataPath)) return new LeaveData();
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
return JsonSerializer.Deserialize<LeaveData>(json, JsonOpts) ?? new LeaveData();
}
catch { return new LeaveData(); }
}
private static void SaveData(LeaveData data)
{
try
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
System.IO.File.WriteAllText(DataPath,
JsonSerializer.Serialize(data, JsonOpts),
System.Text.Encoding.UTF8);
}
catch { }
}
}

View File

@@ -0,0 +1,402 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L21-2: 로그 파일 분석기 핸들러. "log" 프리픽스로 사용합니다.
///
/// 예: log → 클립보드 로그 통계
/// log <경로> → 파일 경로 직접 분석
/// log error → ERROR 줄만 표시
/// log warn → WARN 줄만 표시
/// log last 20 → 마지막 20줄 (tail)
/// log head 20 → 처음 20줄
/// log grep <키워드> → 키워드 필터
/// log stats → 레벨별 통계 + 시간대 분포
/// log exceptions → 예외·스택트레이스 블록 추출
/// log today → 오늘 날짜 포함 줄만 표시
/// Enter → 값 복사.
/// </summary>
public partial class LogHandler : IActionHandler
{
public string? Prefix => "log";
public PluginMetadata Metadata => new(
"Log",
"로그 파일 분석기 — ERROR/WARN 필터·tail·grep·통계·예외 추출",
"1.0",
"AX");
private record LogLine(int No, string Level, string Raw);
// 로그 레벨 감지 패턴
private static readonly (string Level, string[] Keywords)[] LevelPatterns =
[
("ERROR", ["[ERROR]", "ERROR:", "ERRO ", "error", "Error", "FATAL", "[FATAL]", "Exception", "EXCEPTION"]),
("WARN", ["[WARN]", "WARN:", "WARNING", "warning", "Warning", "[WARNING]"]),
("INFO", ["[INFO]", "INFO:", "information", "Information"]),
("DEBUG", ["[DEBUG]", "DEBUG:", "debug", "Debug", "TRACE", "[TRACE]"]),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 or 파일
string? src = null;
string? srcLabel = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText()) src = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
if (string.IsNullOrWhiteSpace(src))
{
items.Add(new LauncherItem("로그 파일 분석기",
"클립보드에 로그를 복사하거나 log <파일경로> / log error / log grep <키워드>",
null, null, Symbol: "\uE9D9"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
srcLabel = "클립보드";
BuildSummary(items, src!, srcLabel);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// 파일 경로인지 판단
if ((sub.Contains('\\') || sub.Contains('/') || sub.Contains(':') || sub.EndsWith(".log") || sub.EndsWith(".txt"))
&& File.Exists(parts[0]))
{
string? fileErr = null;
string? content = null;
try { content = File.ReadAllText(parts[0]); }
catch (Exception ex) { fileErr = ex.Message; }
if (fileErr != null) { items.Add(ErrorItem($"파일 읽기 오류: {fileErr}")); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
src = content;
srcLabel = Path.GetFileName(parts[0]);
if (parts.Length == 1)
{
BuildSummary(items, src!, srcLabel);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 파일 경로 + 서브커맨드
sub = parts[1].ToLowerInvariant();
parts = parts[1..];
}
else
{
srcLabel = "클립보드";
}
var logSrc = src ?? "";
if (string.IsNullOrWhiteSpace(logSrc))
{
items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요."));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var allLines = ParseLogLines(logSrc);
switch (sub)
{
case "error" or "err":
BuildFilteredLines(items, allLines, "ERROR", "ERROR / FATAL / Exception", srcLabel!);
break;
case "warn" or "warning":
BuildFilteredLines(items, allLines, "WARN", "WARN / WARNING", srcLabel!);
break;
case "info":
BuildFilteredLines(items, allLines, "INFO", "INFO / information", srcLabel!);
break;
case "debug" or "trace":
BuildFilteredLines(items, allLines, "DEBUG", "DEBUG / TRACE", srcLabel!);
break;
case "last" or "tail":
{
int n = 20;
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
var tail = allLines.TakeLast(n).ToList();
var joined = string.Join("\n", tail.Select(l => l.Raw));
items.Add(new LauncherItem($"마지막 {tail.Count}줄 ({srcLabel})",
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
foreach (var l in tail)
items.Add(BuildLineItem(l));
break;
}
case "head" or "first":
{
int n = 20;
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
var head = allLines.Take(n).ToList();
var joined = string.Join("\n", head.Select(l => l.Raw));
items.Add(new LauncherItem($"처음 {head.Count}줄 ({srcLabel})",
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
foreach (var l in head)
items.Add(BuildLineItem(l));
break;
}
case "grep" or "find" or "search":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: log grep Exception")); break; }
var kw = string.Join(" ", parts[1..]);
var matched = allLines.Where(l => l.Raw.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
var joined = string.Join("\n", matched.Select(l => l.Raw));
items.Add(new LauncherItem($"'{kw}' 검색 결과: {matched.Count}줄",
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
foreach (var l in matched.Take(15))
items.Add(BuildLineItem(l));
if (matched.Count > 15)
items.Add(new LauncherItem($"... ({matched.Count - 15}줄 더)", "Enter 전체 복사",
null, ("copy", joined), Symbol: "\uE9D9"));
break;
}
case "stats":
BuildStats(items, allLines, logSrc, srcLabel!);
break;
case "exceptions" or "exception" or "ex":
{
var blocks = ExtractExceptions(logSrc);
items.Add(new LauncherItem($"예외 블록 {blocks.Count}개 발견",
srcLabel!, null, null, Symbol: "\uE9D9"));
for (int i = 0; i < blocks.Count; i++)
{
var b = blocks[i];
var preview = b.Split('\n')[0];
items.Add(new LauncherItem($"#{i + 1} {TruncateStr(preview, 60)}",
$"{b.Split('\n').Length}줄", null, ("copy", b), Symbol: "\uE9D9"));
}
if (blocks.Count == 0)
items.Add(new LauncherItem("예외·스택트레이스 패턴 없음", "", null, null, Symbol: "\uE9D9"));
break;
}
case "today":
{
var today = DateTime.Today.ToString("yyyy-MM-dd");
var today2 = DateTime.Today.ToString("yyyy/MM/dd");
var matched = allLines.Where(l =>
l.Raw.Contains(today) || l.Raw.Contains(today2)).ToList();
var joined = string.Join("\n", matched.Select(l => l.Raw));
items.Add(new LauncherItem($"오늘({today}) {matched.Count}줄",
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
foreach (var l in matched.Take(15))
items.Add(BuildLineItem(l));
break;
}
default:
// 기본 요약으로 폴백
BuildSummary(items, logSrc, srcLabel!);
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Log", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static void BuildSummary(List<LauncherItem> items, string src, string label)
{
var lines = ParseLogLines(src);
var errors = lines.Count(l => l.Level == "ERROR");
var warns = lines.Count(l => l.Level == "WARN");
var infos = lines.Count(l => l.Level == "INFO");
var debugs = lines.Count(l => l.Level == "DEBUG");
var unknowns = lines.Count(l => l.Level == "OTHER");
items.Add(new LauncherItem($"{label} — {lines.Count}줄",
$"ERROR:{errors} WARN:{warns} INFO:{infos} DEBUG:{debugs}",
null, null, Symbol: "\uE9D9"));
if (errors > 0)
{
items.Add(new LauncherItem($"🔴 ERROR {errors}줄",
"log error 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
}
if (warns > 0)
{
items.Add(new LauncherItem($"🟡 WARN {warns}줄",
"log warn 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
}
items.Add(CopyItem("전체 줄 수", lines.Count.ToString()));
items.Add(CopyItem("ERROR 줄", errors.ToString()));
items.Add(CopyItem("WARN 줄", warns.ToString()));
// 마지막 5줄 미리보기
items.Add(new LauncherItem("── 마지막 5줄 ──", "", null, null, Symbol: "\uE9D9"));
foreach (var l in lines.TakeLast(5))
items.Add(BuildLineItem(l));
}
private static void BuildFilteredLines(List<LauncherItem> items, List<LogLine> lines,
string level, string label, string srcLabel)
{
var filtered = lines.Where(l => l.Level == level).ToList();
var joined = string.Join("\n", filtered.Select(l => l.Raw));
items.Add(new LauncherItem($"{label} {filtered.Count}줄 ({srcLabel})",
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
if (filtered.Count == 0)
{
items.Add(new LauncherItem($"{label} 줄이 없습니다", "", null, null, Symbol: "\uE9D9"));
return;
}
foreach (var l in filtered.Take(15))
items.Add(BuildLineItem(l));
if (filtered.Count > 15)
items.Add(new LauncherItem($"... ({filtered.Count - 15}줄 더)", "Enter 전체 복사",
null, ("copy", joined), Symbol: "\uE9D9"));
}
private static void BuildStats(List<LauncherItem> items, List<LogLine> lines, string src, string label)
{
var counts = new Dictionary<string, int>
{
["ERROR"] = lines.Count(l => l.Level == "ERROR"),
["WARN"] = lines.Count(l => l.Level == "WARN"),
["INFO"] = lines.Count(l => l.Level == "INFO"),
["DEBUG"] = lines.Count(l => l.Level == "DEBUG"),
["OTHER"] = lines.Count(l => l.Level == "OTHER"),
};
items.Add(new LauncherItem($"로그 통계 ({label})", $"총 {lines.Count}줄",
null, null, Symbol: "\uE9D9"));
foreach (var (lvl, cnt) in counts.Where(kv => kv.Value > 0))
items.Add(CopyItem(lvl, $"{cnt}줄 ({cnt * 100.0 / Math.Max(1, lines.Count):F1}%)"));
// 날짜별 분포 (yyyy-MM-dd 패턴 추출)
var dateCounts = new Dictionary<string, int>();
foreach (var l in lines)
{
var m = DatePattern().Match(l.Raw);
if (m.Success)
{
var date = m.Value[..10];
dateCounts[date] = dateCounts.GetValueOrDefault(date) + 1;
}
}
if (dateCounts.Count > 0)
{
items.Add(new LauncherItem("── 날짜별 분포 ──", "", null, null, Symbol: "\uE9D9"));
foreach (var (date, cnt) in dateCounts.OrderByDescending(kv => kv.Key).Take(5))
items.Add(CopyItem(date, $"{cnt}줄"));
}
}
private static List<string> ExtractExceptions(string src)
{
var blocks = new List<string>();
var lines = src.Split('\n');
var inBlock = false;
var current = new StringBuilder();
foreach (var line in lines)
{
var isEx = line.Contains("Exception") || line.Contains("Error:") ||
line.Contains("at ") || line.TrimStart().StartsWith("Caused by");
if (!inBlock && isEx)
{
inBlock = true;
current.Clear();
current.AppendLine(line);
}
else if (inBlock)
{
if (isEx || line.TrimStart().StartsWith("at ") || line.TrimStart().StartsWith("..."))
current.AppendLine(line);
else
{
if (current.Length > 0) blocks.Add(current.ToString().Trim());
inBlock = false;
current.Clear();
}
}
}
if (inBlock && current.Length > 0) blocks.Add(current.ToString().Trim());
return blocks;
}
// ── 파서·헬퍼 ───────────────────────────────────────────────────────────
private static List<LogLine> ParseLogLines(string src)
{
var lines = src.Split('\n');
return lines.Select((raw, i) =>
{
var level = DetectLevel(raw);
return new LogLine(i + 1, level, raw.TrimEnd('\r'));
}).ToList();
}
private static string DetectLevel(string line)
{
foreach (var (level, keywords) in LevelPatterns)
if (keywords.Any(kw => line.Contains(kw, StringComparison.Ordinal)))
return level;
return "OTHER";
}
private static LauncherItem BuildLineItem(LogLine l)
{
var icon = l.Level switch
{
"ERROR" => "🔴",
"WARN" => "🟡",
"INFO" => "🔵",
"DEBUG" => "⚪",
_ => " "
};
var display = TruncateStr(l.Raw, 80);
return new LauncherItem($"{icon} {display}",
$"줄 {l.No} ({l.Level})", null, ("copy", l.Raw), Symbol: "\uE9D9");
}
private static string TruncateStr(string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE9D9");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
[GeneratedRegex(@"\d{4}[-/]\d{2}[-/]\d{2}")]
private static partial Regex DatePattern();
}

View File

@@ -0,0 +1,284 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L10-4: Lorem Ipsum / 더미 텍스트 생성기 핸들러. "lorem" 프리픽스로 사용합니다.
///
/// 예: lorem → 1단락 생성 (기본)
/// lorem 3 → 3단락 생성
/// lorem words 20 → 단어 20개
/// lorem sentences 5 → 문장 5개
/// lorem ko → 한국어 더미 텍스트 1단락
/// lorem ko 3 → 한국어 더미 텍스트 3단락
/// lorem email 5 → 더미 이메일 주소 5개
/// lorem name 5 → 더미 이름 5개 (한국어)
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class LoremHandler : IActionHandler
{
public string? Prefix => "lorem";
public PluginMetadata Metadata => new(
"Lorem",
"더미 텍스트 생성기 — Lorem Ipsum · 한국어 · 이메일 · 이름",
"1.0",
"AX");
// ── Lorem Ipsum 단어 풀 ─────────────────────────────────────────────────
private static readonly string[] LoremWords =
[
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
"velit", "esse", "cillum", "eu", "fugiat", "nulla", "pariatur", "excepteur",
"sint", "occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui",
"officia", "deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis",
"unde", "omnis", "iste", "natus", "error", "voluptatem", "accusantium",
"doloremque", "laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae",
"ab", "illo", "inventore", "veritatis", "quasi", "architecto", "beatae", "vitae",
"dicta", "explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut",
"odit", "fugit", "consequuntur", "magni", "dolores", "ratione", "sequi",
"nesciunt", "neque", "porro", "quisquam", "dolorem", "numquam", "eius", "modi",
"temporibus", "incidunt", "magnam", "aliquam", "quaerat", "minima", "nostrum",
"exercitationem", "ullam", "corporis", "suscipit", "laboriosam", "nisi",
"aliquid", "commodi", "consequatur", "quidem", "rerum", "facilis",
];
// ── 한국어 더미 단어 풀 ──────────────────────────────────────────────────
private static readonly string[] KorWords =
[
"가나다라", "마바사아", "자차카타", "파하", "데이터", "처리", "시스템", "네트워크",
"소프트웨어", "알고리즘", "데이터베이스", "인터페이스", "프레임워크", "모듈", "클래스",
"메서드", "함수", "변수", "구조체", "배열", "목록", "사전", "집합", "스택", "큐",
"트리", "그래프", "정렬", "탐색", "분석", "설계", "구현", "테스트", "배포", "운영",
"서비스", "플랫폼", "클라우드", "컨테이너", "가상화", "보안", "암호화", "인증", "권한",
"로그", "모니터링", "알림", "이벤트", "트랜잭션", "세션", "쿠키", "토큰", "키",
"값", "객체", "인스턴스", "프로세스", "스레드", "비동기", "병렬", "동기화", "잠금",
"버퍼", "캐시", "인덱스", "쿼리", "뷰", "프로시저", "스키마", "테이블", "컬럼",
"행", "열", "기본키", "외래키", "조인", "집계", "필터", "정렬", "그룹화", "분류",
];
private static readonly string[] KorSentenceStarters =
[
"이 시스템은", "해당 모듈은", "기능을 구현하면", "데이터를 처리하는",
"네트워크 연결이", "서비스가 시작되면", "사용자 인터페이스는", "알고리즘이",
"처리 과정에서", "설계 단계에서", "구현 방식은", "테스트 결과",
];
private static readonly string[] KorSentenceEnders =
[
"처리됩니다.", "구현되어 있습니다.", "필요합니다.", "중요한 역할을 합니다.",
"확인할 수 있습니다.", "설계되어 있습니다.", "활용됩니다.", "반환됩니다.",
"저장됩니다.", "업데이트됩니다.", "삭제됩니다.", "초기화됩니다.",
];
// ── 더미 이름/이메일 데이터 ───────────────────────────────────────────────
private static readonly string[] KorLastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", "한", "오", "서", "신", "권", "황", "안", "송", "류", "전"];
private static readonly string[] KorFirstNames = ["민준", "서연", "예준", "서현", "도윤", "지우", "시우", "수아", "지호", "하은", "준서", "하린", "건우", "소연", "현우", "지민", "우진", "지유", "연우", "채원"];
private static readonly string[] EmailDomains = ["example.com", "test.co.kr", "dummy.net", "sample.org", "mock.io", "placeholder.dev"];
private static readonly Random Rng = new();
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
var para = GenerateParagraph(false);
items.Add(new LauncherItem(
"Lorem Ipsum 1단락",
para.Length > 80 ? para[..80] + "…" : para,
null,
("copy", para),
Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem 3", "3단락 생성", null, null, Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem words 20", "단어 20개", null, null, Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem sentences 3", "문장 3개", null, null, Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem ko", "한국어 더미 텍스트", null, null, Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem email 5", "더미 이메일 5개", null, null, Symbol: "\uE8BD"));
items.Add(new LauncherItem("lorem name 5", "더미 이름 5개", null, null, Symbol: "\uE8BD"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "words":
case "word":
case "w":
{
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 500) : 20;
var text = GenerateWords(cnt, false);
items.Add(new LauncherItem(
$"단어 {cnt}개",
text.Length > 80 ? text[..80] + "…" : text,
null, ("copy", text), Symbol: "\uE8BD"));
break;
}
case "sentences":
case "sentence":
case "s":
{
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 50) : 5;
var text = GenerateSentences(cnt, false);
items.Add(new LauncherItem(
$"문장 {cnt}개",
text.Length > 80 ? text[..80] + "…" : text,
null, ("copy", text), Symbol: "\uE8BD"));
break;
}
case "ko":
case "kor":
case "korean":
{
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 10) : 1;
var text = GenerateParagraphs(cnt, true);
items.Add(new LauncherItem(
$"한국어 더미 텍스트 {cnt}단락",
text.Length > 80 ? text[..80] + "…" : text,
null, ("copy", text), Symbol: "\uE8BD"));
break;
}
case "email":
{
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5;
var emails = Enumerable.Range(0, cnt).Select(_ => GenerateEmail()).ToList();
var all = string.Join("\n", emails);
items.Add(new LauncherItem(
$"더미 이메일 {cnt}개",
"전체 복사: Enter",
null, ("copy", all), Symbol: "\uE8BD"));
foreach (var email in emails)
items.Add(new LauncherItem(email, "Enter 복사", null, ("copy", email), Symbol: "\uE8BD"));
break;
}
case "name":
case "names":
{
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5;
var names = Enumerable.Range(0, cnt).Select(_ => GenerateKorName()).ToList();
var all = string.Join("\n", names);
items.Add(new LauncherItem(
$"더미 이름 {cnt}개",
"전체 복사: Enter",
null, ("copy", all), Symbol: "\uE8BD"));
foreach (var name in names)
items.Add(new LauncherItem(name, "한국어 이름 · Enter 복사", null, ("copy", name), Symbol: "\uE8BD"));
break;
}
default:
{
// 숫자 단독 → 단락 수
var cnt = int.TryParse(sub, out var n) ? Math.Clamp(n, 1, 10) : 1;
var text = GenerateParagraphs(cnt, false);
items.Add(new LauncherItem(
$"Lorem Ipsum {cnt}단락",
text.Length > 80 ? text[..80] + "…" : text,
null, ("copy", text), Symbol: "\uE8BD"));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Lorem", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 생성 헬퍼 ─────────────────────────────────────────────────────────────
private static string GenerateParagraphs(int count, bool korean)
{
var paras = Enumerable.Range(0, count).Select(_ => GenerateParagraph(korean));
return string.Join("\n\n", paras);
}
private static string GenerateParagraph(bool korean)
{
var sentenceCount = Rng.Next(4, 8);
return GenerateSentences(sentenceCount, korean);
}
private static string GenerateSentences(int count, bool korean)
{
var sb = new StringBuilder();
for (var i = 0; i < count; i++)
{
if (i > 0) sb.Append(' ');
sb.Append(GenerateSentence(korean));
}
return sb.ToString();
}
private static string GenerateSentence(bool korean)
{
if (korean)
{
var starter = KorSentenceStarters[Rng.Next(KorSentenceStarters.Length)];
var wordCnt = Rng.Next(3, 8);
var words = Enumerable.Range(0, wordCnt).Select(_ => KorWords[Rng.Next(KorWords.Length)]);
var ender = KorSentenceEnders[Rng.Next(KorSentenceEnders.Length)];
return $"{starter} {string.Join(" ", words)} {ender}";
}
else
{
var wordCnt = Rng.Next(6, 15);
var words = Enumerable.Range(0, wordCnt).Select((_, idx) =>
{
var w = LoremWords[Rng.Next(LoremWords.Length)];
return idx == 0 ? char.ToUpper(w[0]) + w[1..] : w;
});
return string.Join(" ", words) + ".";
}
}
private static string GenerateWords(int count, bool korean)
{
var pool = korean ? KorWords : LoremWords;
return string.Join(" ", Enumerable.Range(0, count).Select(_ => pool[Rng.Next(pool.Length)]));
}
private static string GenerateEmail()
{
var first = LoremWords[Rng.Next(LoremWords.Length)];
var second = LoremWords[Rng.Next(LoremWords.Length)];
var num = Rng.Next(10, 999);
var domain = EmailDomains[Rng.Next(EmailDomains.Length)];
return $"{first}.{second}{num}@{domain}";
}
private static string GenerateKorName()
{
var last = KorLastNames[Rng.Next(KorLastNames.Length)];
var first = KorFirstNames[Rng.Next(KorFirstNames.Length)];
return last + first;
}
}

View File

@@ -0,0 +1,231 @@
using System.Diagnostics;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다.
///
/// 예: macro → 매크로 목록
/// macro 이름 → 이름으로 필터
/// macro new → 새 매크로 편집기 열기
/// macro edit 이름 → 기존 매크로 편집
/// macro del 이름 → 매크로 삭제
/// macro play 이름 → 즉시 실행
/// Enter로 선택한 매크로를 실행합니다.
/// </summary>
public class MacroHandler : IActionHandler
{
private readonly SettingsService _settings;
public MacroHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "macro";
public PluginMetadata Metadata => new(
"Macro",
"런처 매크로 — macro",
"1.0",
"AX");
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
if (cmd == "new")
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("새 매크로 만들기",
"편집기에서 단계별 실행 시퀀스를 설정합니다",
null, "__new__", Symbol: "\uE710")
});
}
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기",
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
});
}
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 삭제",
"Enter로 삭제 확인",
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
});
}
if (cmd == "play" && parts.Length > 1)
{
var entry = _settings.Settings.Macros
.FirstOrDefault(m => m.Name.Equals(parts[1], StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"[{entry.Name}] 매크로 실행",
$"{entry.Steps.Count}단계 · Enter로 즉시 실행",
null, entry, Symbol: "\uE768")
});
}
}
// 목록
var macros = _settings.Settings.Macros;
var filter = q.ToLowerInvariant();
var items = new List<LauncherItem>();
foreach (var m in macros)
{
if (!string.IsNullOrEmpty(filter) &&
!m.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!m.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var preview = m.Steps.Count == 0
? "단계 없음"
: string.Join(" → ", m.Steps.Take(3).Select(s => string.IsNullOrWhiteSpace(s.Label) ? s.Target : s.Label))
+ (m.Steps.Count > 3 ? $" … +{m.Steps.Count - 3}" : "");
items.Add(new LauncherItem(
m.Name,
$"{m.Steps.Count}단계 · {preview}",
null, m, Symbol: "\uE768"));
}
if (items.Count == 0 && string.IsNullOrEmpty(filter))
{
items.Add(new LauncherItem(
"등록된 매크로 없음",
"'macro new'로 명령 시퀀스를 추가하세요",
null, null, Symbol: Symbols.Info));
}
items.Add(new LauncherItem(
"새 매크로 만들기",
"macro new · 앱·URL·폴더·알림을 순서대로 실행",
null, "__new__", Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s)
{
if (s == "__new__")
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(null, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__edit__"))
{
var name = s["__edit__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(entry, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__del__"))
{
var name = s["__del__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
_settings.Settings.Macros.Remove(entry);
_settings.Save();
NotificationService.Notify("AX Copilot", $"매크로 '{name}' 삭제됨");
}
return Task.CompletedTask;
}
}
// 매크로 항목 Enter → 실행
if (item.Data is MacroEntry macro)
{
_ = RunMacroAsync(macro, ct);
}
return Task.CompletedTask;
}
// ─── 매크로 재생 ──────────────────────────────────────────────────────
internal static async Task RunMacroAsync(MacroEntry macro, CancellationToken ct)
{
int executed = 0;
foreach (var step in macro.Steps)
{
if (ct.IsCancellationRequested) break;
if (step.DelayMs > 0)
await Task.Delay(step.DelayMs, ct).ConfigureAwait(false);
try
{
switch (step.Type.ToLowerInvariant())
{
case "app":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo
{
FileName = step.Target,
Arguments = step.Args ?? "",
UseShellExecute = true
});
break;
case "url":
case "folder":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo(step.Target)
{ UseShellExecute = true });
break;
case "notification":
var msg = string.IsNullOrWhiteSpace(step.Label) ? step.Target : step.Label;
NotificationService.Notify($"[매크로] {macro.Name}", msg);
break;
case "cmd":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"{step.Target}\"")
{ UseShellExecute = false, CreateNoWindow = true });
break;
}
executed++;
}
catch (Exception ex)
{
LogService.Warn($"매크로 단계 실행 실패 '{step.Label}': {ex.Message}");
}
}
NotificationService.Notify("매크로 완료",
$"[{macro.Name}] {executed}/{macro.Steps.Count}단계 실행됨");
}
}

View File

@@ -0,0 +1,356 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다.
///
/// 예: md → 클립보드 Markdown 분석 (구조·통계)
/// md toc → 목차(TOC) 생성
/// md strip → Markdown 기호 제거 → 순수 텍스트
/// md count → 단어·줄·코드블록 수 세기
/// md links → 링크 목록 추출
/// md images → 이미지 목록 추출
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public partial class MdHandler : IActionHandler
{
public string? Prefix => "md";
public PluginMetadata Metadata => new(
"MD",
"Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드에서 텍스트 읽기
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipboard = Clipboard.GetText();
});
}
catch { /* 클립보드 접근 실패 */ }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("Markdown 분석기",
"클립보드 Markdown 분석 · md toc / strip / count / links / images",
null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("md toc", "목차(TOC) 생성", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("md strip", "Markdown 기호 제거", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("md count", "단어·줄·코드블록 통계", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("md links", "링크 목록 추출", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("md images", "이미지 목록 추출", null, null, Symbol: "\uE8A5"));
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 간단 미리보기 통계
var stat = QuickStat(clipboard);
items.Add(new LauncherItem("── 클립보드 미리보기 ──", "", null, null, Symbol: "\uE8A5"));
items.Add(new LauncherItem("클립보드 Markdown 분석", stat, null, ("copy", stat), Symbol: "\uE8A5"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 클립보드 없으면 서브커맨드도 안내만
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var sub = q.Split(' ')[0].ToLowerInvariant();
switch (sub)
{
case "toc":
items.AddRange(BuildTocItems(clipboard));
break;
case "strip":
case "plain":
case "text":
items.AddRange(BuildStripItems(clipboard));
break;
case "count":
case "stat":
case "stats":
items.AddRange(BuildCountItems(clipboard));
break;
case "links":
case "link":
items.AddRange(BuildLinkItems(clipboard));
break;
case "images":
case "image":
case "img":
items.AddRange(BuildImageItems(clipboard));
break;
default:
items.Add(new LauncherItem("알 수 없는 서브커맨드",
"toc · strip · count · links · images", null, null, Symbol: "\uE783"));
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("MD", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 분석 빌더 ─────────────────────────────────────────────────────────────
private static string QuickStat(string md)
{
var lines = md.Split('\n');
var headings = lines.Count(l => HeadingRegex().IsMatch(l));
var codeBlocks = CountCodeBlocks(md);
var links = LinkRegex().Matches(md).Count;
var words = WordCount(md);
return $"{lines.Length}줄 · 제목 {headings}개 · 코드블록 {codeBlocks}개 · 링크 {links}개 · 단어 {words}개";
}
private static List<LauncherItem> BuildTocItems(string md)
{
var items = new List<LauncherItem>();
var lines = md.Split('\n');
var headings = new List<(int Level, string Text, string Anchor)>();
foreach (var line in lines)
{
var m = HeadingRegex().Match(line);
if (!m.Success) continue;
var level = m.Groups[1].Value.Length;
var text = m.Groups[2].Value.Trim();
var anchor = MakeAnchor(text);
headings.Add((level, text, anchor));
}
if (headings.Count == 0)
{
items.Add(new LauncherItem("제목(#)이 없습니다", "Markdown 제목이 없으면 TOC를 생성할 수 없습니다",
null, null, Symbol: "\uE946"));
return items;
}
var sb = new StringBuilder();
foreach (var (level, text, anchor) in headings)
{
var indent = new string(' ', (level - 1) * 2);
sb.AppendLine($"{indent}- [{text}](#{anchor})");
}
var toc = sb.ToString().TrimEnd();
items.Add(new LauncherItem($"TOC 생성 완료 ({headings.Count}개 제목)",
"Enter → 전체 TOC 복사", null, ("copy", toc), Symbol: "\uE8A5"));
foreach (var (level, text, anchor) in headings.Take(20))
{
var prefix = new string('#', level) + " ";
var entry = $"- [{text}](#{anchor})";
items.Add(new LauncherItem($"{prefix}{text}", entry, null, ("copy", entry), Symbol: "\uE8A5"));
}
if (headings.Count > 20)
items.Add(new LauncherItem($"… 외 {headings.Count - 20}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5"));
return items;
}
private static List<LauncherItem> BuildStripItems(string md)
{
var items = new List<LauncherItem>();
var plain = StripMarkdown(md);
var preview = plain.Length > 80 ? plain[..80] + "…" : plain;
items.Add(new LauncherItem("Markdown 기호 제거 완료",
$"Enter → 순수 텍스트 복사 ({plain.Length}자)", null, ("copy", plain), Symbol: "\uE8A5"));
items.Add(new LauncherItem("미리보기", preview, null, ("copy", plain), Symbol: "\uE8A5"));
return items;
}
private static List<LauncherItem> BuildCountItems(string md)
{
var items = new List<LauncherItem>();
var lines = md.Split('\n');
var totalLines = lines.Length;
var blankLines = lines.Count(l => string.IsNullOrWhiteSpace(l));
var codeBlockCount= CountCodeBlocks(md);
var headingCount = lines.Count(l => HeadingRegex().IsMatch(l));
var listCount = lines.Count(l => ListRegex().IsMatch(l));
var linkCount = LinkRegex().Matches(md).Count;
var imageCount = ImageRegex().Matches(md).Count;
var boldCount = BoldRegex().Matches(md).Count;
var words = WordCount(md);
var chars = md.Length;
var charsNoSpace = md.Replace(" ", "").Replace("\n", "").Replace("\r", "").Length;
items.Add(new LauncherItem($"Markdown 통계", $"{totalLines}줄 · {words}단어 · {chars}자",
null, ("copy", $"줄 {totalLines} · 단어 {words} · 문자 {chars}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("전체 줄 수", $"{totalLines}줄 (공백 {blankLines}줄)", null, ("copy", $"{totalLines}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("단어 수", $"{words}단어", null, ("copy", $"{words}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("문자 수", $"{chars}자 (공백 제외 {charsNoSpace}자)", null, ("copy", $"{chars}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("제목(#) 수", $"{headingCount}개", null, ("copy", $"{headingCount}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("코드 블록 수", $"{codeBlockCount}개", null, ("copy", $"{codeBlockCount}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("목록 항목 수", $"{listCount}개", null, ("copy", $"{listCount}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("링크 수", $"{linkCount}개", null, ("copy", $"{linkCount}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("이미지 수", $"{imageCount}개", null, ("copy", $"{imageCount}"), Symbol: "\uE8A5"));
items.Add(new LauncherItem("강조(**bold**) 수", $"{boldCount}개", null, ("copy", $"{boldCount}"), Symbol: "\uE8A5"));
return items;
}
private static List<LauncherItem> BuildLinkItems(string md)
{
var items = new List<LauncherItem>();
var matches = LinkRegex().Matches(md);
if (matches.Count == 0)
{
items.Add(new LauncherItem("링크 없음", "클립보드 Markdown에 링크([text](url))가 없습니다",
null, null, Symbol: "\uE946"));
return items;
}
var allUrls = string.Join("\n", matches.Cast<Match>().Select(m => m.Groups[2].Value));
items.Add(new LauncherItem($"링크 {matches.Count}개 발견",
"Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5"));
foreach (Match m in matches.Cast<Match>().Take(25))
{
var text = m.Groups[1].Value;
var url = m.Groups[2].Value;
var display = text.Length > 30 ? text[..30] + "…" : text;
items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5"));
}
if (matches.Count > 25)
items.Add(new LauncherItem($"… 외 {matches.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5"));
return items;
}
private static List<LauncherItem> BuildImageItems(string md)
{
var items = new List<LauncherItem>();
var matches = ImageRegex().Matches(md);
if (matches.Count == 0)
{
items.Add(new LauncherItem("이미지 없음", "클립보드 Markdown에 이미지(![alt](url))가 없습니다",
null, null, Symbol: "\uE946"));
return items;
}
var allUrls = string.Join("\n", matches.Cast<Match>().Select(m => m.Groups[2].Value));
items.Add(new LauncherItem($"이미지 {matches.Count}개 발견",
"Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5"));
foreach (Match m in matches.Cast<Match>().Take(25))
{
var alt = m.Groups[1].Value;
var url = m.Groups[2].Value;
var display = string.IsNullOrWhiteSpace(alt) ? "(alt 없음)" : (alt.Length > 30 ? alt[..30] + "…" : alt);
items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5"));
}
return items;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string StripMarkdown(string md)
{
var s = md;
// 코드 블록 제거
s = Regex.Replace(s, @"```[\s\S]*?```", "", RegexOptions.Multiline);
s = Regex.Replace(s, @"`[^`]+`", "");
// 제목 기호 제거
s = Regex.Replace(s, @"^#{1,6}\s+", "", RegexOptions.Multiline);
// 이미지, 링크 → 텍스트만
s = Regex.Replace(s, @"!\[([^\]]*)\]\([^\)]*\)", "$1");
s = Regex.Replace(s, @"\[([^\]]+)\]\([^\)]+\)", "$1");
// 강조 기호 제거
s = Regex.Replace(s, @"\*{1,3}([^*]+)\*{1,3}", "$1");
s = Regex.Replace(s, @"_{1,3}([^_]+)_{1,3}", "$1");
// 인용 기호
s = Regex.Replace(s, @"^>\s+", "", RegexOptions.Multiline);
// 목록 기호
s = Regex.Replace(s, @"^[\-\*\+]\s+", "", RegexOptions.Multiline);
s = Regex.Replace(s, @"^\d+\.\s+", "", RegexOptions.Multiline);
// 수평선
s = Regex.Replace(s, @"^[-*_]{3,}\s*$", "", RegexOptions.Multiline);
// 다중 공백 정리
s = Regex.Replace(s, @"\n{3,}", "\n\n");
return s.Trim();
}
private static string MakeAnchor(string text)
{
var s = text.ToLowerInvariant();
s = Regex.Replace(s, @"[^\w\s\-가-힣]", "");
s = Regex.Replace(s, @"\s+", "-");
return s;
}
private static int CountCodeBlocks(string md)
{
var matches = Regex.Matches(md, @"^```", RegexOptions.Multiline);
return matches.Count / 2;
}
private static int WordCount(string md)
{
var plain = StripMarkdown(md);
return Regex.Matches(plain, @"\S+").Count;
}
[GeneratedRegex(@"^(#{1,6})\s+(.+)$", RegexOptions.Multiline)]
private static partial Regex HeadingRegex();
[GeneratedRegex(@"^\s*[\-\*\+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline)]
private static partial Regex ListRegex();
[GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"!\[([^\]]*)\]\(([^\)]+)\)")]
private static partial Regex ImageRegex();
[GeneratedRegex(@"\*{2,3}[^*]+\*{2,3}")]
private static partial Regex BoldRegex();
}

View File

@@ -0,0 +1,231 @@
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L27-4: 회의 링크 전용 관리 핸들러. "meet" 프리픽스로 사용합니다.
///
/// 예: meet → 전체 회의 목록
/// meet 스탠드업 → 이름 검색
/// meet add 스탠드업 https://... → 회의 추가
/// meet del 스탠드업 → 회의 삭제
/// Enter → 기본 브라우저로 회의 링크 열기.
/// 저장: %APPDATA%\AxCopilot\meet.json
/// </summary>
public class MeetHandler : IActionHandler
{
public string? Prefix => "meet";
public PluginMetadata Metadata => new(
"회의 링크",
"회의 링크 관리 — 추가 · 검색 · 즉시 열기",
"1.0",
"AX");
private record MeetEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("service")] string Service);
private static readonly string DataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "meet.json");
private static readonly JsonSerializerOptions JsonOpt = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var meets = Load();
// ── add 명령 ─────────────────────────────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var parts = q[4..].Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !Uri.TryCreate(parts[1], UriKind.Absolute, out _))
{
items.Add(new LauncherItem("사용법: meet add {이름} {URL}",
"예: meet add 스탠드업 https://teams.microsoft.com/...",
null, null, Symbol: "\uE710"));
}
else
{
var name = parts[0];
var url = parts[1];
var svc = DetectService(url);
items.Add(new LauncherItem(
$"회의 추가: {name}",
$"{svc} · {url}",
null, ("add", $"{name}\t{url}\t{svc}"), Symbol: "\uE710"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del 명령 ─────────────────────────────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var name = q[4..].Trim();
var found = meets.FirstOrDefault(m =>
m.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
items.Add(new LauncherItem(
$"회의 삭제: {found.Name}",
$"{found.Service} · {found.Url}",
null, ("del", found.Name), Symbol: "\uE74D"));
}
else
{
items.Add(new LauncherItem($"'{name}' 회의를 찾을 수 없습니다",
"meet del {이름}", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
if (meets.Count == 0)
{
items.Add(new LauncherItem("등록된 회의가 없습니다",
"meet add {이름} {URL} 로 추가하세요",
null, null, Symbol: "\uE8D6"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
$"회의 {meets.Count}개 등록됨",
"Enter: 브라우저로 열기 · meet add/del 로 관리",
null, null, Symbol: "\uE8D6"));
foreach (var m in meets)
items.Add(MeetItem(m));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 검색 ──────────────────────────────────────────────────────────────
var searched = meets.Where(m =>
m.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
m.Service.Contains(q, StringComparison.OrdinalIgnoreCase) ||
m.Url.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (searched.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 회의를 찾을 수 없습니다",
"meet add {이름} {URL} 로 추가하세요",
null, null, Symbol: "\uE783"));
}
else
{
foreach (var m in searched)
items.Add(MeetItem(m));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("add", string addData))
{
var parts = addData.Split('\t');
if (parts.Length >= 3)
{
var meets = Load();
meets.RemoveAll(m => m.Name.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
meets.Add(new MeetEntry(parts[0], parts[1], parts[2]));
Save(meets);
NotificationService.Notify("meet", $"'{parts[0]}' 회의가 추가되었습니다.");
}
}
else if (item.Data is ("del", string delName))
{
var meets = Load();
int removed = meets.RemoveAll(m => m.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
Save(meets);
if (removed > 0)
NotificationService.Notify("meet", $"'{delName}' 회의가 삭제되었습니다.");
}
else if (item.Data is ("open", string url))
{
try
{
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
NotificationService.Notify("meet", $"열기 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MeetItem(MeetEntry m)
{
var icon = m.Service switch
{
"Zoom" => "\uE774",
"Teams" => "\uE8D6",
"Google Meet" => "\uE774",
"Webex" => "\uE774",
_ => "\uE774"
};
return new LauncherItem(
$"{m.Name} [{m.Service}]",
m.Url,
null, ("open", m.Url), Symbol: icon);
}
private static string DetectService(string url)
{
var lower = url.ToLowerInvariant();
if (lower.Contains("zoom.us") || lower.Contains("zoom.com")) return "Zoom";
if (lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com")) return "Teams";
if (lower.Contains("meet.google.com")) return "Google Meet";
if (lower.Contains("webex.com")) return "Webex";
if (lower.Contains("discord.gg") || lower.Contains("discord.com")) return "Discord";
if (lower.Contains("slack.com")) return "Slack";
return "기타";
}
// ─── JSON 파일 I/O ───────────────────────────────────────────────────────
private static List<MeetEntry> Load()
{
try
{
if (!File.Exists(DataPath)) return [];
var json = File.ReadAllText(DataPath);
return JsonSerializer.Deserialize<List<MeetEntry>>(json) ?? [];
}
catch { return []; }
}
private static void Save(List<MeetEntry> list)
{
try
{
var dir = Path.GetDirectoryName(DataPath)!;
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
}
catch { }
}
}

View File

@@ -0,0 +1,251 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다.
///
/// 예: morse hello → 텍스트 → 모스 부호
/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분)
/// morse SOS → SOS 모스 부호
/// morse → 클립보드 자동 감지·변환
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class MorseHandler : IActionHandler
{
public string? Prefix => "morse";
public PluginMetadata Metadata => new(
"Morse",
"모스 부호 변환기 — 텍스트 ↔ 모스 부호",
"1.0",
"AX");
// ── 모스 부호 사전 ────────────────────────────────────────────────────────
private static readonly Dictionary<char, string> TextToMorse = new()
{
['A'] = ".-", ['B'] = "-...", ['C'] = "-.-.", ['D'] = "-..",
['E'] = ".", ['F'] = "..-.", ['G'] = "--.", ['H'] = "....",
['I'] = "..", ['J'] = ".---", ['K'] = "-.-", ['L'] = ".-..",
['M'] = "--", ['N'] = "-.", ['O'] = "---", ['P'] = ".--.",
['Q'] = "--.-", ['R'] = ".-.", ['S'] = "...", ['T'] = "-",
['U'] = "..-", ['V'] = "...-", ['W'] = ".--", ['X'] = "-..-",
['Y'] = "-.--", ['Z'] = "--..",
['0'] = "-----", ['1'] = ".----", ['2'] = "..---", ['3'] = "...--",
['4'] = "....-", ['5'] = ".....", ['6'] = "-....", ['7'] = "--...",
['8'] = "---..", ['9'] = "----.",
['.'] = ".-.-.-", [','] = "--..--", ['?'] = "..--..", ['!'] = "-.-.--",
['/'] = "-..-.", ['-'] = "-....-", ['('] = "-.--.", [')'] = "-.--.-",
['@'] = ".--.-.", ['='] = "-...-", ['+'] = ".-.-.", [':'] = "---...",
[';'] = "-.-.-.", ['"'] = ".-..-.", ['\''] = ".----.", ['_'] = "..--.-",
[' '] = "/",
};
private static readonly Dictionary<string, char> MorseToText;
// 번개 부호 / 대문자 표
private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"];
private static readonly Dictionary<string, string> ProsignMorse = new()
{
["SOS"] = "... --- ...",
["AR"] = ".-.-.",
["AS"] = ".-...",
["BT"] = "-...-",
["KN"] = "-.--.",
["SK"] = "...-.-",
};
static MorseHandler()
{
MorseToText = TextToMorse
.Where(kv => kv.Key != ' ')
.ToDictionary(kv => kv.Value, kv => kv.Key);
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드 자동 감지
var clip = GetClipboard();
if (!string.IsNullOrWhiteSpace(clip))
{
if (IsMorseCode(clip))
items.AddRange(BuildMorseToText(clip));
else if (clip.Length <= 100)
items.AddRange(BuildTextToMorse(clip));
}
if (items.Count == 0)
{
items.Add(new LauncherItem("모스 부호 변환기",
"예: morse hello / morse .- -... -.-.",
null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("morse SOS", "SOS 모스 부호", null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("morse hello", "텍스트 → 모스", null, null, Symbol: "\uE8C4"));
items.Add(new LauncherItem("morse .- -...", "모스 → 텍스트", null, null, Symbol: "\uE8C4"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 프로사인 키워드 우선
if (ProsignMorse.TryGetValue(q.ToUpperInvariant(), out var psCode))
{
items.Add(new LauncherItem(
$"{q.ToUpper()} = {psCode}",
"모스 부호 프로사인 · Enter 복사",
null, ("copy", psCode), Symbol: "\uE8C4"));
items.AddRange(BuildTextToMorse(q.ToUpper()));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 모스 부호 입력 감지
if (IsMorseCode(q))
{
items.AddRange(BuildMorseToText(q));
}
else
{
items.AddRange(BuildTextToMorse(q));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Morse", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 변환 로직 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildTextToMorse(string text)
{
var upper = text.ToUpperInvariant();
var words = upper.Split(' ');
var morseSb = new StringBuilder();
var unknown = new List<char>();
foreach (var word in words)
{
if (morseSb.Length > 0) morseSb.Append("/ "); // 단어 구분
foreach (var ch in word)
{
if (TextToMorse.TryGetValue(ch, out var code))
morseSb.Append(code + " ");
else
unknown.Add(ch);
}
}
var morseStr = morseSb.ToString().TrimEnd();
if (string.IsNullOrEmpty(morseStr))
{
yield return new LauncherItem("변환 불가", "모스 부호에 없는 문자입니다", null, null, Symbol: "\uE783");
yield break;
}
yield return new LauncherItem(
morseStr.Length > 80 ? morseStr[..80] + "…" : morseStr,
$"'{text}' → 모스 부호 · Enter 복사",
null,
("copy", morseStr),
Symbol: "\uE8C4");
if (unknown.Count > 0)
yield return new LauncherItem("변환 불가 문자",
string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
// 문자별 표 (최대 10자)
var displayText = upper.Replace(" ", "");
foreach (var ch in displayText.Take(10))
{
if (TextToMorse.TryGetValue(ch, out var code) && ch != ' ')
yield return new LauncherItem($"{ch} = {code}", "문자별 코드", null, ("copy", code), Symbol: "\uE8C4");
}
}
private static IEnumerable<LauncherItem> BuildMorseToText(string morse)
{
// "/" 는 단어 구분, 공백은 문자 구분
var sb = new StringBuilder();
var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
var unknown = new List<string>();
foreach (var word in words)
{
var codes = word.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
foreach (var code in codes)
{
if (MorseToText.TryGetValue(code, out var ch))
sb.Append(ch);
else
unknown.Add(code);
}
sb.Append(' ');
}
var result = sb.ToString().Trim();
if (string.IsNullOrEmpty(result))
{
yield return new LauncherItem("변환 실패", "인식할 수 없는 모스 부호입니다", null, null, Symbol: "\uE783");
yield break;
}
yield return new LauncherItem(
result,
$"모스 부호 → '{result}' · Enter 복사",
null,
("copy", result),
Symbol: "\uE8C4");
if (unknown.Count > 0)
yield return new LauncherItem("인식 불가 코드",
string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
// 코드별 표 (최대 10개)
var codes_ = morse.Trim().Split(new[] { ' ', '/' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var code in codes_.Take(10))
{
if (MorseToText.TryGetValue(code, out var ch))
yield return new LauncherItem($"{code} = {ch}", "코드별 문자", null, ("copy", ch.ToString()), Symbol: "\uE8C4");
}
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool IsMorseCode(string s)
{
// .-/ 문자와 공백만으로 구성되어 있으면 모스 부호로 판단
return !string.IsNullOrWhiteSpace(s) &&
s.All(c => c is '.' or '-' or '/' or ' ') &&
(s.Contains('.') || s.Contains('-'));
}
private static string GetClipboard()
{
try
{
return System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
}
catch { return ""; }
}
}

View File

@@ -0,0 +1,365 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-4: 네트워크 진단 핸들러. "net" 프리픽스로 사용합니다.
/// (기존 PortHandler의 포트/프로세스 조회와 구분되는 어댑터·핑·DNS 진단 기능)
///
/// 예: net → 활성 네트워크 어댑터 IP 목록
/// net ping 8.8.8.8 → 핑 테스트 (사외 모드에서만 외부 호스트)
/// net ping localhost → 로컬 핑 (항상 허용)
/// net dns google.com → DNS A 레코드 조회 (사외 모드에서만 외부)
/// net ip → 로컬 IP 정보 (공인 IP는 사외 모드에서만 표시)
/// net adapter → 네트워크 어댑터 전체 정보
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class NetDiagHandler : IActionHandler
{
public string? Prefix => "net";
public PluginMetadata Metadata => new(
"NetDiag",
"네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태",
"1.0",
"AX");
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 로컬 어댑터 IP 빠른 표시
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
ip,
$"{name} · MAC: {mac}",
null,
("copy", ip),
Symbol: "\uE968"));
}
// 서브커맨드 안내
items.Add(new LauncherItem("net ping <호스트>", "핑 테스트", null, null, Symbol: "\uE8F2"));
items.Add(new LauncherItem("net dns <도메인>", "DNS A 레코드 조회", null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("net ip", "로컬 IP 전체 정보", null, ("ip_info", ""), Symbol: "\uE968"));
items.Add(new LauncherItem("net adapter", "어댑터 세부 정보", null, ("adapter_info", ""), Symbol: "\uE968"));
return items;
}
// ── ping ──────────────────────────────────────────────────────────
if (q.StartsWith("ping "))
{
var host = query.Trim()["ping ".Length..].Trim();
if (string.IsNullOrEmpty(host))
{
items.Add(new LauncherItem("호스트를 입력하세요", "예: net ping 192.168.1.1", null, null, Symbol: "\uE783"));
return items;
}
// 사내 모드에서는 내부 호스트만 허용
if (!IsAllowedInInternalMode(host))
{
items.Add(new LauncherItem(
"사내 모드 — 외부 호스트 차단",
"사외 모드에서만 외부 IP/도메인에 핑 가능합니다",
null, null, Symbol: "\uE785"));
return items;
}
items.Add(new LauncherItem(
$"Ping: {host}",
"테스트 중...",
null,
("ping", host),
Symbol: "\uE8F2"));
// 비동기 핑 시도
try
{
var result = await PingAsync(host, ct);
items.Clear();
foreach (var r in result)
items.Add(r);
}
catch (OperationCanceledException) { }
return items;
}
// ── dns ──────────────────────────────────────────────────────────
if (q.StartsWith("dns "))
{
var domain = query.Trim()["dns ".Length..].Trim();
if (string.IsNullOrEmpty(domain))
{
items.Add(new LauncherItem("도메인을 입력하세요", "예: net dns example.com", null, null, Symbol: "\uE783"));
return items;
}
if (!IsAllowedInInternalMode(domain))
{
items.Add(new LauncherItem(
"사내 모드 — 외부 도메인 차단",
"사외 모드에서만 외부 도메인 DNS 조회 가능합니다",
null, null, Symbol: "\uE785"));
return items;
}
try
{
var ips = await Dns.GetHostAddressesAsync(domain, ct);
if (ips.Length == 0)
{
items.Add(new LauncherItem($"{domain}", "DNS 조회 결과 없음", null, null, Symbol: "\uE783"));
}
else
{
items.Add(new LauncherItem(
$"{domain} — {ips.Length}개 레코드",
string.Join(", ", ips.Select(ip => ip.ToString())),
null,
("copy", string.Join("\n", ips.Select(ip => ip.ToString()))),
Symbol: "\uE968"));
foreach (var ip in ips)
{
items.Add(new LauncherItem(
ip.ToString(),
ip.AddressFamily == AddressFamily.InterNetworkV6 ? "IPv6" : "IPv4",
null,
("copy", ip.ToString()),
Symbol: "\uE968"));
}
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("DNS 조회 실패", ex.Message, null, null, Symbol: "\uE783"));
}
return items;
}
// ── ip ────────────────────────────────────────────────────────────
if (q == "ip")
{
items.Add(new LauncherItem("로컬 IP 정보", "어댑터별 IP 주소", null, ("ip_info", ""), Symbol: "\uE968"));
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
ip,
$"{name} · MAC: {mac}",
null,
("copy", ip),
Symbol: "\uE968"));
}
return items;
}
// ── adapter ──────────────────────────────────────────────────────
if (q.StartsWith("adapter"))
{
items.Add(new LauncherItem(
"어댑터 전체 정보 (클립보드 복사)",
"활성 어댑터, IP, MAC, 속도",
null,
("adapter_info", ""),
Symbol: "\uE968"));
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
name,
$"IP: {ip} · MAC: {mac}",
null,
("copy", $"{name}: {ip} ({mac})"),
Symbol: "\uE968"));
}
return items;
}
// 미인식 → 기본 표시
var defaultAdapters = GetLocalAdapters();
foreach (var (name, ip, mac) in defaultAdapters)
{
items.Add(new LauncherItem(ip, $"{name} · MAC: {mac}", null, ("copy", ip), Symbol: "\uE968"));
}
return items;
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
TryCopyToClipboard(text);
NotificationService.Notify("NetDiag", "클립보드에 복사했습니다.");
break;
case ("ping", string host):
var pingItems = await PingAsync(host, ct);
var summary = string.Join("\n", pingItems.Select(i => $"{i.Title} {i.Subtitle}"));
TryCopyToClipboard(summary);
NotificationService.Notify("Ping", pingItems.FirstOrDefault()?.Title ?? host);
break;
case ("ip_info", _):
case ("adapter_info", _):
var adapterInfo = BuildAdapterInfoText();
TryCopyToClipboard(adapterInfo);
NotificationService.Notify("NetDiag", "어댑터 정보를 클립보드에 복사했습니다.");
break;
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static List<(string Name, string IP, string MAC)> GetLocalAdapters()
{
var result = new List<(string, string, string)>();
try
{
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
var props = adapter.GetIPProperties();
foreach (var addr in props.UnicastAddresses)
{
if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
.Replace('-', ':');
result.Add((adapter.Name, addr.Address.ToString(), mac));
}
}
}
catch { /* 비핵심 */ }
return result;
}
private static string BuildAdapterInfoText()
{
var sb = new StringBuilder();
try
{
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
var props = adapter.GetIPProperties();
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
.Replace('-', ':');
var speed = adapter.Speed > 0 ? $"{adapter.Speed / 1_000_000} Mbps" : "-";
sb.AppendLine($"[{adapter.Name}]");
sb.AppendLine($" 속도: {speed}");
sb.AppendLine($" MAC: {mac}");
foreach (var addr in props.UnicastAddresses)
sb.AppendLine($" IP: {addr.Address} / {addr.PrefixLength}");
foreach (var gw in props.GatewayAddresses)
sb.AppendLine($" GW: {gw.Address}");
sb.AppendLine();
}
}
catch { /* 비핵심 */ }
return sb.ToString().Trim();
}
private static async Task<List<LauncherItem>> PingAsync(string host, CancellationToken ct)
{
var items = new List<LauncherItem>();
try
{
using var pinger = new Ping();
var results = new List<(bool Ok, long Ms, string Status)>();
for (int i = 0; i < 4; i++)
{
ct.ThrowIfCancellationRequested();
try
{
var reply = await pinger.SendPingAsync(host, 1500);
results.Add((reply.Status == IPStatus.Success,
reply.RoundtripTime,
reply.Status.ToString()));
}
catch
{
results.Add((false, -1, "Timeout"));
}
if (i < 3) await Task.Delay(200, ct);
}
var successCount = results.Count(r => r.Ok);
var avgMs = results.Where(r => r.Ok).Select(r => r.Ms).DefaultIfEmpty(0)
.Average();
var loss = (4 - successCount) * 25;
items.Add(new LauncherItem(
$"Ping {host} {avgMs:F0}ms",
$"패킷 손실: {loss}% ({successCount}/4 성공)",
null,
("copy", $"Ping {host}: {avgMs:F0}ms, 손실 {loss}%"),
Symbol: successCount == 4 ? "\uE73E" : successCount == 0 ? "\uE783" : "\uE7BA"));
for (int i = 0; i < results.Count; i++)
{
var (ok, ms, status) = results[i];
items.Add(new LauncherItem(
ok ? $"응답 {ms}ms" : "시간 초과",
$"#{i + 1} {status}",
null,
("copy", ok ? $"{ms}ms" : "timeout"),
Symbol: ok ? "\uE73E" : "\uE783"));
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
items.Add(new LauncherItem("핑 실패", ex.Message, null, null, Symbol: "\uE783"));
}
return items;
}
/// <summary>사내 모드에서 외부 호스트 차단 여부 확인.</summary>
private static bool IsAllowedInInternalMode(string host)
{
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
var isInternal = settings?.InternalModeEnabled ?? true;
if (!isInternal) return true; // 사외 모드: 모두 허용
// 사내 모드: 내부 주소만 허용
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.StartsWith("127.", StringComparison.Ordinal)
|| host.StartsWith("192.168.", StringComparison.Ordinal)
|| host.StartsWith("10.", StringComparison.Ordinal)
|| host.StartsWith("172.", StringComparison.Ordinal);
}
private static void TryCopyToClipboard(string text)
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,139 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-8: 알림 센터 핸들러. "notif" 프리픽스로 사용합니다.
/// AX Copilot이 표시한 최근 알림 이력을 런처에서 조회합니다.
///
/// 사용법:
/// notif → 최근 알림 이력 목록
/// notif clear → 알림 이력 초기화
/// notif [검색어] → 제목·내용으로 필터링
///
/// Enter → 알림 내용을 클립보드에 복사.
/// </summary>
public class NotifHandler : IActionHandler
{
public string? Prefix => "notif";
public PluginMetadata Metadata => new(
"NotifCenter",
"알림 센터 — notif",
"1.0",
"AX",
"AX Copilot 알림 이력을 조회합니다.");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// clear 명령
if (q.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
var count = NotificationCenterService.History.Count;
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"알림 이력 초기화 ({count}건)",
"Enter를 눌러 전체 삭제",
null, "__CLEAR__",
Symbol: Symbols.Delete)
]);
}
var history = NotificationCenterService.History;
// 검색어 필터
IEnumerable<NotificationEntry> filtered = history;
if (!string.IsNullOrEmpty(q))
{
filtered = history
.Where(e => e.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
|| e.Message.Contains(q, StringComparison.OrdinalIgnoreCase));
}
var list = filtered.Take(12).ToList();
if (list.Count == 0)
{
var emptyMsg = string.IsNullOrEmpty(q)
? "알림 이력이 없습니다"
: $"'{q}'에 해당하는 알림 없음";
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
emptyMsg,
"AX Copilot 동작 중 발생한 알림이 여기에 표시됩니다",
null, null,
Symbol: Symbols.Info)
]);
}
var items = list
.Select(e => new LauncherItem(
e.Title,
$"{e.Message} · {TimeAgo(e.Timestamp)}",
null, e,
Symbol: GetSymbol(e)))
.ToList<LauncherItem>();
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 이력 초기화
if (item.Data is string s && s == "__CLEAR__")
{
NotificationCenterService.ClearHistory();
NotificationService.LogOnly("AX Copilot", "알림 이력이 초기화되었습니다.");
return Task.CompletedTask;
}
// 알림 내용 클립보드 복사
if (item.Data is NotificationEntry entry)
{
try
{
Application.Current?.Dispatcher.Invoke(() =>
Clipboard.SetText($"[{entry.Title}] {entry.Message}"));
}
catch (Exception ex)
{
LogService.Warn($"[NotifHandler] 클립보드 복사 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private static string TimeAgo(DateTime timestamp)
{
var diff = DateTime.Now - timestamp;
if (diff.TotalSeconds < 60) return "방금 전";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전";
return $"{(int)diff.TotalDays}일 전";
}
private static string GetSymbol(NotificationEntry entry) => entry.Type switch
{
NotificationType.Error => Symbols.Error,
NotificationType.Warning => Symbols.Warning,
NotificationType.Success => Symbols.Favorite,
_ => entry.Title switch
{
var t when t.Contains("태그") => Symbols.Tag,
var t when t.Contains("즐겨찾기") => Symbols.Favorite,
var t when t.Contains("저장")
|| t.Contains("내보내기") => Symbols.Save,
_ => Symbols.ReminderBell,
}
};
}

View File

@@ -0,0 +1,342 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-4: npm/yarn/pnpm 패키지 매니저 명령어 생성·실행 핸들러. "npm" 프리픽스로 사용합니다.
///
/// 예: npm → 자주 쓰는 명령어 목록
/// npm init → npm/yarn/pnpm init 명령 비교
/// npm install lodash → npm/yarn/pnpm install lodash 명령 비교
/// npm i lodash → install 단축키
/// npm run dev → npm/yarn/pnpm run dev
/// npm uninstall lodash → npm/yarn/pnpm uninstall
/// npm update → 패키지 업데이트 명령
/// npm list → 패키지 목록 명령
/// npm audit → 보안 감사 명령
/// npm publish → 배포 명령
/// npm scripts → package.json scripts 확인 방법
/// npm global → 전역 패키지 목록 명령
/// npm clean → 캐시·node_modules 정리 명령
/// Enter → 클립보드 복사 (또는 터미널에서 실행).
/// </summary>
public class NpmHandler : IActionHandler
{
public string? Prefix => "npm";
public PluginMetadata Metadata => new(
"NPM",
"npm/yarn/pnpm 명령어 생성기 — install·run·audit·publish·clean",
"1.0",
"AX");
private record PmCmd(string Manager, string Cmd, string Description);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("npm/yarn/pnpm 명령어 생성기",
"npm install / run / init / build / test / audit / publish / clean …",
null, null, Symbol: "\uE756"));
AddCommandGroups(items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
var arg = parts.Length > 1 ? parts[1].Trim() : "";
// 단축키 정규화
sub = sub switch
{
"i" => "install",
"un" => "uninstall",
"rm" => "uninstall",
"up" => "update",
"ls" => "list",
_ => sub
};
switch (sub)
{
case "init":
AddSection(items, "프로젝트 초기화", [
new("npm", "npm init -y", "기본값으로 package.json 생성"),
new("yarn", "yarn init -y", "기본값으로 package.json 생성"),
new("pnpm", "pnpm init", "기본값으로 package.json 생성"),
]);
break;
case "install" when !string.IsNullOrWhiteSpace(arg):
AddSection(items, $"패키지 설치: {arg}", [
new("npm", $"npm install {arg}", $"npm으로 {arg} 설치"),
new("yarn", $"yarn add {arg}", $"yarn으로 {arg} 설치"),
new("pnpm", $"pnpm add {arg}", $"pnpm으로 {arg} 설치"),
]);
AddSection(items, $"개발 의존성으로 설치: {arg}", [
new("npm", $"npm install {arg} --save-dev", "devDependencies에 추가"),
new("yarn", $"yarn add {arg} --dev", "devDependencies에 추가"),
new("pnpm", $"pnpm add {arg} --save-dev", "devDependencies에 추가"),
]);
AddSection(items, $"전역 설치: {arg}", [
new("npm", $"npm install -g {arg}", "전역 설치"),
new("yarn", $"yarn global add {arg}", "전역 설치"),
new("pnpm", $"pnpm add -g {arg}", "전역 설치"),
]);
break;
case "install":
AddSection(items, "의존성 설치 (node_modules)", [
new("npm", "npm install", "package.json 의존성 모두 설치"),
new("yarn", "yarn install", "package.json 의존성 모두 설치"),
new("pnpm", "pnpm install", "package.json 의존성 모두 설치"),
]);
AddSection(items, "프로덕션 의존성만", [
new("npm", "npm install --production", "--production 플래그"),
new("yarn", "yarn install --production", "--production 플래그"),
new("pnpm", "pnpm install --prod", "--prod 플래그"),
]);
break;
case "uninstall" when !string.IsNullOrWhiteSpace(arg):
AddSection(items, $"패키지 제거: {arg}", [
new("npm", $"npm uninstall {arg}", $"npm으로 {arg} 제거"),
new("yarn", $"yarn remove {arg}", $"yarn으로 {arg} 제거"),
new("pnpm", $"pnpm remove {arg}", $"pnpm으로 {arg} 제거"),
]);
break;
case "run" when !string.IsNullOrWhiteSpace(arg):
AddSection(items, $"스크립트 실행: {arg}", [
new("npm", $"npm run {arg}", $"npm run {arg}"),
new("yarn", $"yarn {arg}", $"yarn {arg}"),
new("pnpm", $"pnpm run {arg}", $"pnpm run {arg}"),
]);
break;
case "start":
case "run":
AddSection(items, "주요 스크립트", [
new("npm", "npm start", "start 스크립트 실행"),
new("npm", "npm run dev", "dev 스크립트 실행"),
new("npm", "npm run build", "build 스크립트 실행"),
new("npm", "npm test", "test 스크립트 실행"),
]);
AddSection(items, "yarn 동등 명령", [
new("yarn", "yarn start", "start"),
new("yarn", "yarn dev", "dev"),
new("yarn", "yarn build", "build"),
new("yarn", "yarn test", "test"),
]);
AddSection(items, "pnpm 동등 명령", [
new("pnpm", "pnpm start", "start"),
new("pnpm", "pnpm run dev", "dev"),
new("pnpm", "pnpm run build", "build"),
new("pnpm", "pnpm test", "test"),
]);
break;
case "build":
AddSection(items, "빌드", [
new("npm", "npm run build", "build 스크립트"),
new("yarn", "yarn build", "build 스크립트"),
new("pnpm", "pnpm run build", "build 스크립트"),
]);
break;
case "test":
AddSection(items, "테스트", [
new("npm", "npm test", "test 스크립트"),
new("npm", "npm run test:watch", "테스트 와처"),
new("yarn", "yarn test", "test 스크립트"),
new("pnpm", "pnpm test", "test 스크립트"),
]);
break;
case "update" or "upgrade":
AddSection(items, "패키지 업데이트", [
new("npm", "npm update", "모든 패키지 업데이트"),
new("npm", "npm outdated", "오래된 패키지 확인"),
new("npm", "npx npm-check-updates", "버전 업그레이드 체크"),
new("yarn", "yarn upgrade", "모든 패키지 업그레이드"),
new("pnpm", "pnpm update", "모든 패키지 업데이트"),
]);
break;
case "list" or "ls":
AddSection(items, "패키지 목록", [
new("npm", "npm list", "설치된 패키지 목록"),
new("npm", "npm list --depth=0", "최상위 패키지만"),
new("npm", "npm list -g --depth=0", "전역 패키지 목록"),
new("yarn", "yarn list", "설치된 패키지 목록"),
new("pnpm", "pnpm list", "설치된 패키지 목록"),
]);
break;
case "audit":
AddSection(items, "보안 감사", [
new("npm", "npm audit", "보안 취약점 스캔"),
new("npm", "npm audit fix", "자동 수정"),
new("yarn", "yarn audit", "보안 취약점 스캔"),
new("pnpm", "pnpm audit", "보안 취약점 스캔"),
]);
break;
case "publish":
AddSection(items, "패키지 배포", [
new("npm", "npm publish", "npm 레지스트리에 배포"),
new("npm", "npm publish --access public","공개 패키지로 배포"),
new("npm", "npm version patch", "패치 버전 올리기"),
new("npm", "npm version minor", "마이너 버전 올리기"),
new("npm", "npm version major", "메이저 버전 올리기"),
new("yarn", "yarn publish", "yarn으로 배포"),
new("pnpm", "pnpm publish", "pnpm으로 배포"),
]);
break;
case "scripts":
AddSection(items, "package.json 스크립트 확인", [
new("npm", "npm run", "스크립트 목록 보기"),
new("npm", "cat package.json", "package.json 확인"),
new("yarn", "yarn run", "스크립트 목록 보기"),
new("pnpm", "pnpm run", "스크립트 목록 보기"),
]);
break;
case "global":
AddSection(items, "전역 패키지", [
new("npm", "npm list -g --depth=0", "전역 패키지 목록"),
new("npm", "npm root -g", "전역 node_modules 경로"),
new("yarn", "yarn global list", "전역 패키지 목록"),
new("pnpm", "pnpm list -g", "전역 패키지 목록"),
]);
break;
case "clean":
AddSection(items, "캐시 및 모듈 정리", [
new("npm", "npm cache clean --force", "npm 캐시 삭제"),
new("npm", "Remove-Item -Recurse -Force node_modules", "node_modules 삭제 (PowerShell)"),
new("npm", "rm -rf node_modules && npm install", "재설치 (bash)"),
new("yarn", "yarn cache clean", "yarn 캐시 삭제"),
new("pnpm", "pnpm store prune", "pnpm 스토어 정리"),
]);
break;
case "ci":
AddSection(items, "CI/CD용 클린 설치", [
new("npm", "npm ci", "package-lock.json 기반 클린 설치"),
new("yarn", "yarn install --frozen-lockfile", "lockfile 기반 클린 설치"),
new("pnpm", "pnpm install --frozen-lockfile", "lockfile 기반 클린 설치"),
]);
break;
case "lock":
AddSection(items, "lockfile 관리", [
new("npm", "npm install --package-lock-only", "lockfile만 갱신"),
new("yarn", "yarn import", "package-lock → yarn.lock 변환"),
new("pnpm", "pnpm import", "다른 lockfile → pnpm-lock.yaml 변환"),
]);
break;
default:
items.Add(new LauncherItem($"알 수 없는 명령: '{sub}'",
"init · install · uninstall · run · build · test · update · list · audit · publish · clean · global",
null, null, Symbol: "\uE783"));
AddCommandGroups(items);
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("NPM", "클립보드에 복사했습니다.");
}
catch { }
break;
case ("run", string cmd):
RunInTerminal(cmd);
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static void AddCommandGroups(List<LauncherItem> items)
{
var groups = new (string Title, string[] Keys)[]
{
("초기화·설치", ["npm init", "npm install", "npm install <패키지>"]),
("개발 워크플로우", ["npm start", "npm run dev", "npm run build", "npm test"]),
("패키지 관리", ["npm update", "npm uninstall <패키지>", "npm list"]),
("보안·배포", ["npm audit", "npm audit fix", "npm publish"]),
("캐시·정리", ["npm cache clean --force", "npm ci"]),
};
foreach (var (title, keys) in groups)
items.Add(new LauncherItem(title, string.Join(" / ", keys), null, null, Symbol: "\uE756"));
}
private static void AddSection(List<LauncherItem> items, string title, PmCmd[] cmds)
{
items.Add(new LauncherItem($"── {title} ──", "", null, null, Symbol: "\uE756"));
foreach (var c in cmds)
{
var icon = c.Manager switch
{
"yarn" => "🧶",
"pnpm" => "📦",
_ => "📦"
};
items.Add(new LauncherItem(c.Cmd, $"{c.Manager} · {c.Description} · Enter 복사",
null, ("copy", c.Cmd), Symbol: "\uE756"));
}
}
private static void RunInTerminal(string cmd)
{
try
{
// Windows Terminal 우선
var wtPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@"Microsoft\WindowsApps\wt.exe");
if (File.Exists(wtPath))
{
Process.Start(new ProcessStartInfo
{
FileName = wtPath,
Arguments = $"cmd /k \"{cmd}\"",
UseShellExecute = true
});
return;
}
// cmd 폴백
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/k {cmd}",
UseShellExecute = true
});
}
catch { }
}
}

View File

@@ -0,0 +1,288 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L17-2: 숫자 포맷·읽기 변환 핸들러. "num" 프리픽스로 사용합니다.
///
/// 예: num 1234567 → 천단위·한글·영어·진수·과학표기 모두 표시
/// num 0xff → 16진수 → 10진수 변환
/// num 0b1010 → 2진수 → 10진수 변환
/// num 42 ko → 한국어로 읽기 (사십이)
/// num 42 en → 영어로 읽기 (forty-two)
/// num 1e6 → 과학표기 → 일반 변환
/// Enter → 결과 복사.
/// </summary>
public class NumHandler : IActionHandler
{
public string? Prefix => "num";
public PluginMetadata Metadata => new(
"Num",
"숫자 포맷·읽기 변환 — 한글·영어·진수·천단위·과학표기",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("숫자 포맷·읽기 변환기",
"예: num 1234567 / num 0xff / num 42 ko / num 42 en",
null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num <숫자>", "천단위·한글·영어·진수 변환", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num 0xff", "16진수 입력", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num 0b1010", "2진수 입력", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num 0o17", "8진수 입력", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num 42 ko", "한국어로 읽기", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("num 42 en", "영어로 읽기", null, null, Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var numStr = parts[0];
var mode = parts.Length > 1 ? parts[1].ToLowerInvariant() : null;
// 숫자 파싱 (여러 진수 지원)
if (!TryParseNumber(numStr, out var value, out var inputBase))
{
items.Add(new LauncherItem("숫자 형식 오류",
"정수 또는 0x/0b/0o 접두사 숫자를 입력하세요", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 한국어 읽기 전용
if (mode is "ko" or "kr" or "한국어" or "한글")
{
var ko = ToKorean(value);
items.Add(new LauncherItem(ko, $"{value:N0} 한국어 읽기", null, ("copy", ko), Symbol: "\uE8EF"));
var ko2 = ToKoreanMoney(value);
items.Add(new LauncherItem(ko2, "금액 읽기 (원)", null, ("copy", ko2), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 영어 읽기 전용
if (mode is "en" or "english" or "영어")
{
var en = ToEnglish(value);
items.Add(new LauncherItem(en, $"{value:N0} 영어 읽기", null, ("copy", en), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 전체 변환 표시
items.Add(new LauncherItem($"{value:N0}",
$"입력값 ({inputBase}진수 → 10진수)",
null, ("copy", $"{value}"), Symbol: "\uE8EF"));
// 천단위 구분
var commaSep = $"{value:N0}";
items.Add(new LauncherItem(commaSep, "천단위 구분", null, ("copy", commaSep), Symbol: "\uE8EF"));
// 한글 단위 (만·억·조)
var krUnit = ToKoreanUnit(value);
items.Add(new LauncherItem(krUnit, "한글 단위", null, ("copy", krUnit), Symbol: "\uE8EF"));
// 한국어 읽기
var koRead = ToKorean(value);
items.Add(new LauncherItem(koRead, "한국어 읽기", null, ("copy", koRead), Symbol: "\uE8EF"));
// 영어 읽기
if (Math.Abs(value) < 1_000_000_000_000L)
{
var enRead = ToEnglish(value);
items.Add(new LauncherItem(enRead, "영어 읽기", null, ("copy", enRead), Symbol: "\uE8EF"));
}
// 진수 변환 (정수 범위만)
if (value >= 0 && value <= long.MaxValue)
{
var lv = (long)value;
var hex = $"0x{lv:X}";
var bin = Convert.ToString(lv, 2);
var oct = Convert.ToString(lv, 8);
items.Add(new LauncherItem($"── 진수 변환 ──", "", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem($"16진수 {hex}", $"Hex", null, ("copy", hex), Symbol: "\uE8EF"));
items.Add(new LauncherItem($"8진수 0o{oct}", $"Octal", null, ("copy", $"0o{oct}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem($"2진수 {bin}", $"Binary ({bin.Length}bit)", null, ("copy", bin), Symbol: "\uE8EF"));
}
// 과학 표기
var sci = $"{value:E4}";
items.Add(new LauncherItem($"과학 표기 {sci}", "Scientific Notation", null, ("copy", sci), Symbol: "\uE8EF"));
// 로마 숫자 (1~3999)
if (value >= 1 && value <= 3999)
{
var roman = ToRoman((int)value);
items.Add(new LauncherItem($"로마 숫자 {roman}", "Roman Numerals", null, ("copy", roman), Symbol: "\uE8EF"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Num", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 파싱 ─────────────────────────────────────────────────────────────────
private static bool TryParseNumber(string s, out double value, out int inputBase)
{
value = 0;
inputBase = 10;
s = s.Replace(",", "").Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ||
s.StartsWith("&H", StringComparison.OrdinalIgnoreCase))
{
inputBase = 16;
var hex = s[2..];
if (long.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var lv))
{ value = lv; return true; }
return false;
}
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase))
{
inputBase = 2;
try { value = System.Convert.ToInt64(s[2..], 2); return true; }
catch { return false; }
}
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase))
{
inputBase = 8;
try { value = System.Convert.ToInt64(s[2..], 8); return true; }
catch { return false; }
}
// 과학 표기법 포함 일반 double
if (double.TryParse(s, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out value))
return true;
return false;
}
// ── 한글 단위 ─────────────────────────────────────────────────────────────
private static string ToKoreanUnit(double value)
{
var abs = Math.Abs(value);
var sign = value < 0 ? "-" : "";
if (abs >= 1_0000_0000_0000) return $"{sign}{abs / 1_0000_0000_0000:N2}조";
if (abs >= 1_0000_0000) return $"{sign}{abs / 1_0000_0000:N2}억";
if (abs >= 1_0000) return $"{sign}{abs / 1_0000:N2}만";
return $"{sign}{abs:N0}";
}
private static readonly string[] KoOnes = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
private static readonly string[] KoTens = ["", "십", "이십", "삼십", "사십", "오십", "육십", "칠십", "팔십", "구십"];
private static readonly string[] KoHundreds= ["", "백", "이백", "삼백", "사백", "오백", "육백", "칠백", "팔백", "구백"];
private static string ToKorean(double value)
{
if (value == 0) return "영";
var abs = (long)Math.Abs(value);
var sign = value < 0 ? "마이너스 " : "";
return sign + KoNumber(abs);
}
private static string KoNumber(long n)
{
if (n == 0) return "";
var sb = new StringBuilder();
var jo = n / 1_0000_0000_0000L;
var eok = n % 1_0000_0000_0000L / 1_0000_0000L;
var man = n % 1_0000_0000L / 1_0000L;
var rest = n % 1_0000L;
if (jo > 0) { sb.Append(KoUnder1만(jo)); sb.Append("조 "); }
if (eok > 0) { sb.Append(KoUnder1만(eok)); sb.Append("억 "); }
if (man > 0) { sb.Append(KoUnder1만(man)); sb.Append("만 "); }
if (rest> 0) { sb.Append(KoUnder1만(rest)); }
return sb.ToString().Trim();
}
private static string KoUnder1만(long n)
{
var sb = new StringBuilder();
var thou = (int)(n / 1000);
var hund = (int)(n % 1000 / 100);
var ten = (int)(n % 100 / 10);
var one = (int)(n % 10);
if (thou > 0) { sb.Append(thou == 1 ? "천" : KoOnes[thou] + "천"); }
if (hund > 0) { sb.Append(hund == 1 ? "백" : KoOnes[hund] + "백"); }
if (ten > 0) { sb.Append(ten == 1 ? "십" : KoOnes[ten] + "십"); }
if (one > 0) { sb.Append(KoOnes[one]); }
return sb.ToString();
}
private static string ToKoreanMoney(double value)
{
var ko = ToKorean(value);
return string.IsNullOrEmpty(ko) ? "영 원" : $"{ko} 원";
}
// ── 영어 읽기 ─────────────────────────────────────────────────────────────
private static readonly string[] EnOnes =
["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen"];
private static readonly string[] EnTens =
["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
private static string ToEnglish(double value)
{
if (value == 0) return "zero";
var n = (long)Math.Abs(value);
var sign = value < 0 ? "negative " : "";
return sign + EnNumber(n);
}
private static string EnNumber(long n)
{
if (n == 0) return "";
if (n < 20) return EnOnes[n];
if (n < 100) return EnTens[n / 10] + (n % 10 > 0 ? "-" + EnOnes[n % 10] : "");
if (n < 1000)
{
var rest = n % 100 > 0 ? " and " + EnNumber(n % 100) : "";
return EnOnes[n / 100] + " hundred" + rest;
}
if (n < 1_000_000) return EnNumber(n / 1000) + " thousand" + (n % 1000 > 0 ? " " + EnNumber(n % 1000) : "");
if (n < 1_000_000_000) return EnNumber(n / 1_000_000) + " million" + (n % 1_000_000 > 0 ? " " + EnNumber(n % 1_000_000) : "");
return EnNumber(n / 1_000_000_000) + " billion" + (n % 1_000_000_000 > 0 ? " " + EnNumber(n % 1_000_000_000) : "");
}
// ── 로마 숫자 ─────────────────────────────────────────────────────────────
private static string ToRoman(int n)
{
var vals = new[] { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
var syms = new[] { "M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I" };
var sb = new StringBuilder();
for (var i = 0; i < vals.Length; i++)
while (n >= vals[i]) { sb.Append(syms[i]); n -= vals[i]; }
return sb.ToString();
}
}

View File

@@ -0,0 +1,264 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using WinBitmapDecoder = Windows.Graphics.Imaging.BitmapDecoder;
using WinBitmapPixelFmt = Windows.Graphics.Imaging.BitmapPixelFormat;
using WinSoftwareBitmap = Windows.Graphics.Imaging.SoftwareBitmap;
using WinOcrEngine = Windows.Media.Ocr.OcrEngine;
using WinStorageFile = Windows.Storage.StorageFile;
using WinFileAccessMode = Windows.Storage.FileAccessMode;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-2: 화면 텍스트 OCR 추출 핸들러.
/// 예: ocr → 옵션 목록 (영역 선택 / 클립보드 이미지)
/// ocr region → 드래그 영역 선택 후 텍스트 추출
/// ocr clip → 클립보드 이미지에서 텍스트 추출
/// 결과 텍스트는 클립보드에 복사되고 런처 입력창에 채워집니다.
/// </summary>
public class OcrHandler : IActionHandler
{
public string? Prefix => "ocr";
public PluginMetadata Metadata => new(
"OcrExtractor",
"화면 텍스트 추출 (OCR)",
"1.0",
"AX");
private const string DataRegion = "__ocr_region__";
private const string DataClipboard = "__ocr_clipboard__";
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>
{
new LauncherItem(
"화면 영역 텍스트 추출",
"드래그로 영역을 선택하면 텍스트를 자동으로 인식합니다 · F4 단축키 지원",
null, DataRegion,
Symbol: "\uE8D2"),
new LauncherItem(
"클립보드 이미지 텍스트 추출",
"클립보드에 복사된 이미지에서 텍스트를 인식합니다",
null, DataClipboard,
Symbol: "\uE77F")
};
// 쿼리 필터링
if (!string.IsNullOrEmpty(q))
{
items = items.Where(i =>
i.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
i.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
}
// OCR 미지원 안내
if (WinOcrEngine.TryCreateFromUserProfileLanguages() == null)
{
items.Clear();
items.Add(new LauncherItem(
"OCR 기능을 사용할 수 없습니다",
"Windows 설정 → 언어에서 OCR 지원 언어 팩을 설치하세요",
null, null,
Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data as string)
{
case DataRegion:
await ExecuteRegionOcrAsync(ct);
break;
case DataClipboard:
await ExecuteClipboardOcrAsync(ct);
break;
}
}
// ─── 영역 선택 OCR ───────────────────────────────────────────────────────
private static async Task ExecuteRegionOcrAsync(CancellationToken ct)
{
// 런처가 완전히 사라질 때까지 대기
await Task.Delay(180, ct);
System.Drawing.Rectangle? selected = null;
Bitmap? fullBmp = null;
// UI 스레드에서 오버레이 창 표시
await Application.Current.Dispatcher.InvokeAsync(() =>
{
var bounds = GetAllScreenBounds();
fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(fullBmp);
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
var overlay = new Views.RegionSelectWindow(fullBmp, bounds);
overlay.ShowDialog();
selected = overlay.SelectedRect;
});
if (selected == null || selected.Value.Width < 8 || selected.Value.Height < 8)
{
fullBmp?.Dispose();
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
return;
}
// 선택 영역 크롭
var r = selected.Value;
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
using (var cg = Graphics.FromImage(crop))
cg.DrawImage(fullBmp!, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
fullBmp?.Dispose();
// OCR 실행
var text = await RunOcrOnBitmapAsync(crop);
// 결과 처리
HandleOcrResult(text, $"{r.Width}×{r.Height} 영역");
}
// ─── 클립보드 이미지 OCR ─────────────────────────────────────────────────
private static async Task ExecuteClipboardOcrAsync(CancellationToken ct)
{
Bitmap? clipBmp = null;
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (Clipboard.ContainsImage())
{
var src = Clipboard.GetImage();
if (src != null)
{
// BitmapSource → System.Drawing.Bitmap 변환
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src));
using var ms = new MemoryStream();
encoder.Save(ms);
ms.Position = 0;
clipBmp = new Bitmap(ms);
}
}
});
if (clipBmp == null)
{
NotificationService.Notify("AX Copilot — OCR", "클립보드에 이미지가 없습니다.");
return;
}
using (clipBmp)
{
var text = await RunOcrOnBitmapAsync(clipBmp);
HandleOcrResult(text, "클립보드 이미지");
}
}
// ─── 공통: Bitmap → OCR ─────────────────────────────────────────────────
private static async Task<string?> RunOcrOnBitmapAsync(Bitmap bmp)
{
var engine = WinOcrEngine.TryCreateFromUserProfileLanguages();
if (engine == null) return null;
// Bitmap을 임시 PNG로 저장
var tmpPath = Path.Combine(Path.GetTempPath(), $"axocr_{Guid.NewGuid():N}.png");
try
{
bmp.Save(tmpPath, ImageFormat.Png);
var storageFile = await WinStorageFile.GetFileFromPathAsync(tmpPath);
using var stream = await storageFile.OpenAsync(WinFileAccessMode.Read);
var decoder = await WinBitmapDecoder.CreateAsync(stream);
WinSoftwareBitmap? origBitmap = null;
WinSoftwareBitmap? ocrBitmap = null;
try
{
origBitmap = await decoder.GetSoftwareBitmapAsync();
ocrBitmap = origBitmap.BitmapPixelFormat == WinBitmapPixelFmt.Bgra8
? origBitmap
: WinSoftwareBitmap.Convert(origBitmap, WinBitmapPixelFmt.Bgra8);
var result = await engine.RecognizeAsync(ocrBitmap);
var text = result.Text?.Trim();
if (text?.Length > 5_000) text = text[..5_000];
return string.IsNullOrWhiteSpace(text) ? null : text;
}
finally
{
if (!ReferenceEquals(origBitmap, ocrBitmap)) origBitmap?.Dispose();
ocrBitmap?.Dispose();
}
}
catch (Exception ex)
{
LogService.Warn($"OCR 실행 오류: {ex.Message}");
return null;
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
}
}
// ─── 공통: 결과 처리 ────────────────────────────────────────────────────
private static void HandleOcrResult(string? text, string source)
{
if (string.IsNullOrWhiteSpace(text))
{
NotificationService.Notify("OCR 완료", $"{source}에서 텍스트를 인식하지 못했습니다.");
return;
}
// 클립보드에 복사
Application.Current?.Dispatcher.Invoke(() =>
{
Clipboard.SetText(text);
});
// 런처를 다시 열고 결과 텍스트를 입력창에 채움
Application.Current?.Dispatcher.BeginInvoke(() =>
{
var launcher = Application.Current?.Windows
.OfType<Views.LauncherWindow>().FirstOrDefault();
if (launcher != null)
{
launcher.SetInputText(text.Length > 200 ? text[..200] : text);
launcher.Show();
}
}, System.Windows.Threading.DispatcherPriority.Background);
// 완료 알림
var preview = text.Length > 60 ? text[..57].Replace('\n', ' ') + "…" : text.Replace('\n', ' ');
NotificationService.Notify("OCR 완료", $"클립보드 복사됨: {preview}");
LogService.Info($"OCR 성공 ({source}, {text.Length}자)");
}
// ─── 헬퍼 ───────────────────────────────────────────────────────────────
private static System.Drawing.Rectangle GetAllScreenBounds()
{
var bounds = System.Drawing.Rectangle.Empty;
foreach (System.Windows.Forms.Screen screen in System.Windows.Forms.Screen.AllScreens)
bounds = System.Drawing.Rectangle.Union(bounds, screen.Bounds);
return bounds;
}
}

View File

@@ -0,0 +1,249 @@
using System.Security.Cryptography;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L9-1: 비밀번호 생성기 핸들러. "pwd" 프리픽스로 사용합니다.
///
/// 예: pwd → 기본 16자 비밀번호 5개 생성
/// pwd 24 → 24자 비밀번호 5개 생성
/// pwd 32 strong → 32자 강력 옵션 (대소문자+숫자+특수)
/// pwd 16 alpha → 알파벳+숫자만 (특수문자 제외)
/// pwd 20 pin → 숫자만 (PIN 코드)
/// pwd passphrase → 단어 조합 기억하기 쉬운 패스프레이즈
/// Enter → 클립보드에 복사.
/// </summary>
public class PasswordGenHandler : IActionHandler
{
public string? Prefix => "pwd";
public PluginMetadata Metadata => new(
"PasswordGen",
"비밀번호 생성기 — 길이 · 복잡도 · 패스프레이즈",
"1.0",
"AX");
private const string LowerChars = "abcdefghijklmnopqrstuvwxyz";
private const string UpperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private const string DigitChars = "0123456789";
private const string SpecialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?";
// 기억하기 쉬운 단어 목록 (간결한 영단어)
private static readonly string[] WordList =
[
"apple","bridge","cloud","dawn","eagle","flame","grape","harbor",
"ivory","jungle","kite","lemon","maple","night","ocean","pearl",
"quartz","river","storm","tiger","ultra","violet","water","xenon",
"yellow","zenith","amber","blaze","cedar","delta","ember","frost",
"glass","honey","iron","jade","knot","lunar","mango","nova",
"orbit","prism","quest","range","solar","track","umbra","valor",
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 패스프레이즈 모드
if (q.StartsWith("passphrase") || q.StartsWith("phrase"))
{
var wordCount = 4;
var parts2 = q.Split(' ');
if (parts2.Length >= 2 && int.TryParse(parts2[1], out var wc))
wordCount = Math.Clamp(wc, 2, 8);
items.Add(new LauncherItem(
"패스프레이즈 생성",
$"{wordCount}단어 조합 · 기억하기 쉬운 형식",
null, null, Symbol: "\uE8D4"));
for (int i = 0; i < 5; i++)
{
var phrase = GeneratePassphrase(wordCount);
var strength = EstimateEntropy(phrase);
items.Add(new LauncherItem(
phrase,
$"엔트로피: ~{strength}bit",
null,
("copy", phrase),
Symbol: "\uE8D4"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 파라미터 파싱: [길이] [모드]
int length = 16;
string mode = "strong";
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 1 && int.TryParse(parts[0], out var l))
{
length = Math.Clamp(l, 4, 128);
if (parts.Length >= 2) mode = parts[1];
}
else if (parts.Length >= 1 && parts[0] is "strong" or "alpha" or "pin" or "simple")
{
mode = parts[0];
}
// 문자 집합 결정
var charset = BuildCharset(mode);
// 강도 표시
var modeLabel = mode switch
{
"alpha" => "알파뉴메릭 (대소문자+숫자)",
"pin" => "숫자 PIN",
"simple" => "간단 (소문자+숫자)",
_ => "강력 (대소문자+숫자+특수)",
};
items.Add(new LauncherItem(
$"비밀번호 생성 {length}자",
modeLabel,
null, null, Symbol: "\uE8D4"));
// 5개 후보 생성
for (int i = 0; i < 5; i++)
{
var pw = GeneratePassword(charset, length, mode);
var strength = GetStrengthLabel(pw);
items.Add(new LauncherItem(
pw,
strength,
null,
("copy", pw),
Symbol: "\uE8D4"));
}
// 옵션 안내
items.Add(new LauncherItem(
"pwd <길이> alpha",
"알파뉴메릭 (특수문자 제외)",
null, null, Symbol: "\uE946"));
items.Add(new LauncherItem(
"pwd <길이> pin",
"숫자만 (PIN 코드)",
null, null, Symbol: "\uE946"));
items.Add(new LauncherItem(
"pwd passphrase",
"단어 조합 패스프레이즈",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string pw))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(pw));
NotificationService.Notify("비밀번호", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string BuildCharset(string mode) => mode switch
{
"pin" => DigitChars,
"alpha" => LowerChars + UpperChars + DigitChars,
"simple" => LowerChars + DigitChars,
_ => LowerChars + UpperChars + DigitChars + SpecialChars,
};
private static string GeneratePassword(string charset, int length, string mode)
{
// strong 모드: 각 카테고리 최소 1개 보장
if (mode == "strong" && length >= 4)
{
var mandatory = new[]
{
RandomChar(LowerChars),
RandomChar(UpperChars),
RandomChar(DigitChars),
RandomChar(SpecialChars),
};
var remaining = length - mandatory.Length;
var bulk = Enumerable.Range(0, remaining)
.Select(_ => RandomChar(charset))
.ToList();
var all = mandatory.Concat(bulk).ToArray();
Shuffle(all);
return new string(all);
}
// alpha/pin/simple
return new string(Enumerable.Range(0, length)
.Select(_ => RandomChar(charset))
.ToArray());
}
private static string GeneratePassphrase(int wordCount)
{
var words = Enumerable.Range(0, wordCount)
.Select(_ => WordList[RandomInt(WordList.Length)]);
var num = RandomInt(9000) + 1000;
var sep = new[] { "-", "_", ".", "!" }[RandomInt(4)];
return string.Join(sep, words) + sep + num;
}
private static char RandomChar(string charset) =>
charset[RandomInt(charset.Length)];
private static int RandomInt(int max) =>
(int)(RandomNumberGenerator.GetInt32(int.MaxValue) % max);
private static void Shuffle<T>(T[] arr)
{
for (int i = arr.Length - 1; i > 0; i--)
{
var j = RandomInt(i + 1);
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
private static string GetStrengthLabel(string pw)
{
var hasLower = pw.Any(char.IsLower);
var hasUpper = pw.Any(char.IsUpper);
var hasDigit = pw.Any(char.IsDigit);
var hasSpecial = pw.Any(c => SpecialChars.Contains(c));
var types = new[] { hasLower, hasUpper, hasDigit, hasSpecial }.Count(b => b);
var strength = (pw.Length, types) switch
{
( >= 24, >= 4) => "매우 강함 🔐",
( >= 16, >= 3) => "강함 🔒",
( >= 12, >= 2) => "보통 🔑",
_ => "약함 ⚠",
};
return $"{strength} · {pw.Length}자";
}
private static int EstimateEntropy(string pw)
{
// 간략 엔트로피 추정: log2(charset^length)
var hasLower = pw.Any(char.IsLower);
var hasUpper = pw.Any(char.IsUpper);
var hasDigit = pw.Any(char.IsDigit);
var hasSpecial = pw.Any(c => !char.IsLetterOrDigit(c));
var pool = (hasLower ? 26 : 0) + (hasUpper ? 26 : 0)
+ (hasDigit ? 10 : 0) + (hasSpecial ? 32 : 0);
if (pool == 0) pool = 36;
return (int)(pw.Length * Math.Log2(pool));
}
}

View File

@@ -0,0 +1,156 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// paste prefix 핸들러: 클립보드 히스토리를 순서대로 붙여넣습니다.
/// 예: paste / paste 3 1 5 / paste all
/// </summary>
public class PasteHandler : IActionHandler
{
private readonly ClipboardHistoryService _history;
public string? Prefix => "paste";
public PluginMetadata Metadata => new(
"순차 붙여넣기",
"클립보드 히스토리를 순서대로 붙여넣기 (Paste Sequentially)",
"1.0",
"AX");
public PasteHandler(ClipboardHistoryService historyService)
{
_history = historyService;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var history = _history.History.Where(e => e.IsText && !string.IsNullOrEmpty(e.Text)).ToList();
if (history.Count == 0)
{
items.Add(new LauncherItem(
"클립보드 히스토리가 비어 있습니다",
"텍스트를 복사하면 사용할 수 있습니다",
null, null, Symbol: Symbols.ClipPaste));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (!string.IsNullOrWhiteSpace(q) && q != "all")
{
var nums = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var indices = new List<int>();
foreach (var n in nums)
{
if (int.TryParse(n, out int idx) && idx >= 1 && idx <= history.Count)
indices.Add(idx);
}
if (indices.Count > 0)
{
var preview = string.Join(" · ", indices.Select(i => $"#{i}"));
var texts = indices.Select(i => history[i - 1].Text ?? "").ToList();
var totalLen = texts.Sum(t => t.Length);
items.Add(new LauncherItem(
$"순차 붙여넣기: {preview}",
$"{indices.Count}개 항목 · 총 {totalLen}자 · Enter로 순서대로 붙여넣기",
null, ("seq", texts), Symbol: Symbols.ClipPaste));
for (int i = 0; i < indices.Count; i++)
{
var entry = history[indices[i] - 1];
items.Add(new LauncherItem(
$" {i + 1}. #{indices[i]}: {Truncate(entry.Preview, 60)}",
entry.RelativeTime,
null, null, Symbol: Symbols.History));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
if (q.Equals("all", StringComparison.OrdinalIgnoreCase))
{
var texts = history.Take(20).Select(e => e.Text ?? "").ToList();
items.Add(new LauncherItem(
$"전체 순차 붙여넣기 ({texts.Count}개)",
$"Enter로 최근 {texts.Count}개 항목을 순서대로 붙여넣기",
null, ("seq", texts), Symbol: Symbols.ClipPaste));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
"순차 붙여넣기 번호를 입력하세요",
"예: paste 3 1 5 · paste all",
null, null, Symbol: Symbols.ClipPaste));
for (int i = 0; i < Math.Min(history.Count, 15); i++)
{
var entry = history[i];
var pinMark = entry.IsPinned ? "\uD83D\uDCCC " : "";
items.Add(new LauncherItem(
$" #{i + 1} {pinMark}{Truncate(entry.Preview, 50)}",
$"{entry.RelativeTime} · {entry.CopiedAt:MM/dd HH:mm}",
null, ("single", entry.Text ?? ""), Symbol: Symbols.History));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("single", string singleText))
{
await PasteTexts([singleText], ct);
}
else if (item.Data is ("seq", List<string> texts))
{
await PasteTexts(texts, ct);
}
}
private async Task PasteTexts(List<string> texts, CancellationToken ct)
{
if (texts.Count == 0) return;
try
{
var prevWindow = WindowTracker.PreviousWindow;
if (prevWindow == IntPtr.Zero) return;
_history.SuppressNextCapture();
var restored = await ForegroundPasteHelper.RestoreWindowAsync(prevWindow, ct, initialDelayMs: 260);
if (!restored)
return;
foreach (var text in texts)
{
if (ct.IsCancellationRequested) break;
_history.SuppressNextCapture();
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
await Task.Delay(50, ct);
await ForegroundPasteHelper.PasteClipboardAsync(prevWindow, ct, initialDelayMs: 0);
await Task.Delay(200, ct);
}
NotificationService.Notify("paste", $"{texts.Count}개 항목 붙여넣기 완료");
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
NotificationService.Notify("paste", $"붙여넣기 실패: {ex.Message}");
}
}
private static string Truncate(string s, int max)
=> s.Length <= max ? s : s[..max] + "…";
}

View File

@@ -0,0 +1,219 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L13-2: PATH 환경변수 뷰어·검색 핸들러. "path" 프리픽스로 사용합니다.
///
/// 예: path → PATH 전체 목록 (존재/미존재 여부 표시)
/// path search git → "git" 포함 경로 필터
/// path which git.exe → 실행 파일 위치 검색 (which/where 대응)
/// path which python → 확장자 없이도 검색 (.exe/.cmd/.bat 시도)
/// path user → 사용자 PATH만 표시
/// path system → 시스템 PATH만 표시
/// Enter → 경로를 클립보드에 복사.
/// </summary>
public class PathHandler : IActionHandler
{
public string? Prefix => "path";
public PluginMetadata Metadata => new(
"Path",
"PATH 환경변수 뷰어 — 경로 목록 · 검색 · which",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
var paths = GetAllPaths();
var exist = paths.Count(p => p.Exists);
var total = paths.Count;
items.Add(new LauncherItem(
$"PATH {total}개 경로 (존재 {exist}개)",
"path which <파일> 로 실행 파일 위치 검색",
null, null, Symbol: "\uE838"));
items.Add(new LauncherItem("path user", "사용자 PATH만", null, null, Symbol: "\uE838"));
items.Add(new LauncherItem("path system", "시스템 PATH만", null, null, Symbol: "\uE838"));
items.Add(new LauncherItem("path which git", "git 위치 검색", null, null, Symbol: "\uE838"));
foreach (var p in paths.Take(15))
items.Add(MakePathItem(p));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "which":
case "where":
case "find":
{
var target = parts.Length > 1 ? parts[1].Trim() : "";
if (string.IsNullOrWhiteSpace(target))
{
items.Add(new LauncherItem("파일명 입력", "예: path which git.exe", null, null, Symbol: "\uE783"));
break;
}
items.AddRange(FindExecutable(target));
break;
}
case "user":
{
var paths = GetPaths(EnvironmentVariableTarget.User);
items.Add(new LauncherItem($"사용자 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
foreach (var p in paths)
items.Add(MakePathItem(p));
break;
}
case "system":
{
var paths = GetPaths(EnvironmentVariableTarget.Machine);
items.Add(new LauncherItem($"시스템 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
foreach (var p in paths)
items.Add(MakePathItem(p));
break;
}
case "search":
{
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(keyword))
{
items.Add(new LauncherItem("검색어 입력", "예: path search python", null, null, Symbol: "\uE783"));
break;
}
var allPaths = GetAllPaths();
var filtered = allPaths.Where(p =>
p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 포함 경로 없음", null, null, Symbol: "\uE946"));
else
foreach (var p in filtered)
items.Add(MakePathItem(p));
break;
}
default:
{
// 기본: 검색어로 처리
var keyword = q.ToLowerInvariant();
var allPaths = GetAllPaths();
var filtered = allPaths.Where(p =>
p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count > 0)
foreach (var p in filtered.Take(15))
items.Add(MakePathItem(p));
else
// which 로 재시도
items.AddRange(FindExecutable(q));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Path", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── PATH 수집 ─────────────────────────────────────────────────────────────
private record PathEntry(string Directory, bool Exists, EnvironmentVariableTarget Scope);
private static List<PathEntry> GetAllPaths()
{
var result = new List<PathEntry>();
result.AddRange(GetPaths(EnvironmentVariableTarget.Process));
return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList();
}
private static List<PathEntry> GetPaths(EnvironmentVariableTarget target)
{
var raw = Environment.GetEnvironmentVariable("PATH", target) ?? "";
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => new PathEntry(p.Trim(), Directory.Exists(p.Trim()), target))
.ToList();
}
private static IEnumerable<LauncherItem> FindExecutable(string name)
{
var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" };
var paths = GetAllPaths();
var found = new List<string>();
foreach (var pathEntry in paths.Where(p => p.Exists))
{
foreach (var ext in extensions)
{
var candidate = Path.Combine(pathEntry.Directory, name + ext);
if (File.Exists(candidate))
found.Add(candidate);
}
}
if (found.Count == 0)
{
yield return new LauncherItem($"'{name}' 찾을 수 없음",
"PATH에서 해당 실행 파일이 없습니다", null, null, Symbol: "\uE946");
yield break;
}
yield return new LauncherItem(
$"'{name}' {found.Count}개 발견",
"전체 복사: Enter",
null, ("copy", string.Join("\n", found)), Symbol: "\uE838");
foreach (var f in found)
{
var dir = Path.GetDirectoryName(f) ?? "";
var fileName = Path.GetFileName(f);
yield return new LauncherItem(
fileName,
dir,
null, ("copy", f), Symbol: "\uE838");
}
}
private static LauncherItem MakePathItem(PathEntry p)
{
var icon = p.Exists ? "\uE838" : "\uE783";
var label = p.Exists ? "" : " (없는 경로)";
return new LauncherItem(
p.Directory + label,
p.Exists ? "존재함" : "경로 없음",
null,
("copy", p.Directory),
Symbol: icon);
}
}

View File

@@ -0,0 +1,277 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-4: Unix 파일 권한 계산기 핸들러. "perm" 프리픽스로 사용합니다.
///
/// 예: perm → 주요 권한 목록
/// perm 755 → rwxr-xr-x 상세 설명
/// perm 644 → rw-r--r-- 상세 설명
/// perm rwxr-xr-x → 기호 → 숫자 변환
/// perm rw-r--r-- → 기호 → 숫자 변환
/// perm +x 644 → 실행 비트 추가
/// perm -x 755 → 실행 비트 제거
/// perm +w 444 → 쓰기 비트 추가
/// perm umask 022 → umask 적용 결과
/// perm common → 자주 쓰는 권한 목록
/// Enter → 값 복사.
/// </summary>
public class PermHandler : IActionHandler
{
public string? Prefix => "perm";
public PluginMetadata Metadata => new(
"Perm",
"Unix 파일 권한 계산기 — chmod 숫자↔기호·umask·권한 설명",
"1.0",
"AX");
private record CommonPerm(string Octal, string Symbol, string Description, string UseCase);
private static readonly CommonPerm[] Common =
[
new("777", "rwxrwxrwx", "모든 사용자 완전 권한", "임시 스크립트 (보안 주의)"),
new("755", "rwxr-xr-x", "소유자 완전·그룹/기타 읽기+실행", "실행 파일, 디렉토리"),
new("750", "rwxr-x---", "소유자 완전·그룹 읽기+실행·기타 없음", "그룹 공유 스크립트"),
new("700", "rwx------", "소유자만 완전 권한", "개인 스크립트"),
new("644", "rw-r--r--", "소유자 읽기+쓰기·그룹/기타 읽기만", "일반 파일"),
new("640", "rw-r-----", "소유자 읽기+쓰기·그룹 읽기·기타 없음", "설정 파일"),
new("600", "rw-------", "소유자만 읽기+쓰기", "개인 설정·SSH 키"),
new("444", "r--r--r--", "모든 사용자 읽기만", "공유 읽기 전용"),
new("400", "r--------", "소유자만 읽기", "SSL 인증서·개인 키"),
new("666", "rw-rw-rw-", "모든 사용자 읽기+쓰기 (실행 없음)", "임시 파일"),
new("664", "rw-rw-r--", "소유자+그룹 읽기+쓰기·기타 읽기", "그룹 협업 파일"),
new("660", "rw-rw----", "소유자+그룹 읽기+쓰기·기타 없음", "그룹 협업 파일"),
new("775", "rwxrwxr-x", "소유자+그룹 완전·기타 읽기+실행", "그룹 협업 디렉토리"),
new("770", "rwxrwx---", "소유자+그룹 완전·기타 없음", "그룹 전용 디렉토리"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("Unix 파일 권한 계산기",
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022 / perm common",
null, null, Symbol: "\uE8A5"));
foreach (var p in Common.Take(6))
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
$"{p.Description} ({p.UseCase})",
null, ("copy", p.Octal), Symbol: "\uE8A5"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// common
if (sub is "common" or "list" or "목록")
{
items.Add(new LauncherItem("자주 쓰는 파일 권한 목록", "", null, null, Symbol: "\uE8A5"));
foreach (var p in Common)
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
$"{p.Description} · {p.UseCase}",
null, ("copy", p.Octal), Symbol: "\uE8A5"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// umask
if (sub == "umask" && parts.Length >= 2)
{
if (TryParseOctal(parts[1], out var umaskVal))
items.AddRange(BuildUmask(umaskVal));
else
items.Add(ErrorItem("umask 값은 3자리 8진수입니다 (예: 022)"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// +x / -x / +w / -w / +r / -r (비트 수정)
if (sub.Length == 2 && sub[0] is '+' or '-' && sub[1] is 'r' or 'w' or 'x')
{
if (parts.Length >= 2 && TryParseOctal(parts[1], out var baseVal))
items.AddRange(BuildBitModify(sub[0] == '+', sub[1], baseVal));
else
items.Add(ErrorItem($"예: perm {sub} 644"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 기호 표기 (rwxr-xr-x)
if (sub.Length == 9 && sub.All(c => c is 'r' or 'w' or 'x' or '-'))
{
items.AddRange(BuildFromSymbol(sub));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 8진수 표기 (755, 644...)
if (TryParseOctal(sub, out var octal))
{
items.AddRange(BuildFromOctal(octal));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"인식할 수 없는 입력: '{sub}'",
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Perm", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildFromOctal(int octal)
{
var sym = OctalToSymbol(octal);
var desc = DescribeOctal(octal);
var preset = Common.FirstOrDefault(p => p.Octal == octal.ToString("D3"));
yield return new LauncherItem($"{octal:D3} → {sym}",
preset?.Description ?? desc,
null, ("copy", octal.ToString("D3")), Symbol: "\uE8A5");
yield return CopyItem("8진수", octal.ToString("D3"));
yield return CopyItem("기호 표기", sym);
yield return CopyItem("chmod 명령", $"chmod {octal:D3} <파일>");
var parts = OctalToParts(octal);
yield return new LauncherItem("소유자 (Owner)",
$"{OctetToSymbol(parts[0])} ({BitsDesc(parts[0])})",
null, null, Symbol: "\uE8A5");
yield return new LauncherItem("그룹 (Group)",
$"{OctetToSymbol(parts[1])} ({BitsDesc(parts[1])})",
null, null, Symbol: "\uE8A5");
yield return new LauncherItem("기타 (Others)",
$"{OctetToSymbol(parts[2])} ({BitsDesc(parts[2])})",
null, null, Symbol: "\uE8A5");
if (preset != null)
yield return new LauncherItem("용도", preset.UseCase, null, null, Symbol: "\uE8A5");
// 관련 권한 제안
yield return new LauncherItem("── 관련 권한 ──", "", null, null, Symbol: "\uE8A5");
var related = Common.Where(p => Math.Abs(int.Parse(p.Octal) - octal) <= 11)
.Take(4).ToList();
foreach (var r in related)
yield return new LauncherItem($"{r.Octal} {r.Symbol}", r.Description,
null, ("copy", r.Octal), Symbol: "\uE8A5");
}
private static IEnumerable<LauncherItem> BuildFromSymbol(string sym)
{
var octal = SymbolToOctal(sym);
return BuildFromOctal(octal);
}
private static IEnumerable<LauncherItem> BuildUmask(int umask)
{
var fileDefault = 0666;
var dirDefault = 0777;
var fileResult = fileDefault & ~umask;
var dirResult = dirDefault & ~umask;
yield return new LauncherItem($"umask {umask:D3} → 파일: {fileResult:D3} 디렉토리: {dirResult:D3}",
"umask 적용 결과", null, null, Symbol: "\uE8A5");
yield return CopyItem("umask 값", umask.ToString("D3"));
yield return CopyItem("기본 파일 권한", fileResult.ToString("D3"));
yield return CopyItem("기본 디렉토리 권한", dirResult.ToString("D3"));
yield return CopyItem("파일 기호", OctalToSymbol(fileResult));
yield return CopyItem("디렉토리 기호", OctalToSymbol(dirResult));
yield return new LauncherItem("설명",
$"umask {umask:D3}: 파일={OctalToSymbol(fileResult)}, 디렉토리={OctalToSymbol(dirResult)}",
null, null, Symbol: "\uE8A5");
}
private static IEnumerable<LauncherItem> BuildBitModify(bool add, char bit, int baseOctal)
{
var mask = bit switch
{
'r' => 0444,
'w' => 0222,
'x' => 0111,
_ => 0
};
var result = add ? (baseOctal | mask) : (baseOctal & ~mask);
result &= 0777;
var label = add ? $"+{bit}" : $"-{bit}";
yield return new LauncherItem($"{baseOctal:D3} {label} → {result:D3} ({OctalToSymbol(result)})",
"Enter 복사", null, ("copy", result.ToString("D3")), Symbol: "\uE8A5");
yield return CopyItem("변경 전", baseOctal.ToString("D3"));
yield return CopyItem("변경 후", result.ToString("D3"));
yield return CopyItem("기호", OctalToSymbol(result));
yield return CopyItem("chmod", $"chmod {label} <파일> (또는 chmod {result:D3} <파일>)");
}
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
private static bool TryParseOctal(string s, out int val)
{
val = 0;
s = s.TrimStart('0');
if (string.IsNullOrEmpty(s)) { val = 0; return true; }
if (s.Length > 4) return false;
try { val = Convert.ToInt32(s, 8); return val <= 0777; }
catch { return false; }
}
private static int[] OctalToParts(int octal) =>
[(octal >> 6) & 7, (octal >> 3) & 7, octal & 7];
private static string OctetToSymbol(int v) =>
$"{((v & 4) != 0 ? 'r' : '-')}{((v & 2) != 0 ? 'w' : '-')}{((v & 1) != 0 ? 'x' : '-')}";
private static string OctalToSymbol(int octal)
{
var p = OctalToParts(octal);
return OctetToSymbol(p[0]) + OctetToSymbol(p[1]) + OctetToSymbol(p[2]);
}
private static int SymbolToOctal(string sym)
{
int TriBit(int off) =>
((sym[off] == 'r' ? 4 : 0) |
(sym[off + 1] == 'w' ? 2 : 0) |
(sym[off + 2] == 'x' ? 1 : 0));
return (TriBit(0) << 6) | (TriBit(3) << 3) | TriBit(6);
}
private static string BitsDesc(int v)
{
var parts = new List<string>();
if ((v & 4) != 0) parts.Add("읽기");
if ((v & 2) != 0) parts.Add("쓰기");
if ((v & 1) != 0) parts.Add("실행");
return parts.Count == 0 ? "없음" : string.Join("·", parts);
}
private static string DescribeOctal(int octal)
{
var p = OctalToParts(octal);
return $"소유자:{BitsDesc(p[0])} 그룹:{BitsDesc(p[1])} 기타:{BitsDesc(p[2])}";
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8A5");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,215 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다.
///
/// 예: phrase → 카테고리 목록
/// phrase 인사 → 인사 문구 목록
/// phrase 보고 → 보고/발표 문구
/// phrase 요청 → 요청/협조 문구
/// phrase 마무리 → 마무리/감사 문구
/// phrase <검색어> → 전체 검색
/// Enter → 문구 클립보드 복사
/// </summary>
public class PhraseHandler : IActionHandler
{
public string? Prefix => "phrase";
public PluginMetadata Metadata => new(
"업무 문구",
"자주 쓰는 업무 문구 — 인사·보고·요청·마무리·승인·회의·사과",
"1.0",
"AX");
private sealed record PhraseEntry(string Text, string Category, string CategoryKey);
private static readonly List<PhraseEntry> _phrases = BuildPhrases();
private static readonly (string Key, string Display, string[] Aliases)[] _categories =
[
("greeting", "인사", ["인사", "greeting"]),
("report", "보고", ["보고", "보고서", "report"]),
("request", "요청", ["요청", "협조", "request"]),
("closing", "마무리", ["마무리", "감사", "closing"]),
("approval", "승인결재", ["승인", "결재", "approval"]),
("meeting", "회의", ["회의", "미팅", "meeting"]),
("apology", "사과지연", ["사과", "지연", "사죄", "apology"]),
];
private static List<PhraseEntry> BuildPhrases()
{
var list = new List<PhraseEntry>();
// 인사 (greeting)
void G(string t) => list.Add(new PhraseEntry(t, "인사", "greeting"));
G("안녕하세요. [이름]입니다.");
G("수고 많으십니다.");
G("오랜만에 연락드립니다.");
G("처음 뵙겠습니다. 잘 부탁드립니다.");
G("바쁘신 와중에 연락드려 죄송합니다.");
G("항상 감사드립니다.");
G("늦은 시간에 연락드려 죄송합니다.");
G("좋은 하루 보내세요.");
G("주말 잘 보내세요.");
G("휴가 잘 다녀오세요.");
// 보고 (report)
void R(string t) => list.Add(new PhraseEntry(t, "보고", "report"));
R("말씀하신 대로 처리하겠습니다.");
R("현재 검토 중에 있습니다.");
R("확인 후 말씀드리겠습니다.");
R("금일 중으로 처리하겠습니다.");
R("해당 건은 [날짜]까지 완료 예정입니다.");
R("진행 상황을 공유드립니다.");
R("결과 보고드립니다.");
R("관련하여 추가 검토가 필요합니다.");
R("이슈 사항이 발생하여 공유드립니다.");
R("문제없이 완료되었습니다.");
// 요청 (request)
void Req(string t) => list.Add(new PhraseEntry(t, "요청", "request"));
Req("검토 부탁드립니다.");
Req("회신 부탁드립니다.");
Req("확인 부탁드립니다.");
Req("협조 부탁드립니다.");
Req("아래 내용 확인 후 승인 부탁드립니다.");
Req("첨부 파일 확인 부탁드립니다.");
Req("가능한 빠른 시일 내 처리 부탁드립니다.");
Req("[날짜]까지 회신 주시면 감사하겠습니다.");
Req("필요한 사항 있으시면 알려주세요.");
Req("문의 사항은 언제든지 연락 주세요.");
// 마무리 (closing)
void C(string t) => list.Add(new PhraseEntry(t, "마무리", "closing"));
C("감사합니다.");
C("수고하셨습니다.");
C("도움 주셔서 감사합니다.");
C("빠른 처리 감사드립니다.");
C("앞으로도 잘 부탁드립니다.");
C("이상입니다.");
C("이상으로 보고를 마치겠습니다.");
C("궁금하신 점은 언제든 문의 주세요.");
C("좋은 결과 있기를 바랍니다.");
C("다시 한번 감사드립니다.");
// 승인결재 (approval)
void A(string t) => list.Add(new PhraseEntry(t, "승인결재", "approval"));
A("검토 후 승인 부탁드립니다.");
A("위 내용으로 진행해도 될까요?");
A("담당자 확인 후 피드백 주세요.");
A("위와 같이 결정되었음을 알려드립니다.");
A("아래와 같이 변경합니다.");
A("이의 없으시면 그대로 진행하겠습니다.");
A("검토 의견 부탁드립니다.");
// 회의 (meeting)
void M(string t) => list.Add(new PhraseEntry(t, "회의", "meeting"));
M("오늘 회의 내용을 공유드립니다.");
M("다음 회의는 [날짜]에 진행 예정입니다.");
M("회의 참석 부탁드립니다.");
M("회의 장소 및 일정을 안내드립니다.");
M("회의록 공유드립니다.");
M("다음 주 [요일] [시간]에 미팅 가능하신가요?");
M("일정 조율 부탁드립니다.");
M("화상 회의 링크 공유드립니다.");
M("회의 아젠다 공유드립니다.");
// 사과/지연 (apology)
void Ap(string t) => list.Add(new PhraseEntry(t, "사과지연", "apology"));
Ap("처리가 늦어져 죄송합니다.");
Ap("불편을 드려 죄송합니다.");
Ap("확인이 늦었습니다. 죄송합니다.");
Ap("오류가 발생하여 사과드립니다.");
Ap("빠른 처리가 어려운 점 양해 부탁드립니다.");
Ap("답변이 늦어 죄송합니다.");
return list;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 카테고리 목록 표시
items.Add(new LauncherItem(
"업무 문구 카테고리",
"phrase <카테고리> 또는 phrase <검색어>",
null, null, Symbol: "\uE8A0"));
foreach (var (key, display, _) in _categories)
{
var count = _phrases.Count(p => p.CategoryKey == key);
items.Add(new LauncherItem(
$"{display} ({count}개)",
$"phrase {display} → 문구 목록",
null, ("category", key), Symbol: "\uE8A0"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var kw = q.ToLowerInvariant();
// 카테고리 키워드 매칭
foreach (var (key, display, aliases) in _categories)
{
if (aliases.Any(a => a.Equals(q, StringComparison.OrdinalIgnoreCase) ||
a.Contains(q, StringComparison.OrdinalIgnoreCase)))
{
var catPhrases = _phrases.Where(p => p.CategoryKey == key).ToList();
items.Add(new LauncherItem(
$"{display} ({catPhrases.Count}개)",
"Enter → 클립보드 복사",
null, null, Symbol: "\uE8A0"));
foreach (var p in catPhrases)
items.Add(new LauncherItem(p.Text, p.Category,
null, ("copy", p.Text), Symbol: "\uE8A0"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 전체 검색
var results = _phrases
.Where(p => p.Text.Contains(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (results.Count > 0)
{
items.Add(new LauncherItem(
$"'{q}' 검색 결과 {results.Count}개",
"Enter → 클립보드 복사",
null, null, Symbol: "\uE8A0"));
foreach (var p in results)
items.Add(new LauncherItem(p.Text, p.Category,
null, ("copy", p.Text), Symbol: "\uE8A0"));
}
else
{
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
"카테고리: 인사 / 보고 / 요청 / 마무리 / 승인결재 / 회의 / 사과지연",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("문구", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,271 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L16-1: ping·tracert 빠른 실행 핸들러. "ping" 프리픽스로 사용합니다.
///
/// 예: ping 8.8.8.8 → ping 결과 (4회)
/// ping google.com → 도메인 ping
/// ping trace 8.8.8.8 → tracert 실행
/// ping local → 로컬 네트워크 어댑터 정보
/// ping scan 192.168.1.0 → 간단 네트워크 스캔 (1~254)
/// Enter → 결과 복사 또는 외부 터미널 실행.
/// 사내 모드: 외부 IP/도메인 ping 차단.
/// </summary>
public class PingHandler : IActionHandler
{
public string? Prefix => "ping";
public PluginMetadata Metadata => new(
"Ping",
"ping·tracert 빠른 실행 — 네트워크 연결 확인·경로 추적",
"1.0",
"AX");
private static readonly string[] QuickTargets =
["localhost", "8.8.8.8", "1.1.1.1", "google.com", "192.168.1.1"];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("ping / tracert 실행기",
"예: ping 8.8.8.8 / ping trace 192.168.1.1 / ping local / ping scan 192.168.1",
null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("── 빠른 대상 ──", "", null, null, Symbol: "\uE968"));
foreach (var t in QuickTargets)
items.Add(new LauncherItem($"ping {t}", t, null, ("ping", t), Symbol: "\uE968"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// local → 로컬 어댑터 정보
if (sub == "local" || sub == "lo")
{
items.AddRange(BuildLocalNetworkItems());
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// trace / tracert / traceroute
if (sub is "trace" or "tracert" or "traceroute")
{
var target = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(target))
{
items.Add(new LauncherItem("대상 주소 입력", "예: ping trace 8.8.8.8", null, null, Symbol: "\uE783"));
}
else
{
var blocked = CheckInternalMode(target);
if (blocked != null) { items.Add(blocked); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
items.Add(new LauncherItem($"tracert {target}", "Enter → 터미널에서 tracert 실행",
null, ("tracert", target), Symbol: "\uE968"));
items.Add(new LauncherItem("터미널 실행", $"tracert {target}",
null, ("tracert", target), Symbol: "\uE968"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// scan → 간단 스캔 (비동기 결과는 실행 시 터미널)
if (sub == "scan")
{
var network = parts.Length > 1 ? parts[1] : "192.168.1";
items.Add(new LauncherItem($"네트워크 스캔: {network}.1~254",
"Enter → 터미널에서 ping 스캔 스크립트 실행",
null, ("scan", network), Symbol: "\uE968"));
items.Add(new LauncherItem("팁", "결과가 많을 수 있습니다 — 터미널에서 확인하세요",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 직접 ping 대상
var host = parts[0];
var blocked2 = CheckInternalMode(host);
if (blocked2 != null) { items.Add(blocked2); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
items.Add(new LauncherItem($"ping {host}",
"Enter → 비동기 ping (4회) 실행",
null, ("ping", host), Symbol: "\uE968"));
items.Add(new LauncherItem($"tracert {host}",
"Enter → 터미널에서 tracert 실행",
null, ("tracert", host), Symbol: "\uE968"));
items.Add(new LauncherItem($"ping 연속 {host}",
"ping -t (무한 반복) — 터미널",
null, ("ping_t", host), Symbol: "\uE968"));
// 즉시 1회 ping 시도
var pingResult = TryPingOnce(host);
if (pingResult != null)
{
items.Insert(0, pingResult);
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("ping", string host):
RunInTerminal($"ping {host}");
break;
case ("ping_t", string host):
RunInTerminal($"ping -t {host}");
break;
case ("tracert", string host):
RunInTerminal($"tracert {host}");
break;
case ("scan", string network):
// PowerShell로 간단 스캔
var ps = $"1..254 | ForEach-Object {{ $ip = '{network}.$_'; if (Test-Connection $ip -Count 1 -Quiet -TimeoutSeconds 1) {{ Write-Host \"$ip is UP\" }} }}; Read-Host 'Press Enter'";
RunInTerminal($"powershell -NoExit -Command \"{ps}\"", usePs: true);
break;
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Ping", "복사됨");
}
catch { /* 비핵심 */ }
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static List<LauncherItem> BuildLocalNetworkItems()
{
var items = new List<LauncherItem>();
try
{
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.ToList();
items.Add(new LauncherItem($"로컬 네트워크 어댑터 {ifaces.Count}개", "", null, null, Symbol: "\uE968"));
foreach (var iface in ifaces)
{
var ipProps = iface.GetIPProperties();
var ipv4 = ipProps.UnicastAddresses
.FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var gateway = ipProps.GatewayAddresses.FirstOrDefault()?.Address?.ToString() ?? "없음";
if (ipv4 == null) continue;
var ip = ipv4.Address.ToString();
var mask = ipv4.IPv4Mask.ToString();
var label = $"{iface.Name} {ip}";
var sub2 = $"넷마스크 {mask} · 게이트웨이 {gateway}";
items.Add(new LauncherItem(label, sub2, null, ("copy", ip), Symbol: "\uE968"));
}
// 외부 IP 안내 (사외 모드에서만)
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
if (settings?.InternalModeEnabled == false)
items.Add(new LauncherItem("외부 IP 조회", "ping trace 8.8.8.8 으로 경로 확인",
null, null, Symbol: "\uE968"));
}
catch (Exception ex)
{
items.Add(new LauncherItem("네트워크 정보 조회 오류", ex.Message, null, null, Symbol: "\uE783"));
}
return items;
}
private static LauncherItem? TryPingOnce(string host)
{
try
{
using var p = new Ping();
var reply = p.Send(host, 1000);
if (reply.Status == IPStatus.Success)
{
var label = $"✓ 응답 {reply.RoundtripTime}ms";
return new LauncherItem(label, $"TTL {reply.Options?.Ttl ?? 0} · {host}",
null, ("copy", $"{reply.RoundtripTime}ms"), Symbol: "\uE968");
}
return new LauncherItem($"✗ 응답 없음 ({reply.Status})", host, null, null, Symbol: "\uE783");
}
catch
{
return null; // 오류 시 무시
}
}
private static LauncherItem? CheckInternalMode(string host)
{
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
if (settings?.InternalModeEnabled != true) return null;
// 내부 주소는 허용
if (host.StartsWith("192.168.", StringComparison.Ordinal) ||
host.StartsWith("10.", StringComparison.Ordinal) ||
host.StartsWith("172.", StringComparison.Ordinal) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
host.StartsWith("127.", StringComparison.Ordinal))
return null;
if (IPAddress.TryParse(host, out _))
return null; // IP 주소 → 내부로 간주 허용
return new LauncherItem("사내 모드 — 외부 도메인 차단",
"사외 모드에서 외부 주소 ping 가능. 설정에서 변경하세요.",
null, null, Symbol: "\uE783");
}
private static void RunInTerminal(string cmd, bool usePs = false)
{
try
{
var wtPath = FindExe("wt.exe");
if (wtPath != null && !usePs)
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = wtPath, Arguments = $"cmd /K {cmd}", UseShellExecute = false,
});
}
else
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = usePs ? "powershell" : "cmd",
Arguments = usePs ? $"-NoExit -Command \"{cmd}\"" : $"/K {cmd}",
UseShellExecute = true,
});
}
}
catch { /* 비핵심 */ }
}
private static string? FindExe(string name)
{
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';'))
{
var full = System.IO.Path.Combine(dir.Trim(), name);
if (System.IO.File.Exists(full)) return full;
}
return null;
}
}

View File

@@ -0,0 +1,178 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L22-3: Python pip 명령 생성기 핸들러. "pip" 프리픽스로 사용합니다.
///
/// 예: pip → 자주 쓰는 명령 목록
/// pip install → 패키지 설치 관련 명령
/// pip list → 목록 관련 명령
/// pip venv → 가상환경 관련 명령
/// pip conda → conda 관련 명령
/// pip <검색어> → 명령 검색
/// Enter → 명령어 클립보드 복사.
/// </summary>
public class PipHandler : IActionHandler
{
public string? Prefix => "pip";
public PluginMetadata Metadata => new(
"pip 명령",
"Python pip 명령 생성기 — 설치·관리·가상환경·conda",
"1.0",
"AX");
private sealed record PipCmd(
string Pip2,
string Pip3,
string Description,
string Category);
private static readonly PipCmd[] Commands =
[
// ── 설치 (install) ───────────────────────────────────────────────────
new("pip install {패키지}", "pip3 install {패키지}", "패키지 설치", "install"),
new("pip install {패키지}=={버전}", "pip3 install {패키지}=={버전}", "특정 버전 설치 (예: requests==2.31.0)", "install"),
new("pip install -r requirements.txt","pip3 install -r requirements.txt","requirements.txt 일괄 설치", "install"),
new("pip install --upgrade {패키지}", "pip3 install --upgrade {패키지}","패키지 업그레이드", "install"),
new("pip install --upgrade pip", "pip3 install --upgrade pip", "pip 자체 업그레이드", "install"),
new("pip install --user {패키지}", "pip3 install --user {패키지}", "사용자 홈에 설치 (관리자 권한 불필요)", "install"),
new("pip install -e .", "pip3 install -e .", "현재 폴더 패키지를 개발 모드로 설치", "install"),
new("pip download {패키지}", "pip3 download {패키지}", "오프라인 설치를 위한 패키지 다운로드", "install"),
// ── 제거 (uninstall) ─────────────────────────────────────────────────
new("pip uninstall {패키지}", "pip3 uninstall {패키지}", "패키지 제거", "uninstall"),
new("pip uninstall -y {패키지}", "pip3 uninstall -y {패키지}", "확인 없이 패키지 제거", "uninstall"),
new("pip uninstall -r requirements.txt -y","pip3 uninstall -r requirements.txt -y","requirements.txt 패키지 일괄 제거", "uninstall"),
// ── 목록·정보 (list) ─────────────────────────────────────────────────
new("pip list", "pip3 list", "설치된 패키지 목록", "list"),
new("pip list --outdated", "pip3 list --outdated", "업데이트 가능한 패키지 목록", "list"),
new("pip show {패키지}", "pip3 show {패키지}", "패키지 상세 정보 (버전·위치·의존성)", "list"),
new("pip freeze", "pip3 freeze", "설치 패키지 버전 고정 출력", "list"),
new("pip freeze > requirements.txt", "pip3 freeze > requirements.txt", "requirements.txt 파일 생성", "list"),
new("pip check", "pip3 check", "의존성 충돌 검사", "list"),
// ── 검색·캐시 (search) ───────────────────────────────────────────────
new("pip cache list", "pip3 cache list", "캐시 목록 확인", "search"),
new("pip cache purge", "pip3 cache purge", "캐시 전체 삭제", "search"),
new("pip index versions {패키지}", "pip3 index versions {패키지}", "PyPI에서 사용 가능한 버전 목록 조회", "search"),
new("pip config list", "pip3 config list", "pip 설정 목록", "search"),
new("pip config set global.index-url {URL}","pip3 config set global.index-url {URL}","사내 PyPI 미러 설정", "search"),
// ── 가상환경 (venv) ──────────────────────────────────────────────────
new("python -m venv .venv", "python3 -m venv .venv", "가상환경 생성 (.venv 폴더)", "venv"),
new(".venv\\Scripts\\activate", "source .venv/bin/activate", "가상환경 활성화 (Win / Mac·Linux)", "venv"),
new("deactivate", "deactivate", "가상환경 비활성화", "venv"),
new("python -m venv .venv --clear", "python3 -m venv .venv --clear", "가상환경 초기화 (재생성)", "venv"),
new("pip list --local", "pip3 list --local", "현재 가상환경 패키지만 목록", "venv"),
new("python -m site --user-site", "python3 -m site --user-site", "사용자 패키지 설치 경로 확인", "venv"),
// ── conda ────────────────────────────────────────────────────────────
new("conda create -n {환경명} python={버전}","conda create -n {환경명} python={버전}","Conda 환경 생성 (버전 지정)", "conda"),
new("conda activate {환경명}", "conda activate {환경명}", "Conda 환경 활성화", "conda"),
new("conda deactivate", "conda deactivate", "Conda 환경 비활성화", "conda"),
new("conda install {패키지}", "conda install {패키지}", "Conda로 패키지 설치", "conda"),
new("conda update {패키지}", "conda update {패키지}", "Conda 패키지 업데이트", "conda"),
new("conda list", "conda list", "현재 Conda 환경 패키지 목록", "conda"),
new("conda env list", "conda env list", "전체 Conda 환경 목록", "conda"),
new("conda env remove -n {환경명}", "conda env remove -n {환경명}", "Conda 환경 삭제", "conda"),
new("conda env export > env.yml", "conda env export > env.yml", "환경 설정을 yml 파일로 내보내기", "conda"),
new("conda env create -f env.yml", "conda env create -f env.yml", "yml 파일로 환경 복원", "conda"),
];
private static readonly (string Key, string[] Aliases, string Label)[] Categories =
[
("install", ["install", "설치", "add"], "패키지 설치"),
("uninstall", ["uninstall", "remove", "삭제"], "패키지 제거"),
("list", ["list", "목록", "show", "freeze"],"목록·정보"),
("search", ["search", "cache", "config"], "검색·캐시·설정"),
("venv", ["venv", "env", "가상환경"], "가상환경"),
("conda", ["conda", "anaconda"], "conda"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("pip 명령 생성기",
"카테고리: install · uninstall · list · venv · conda",
null, null, Symbol: "\uE943"));
foreach (var (key, _, label) in Categories)
{
var cnt = Commands.Count(c => c.Category == key);
items.Add(new LauncherItem($"pip {key}", $"{label} ({cnt}개 명령)",
null, ("copy", $"pip {key}"), Symbol: "\uE943"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var kw = q.ToLowerInvariant();
// 카테고리 일치
var cat = Categories.FirstOrDefault(c =>
c.Aliases.Any(a => a == kw || kw.StartsWith(a + " ")));
if (cat.Key != null)
{
var list = Commands.Where(c => c.Category == cat.Key).ToList();
items.Add(new LauncherItem($"{cat.Label} 명령 {list.Count}개",
"pip2 / pip3 모두 표시 · Enter: pip3 명령 복사", null, null, Symbol: "\uE943"));
foreach (var c in list) items.Add(CmdItem(c));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 검색
var searched = Commands.Where(c =>
c.Pip2.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
c.Pip3.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
c.Description.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
if (searched.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 명령을 찾을 수 없습니다",
"카테고리: install · uninstall · list · venv · conda",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"'{q}' 검색 결과 {searched.Count}개",
"Enter: pip3 명령 복사", null, null, Symbol: "\uE943"));
foreach (var c in searched) items.Add(CmdItem(c));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("pip", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
private static LauncherItem CmdItem(PipCmd c)
{
var title = c.Pip2 == c.Pip3
? c.Pip3
: $"{c.Pip3}";
var sub = c.Pip2 == c.Pip3
? c.Description
: $"{c.Description} | pip2: {c.Pip2}";
return new LauncherItem(title, sub, null, ("copy", c.Pip3), Symbol: "\uE943");
}
}

View File

@@ -0,0 +1,238 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다.
///
/// 예: pkg → 사용법 안내
/// pkg vscode → winget search vscode
/// pkg install {id} → winget install {id}
/// pkg list → 설치된 앱 목록
/// pkg upgrade → 업그레이드 가능 목록
/// winget 미설치 시 안내 메시지 표시.
/// </summary>
public partial class PkgHandler : IActionHandler
{
public string? Prefix => "pkg";
public PluginMetadata Metadata => new(
"앱 패키지",
"winget 앱 검색·설치·업그레이드",
"1.0",
"AX");
private static bool? _wingetAvailable;
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// winget 설치 여부 체크 (캐시)
_wingetAvailable ??= await CheckWingetAsync();
if (_wingetAvailable == false)
{
items.Add(new LauncherItem(
"winget이 설치되어 있지 않습니다",
"Windows Package Manager는 Windows 10 1709+ 에서 사용 가능합니다",
null, null, Symbol: Symbols.Warning));
return items;
}
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("winget 앱 패키지 관리",
"pkg {검색어} · pkg install {id} · pkg list · pkg upgrade",
null, null, Symbol: "\uECAA"));
return items;
}
// ── list 명령 ─────────────────────────────────────────────────────────
if (q.Equals("list", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem("설치된 앱 목록 조회 중...",
"winget list 실행", null, ("list", ""), Symbol: "\uECAA"));
// 실행 시 터미널에서 보여주기
return items;
}
// ── upgrade 명령 ──────────────────────────────────────────────────────
if (q.Equals("upgrade", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem("업그레이드 가능 앱 확인",
"Enter: winget upgrade 실행", null, ("upgrade", ""), Symbol: "\uE777"));
return items;
}
// ── install 명령 ──────────────────────────────────────────────────────
if (q.StartsWith("install ", StringComparison.OrdinalIgnoreCase))
{
var id = q[8..].Trim();
if (!string.IsNullOrWhiteSpace(id))
{
items.Add(new LauncherItem(
$"앱 설치: {id}",
$"Enter: winget install --id {id}",
null, ("install", id), Symbol: "\uE896"));
}
else
{
items.Add(new LauncherItem("사용법: pkg install {앱ID}",
"예: pkg install Microsoft.VisualStudioCode",
null, null, Symbol: Symbols.Info));
}
return items;
}
// ── 검색 ──────────────────────────────────────────────────────────────
try
{
var results = await SearchAsync(q, ct);
if (results.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
"다른 검색어를 시도하세요", null, null, Symbol: Symbols.Search));
}
else
{
items.Add(new LauncherItem($"검색 결과: {results.Count}개",
"Enter: winget install --id {ID}", null, null, Symbol: Symbols.Search));
foreach (var r in results.Take(10))
{
items.Add(new LauncherItem(
$"{r.Name} [{r.Version}]",
$"{r.Id} · {r.Source}",
null, ("install", r.Id), Symbol: "\uECAA"));
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
items.Add(new LauncherItem("검색 오류", ex.Message, null, null, Symbol: Symbols.Error));
}
return items;
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("install", string id) && !string.IsNullOrWhiteSpace(id))
{
RunWingetInTerminal($"install --id \"{id}\" --accept-source-agreements --accept-package-agreements");
NotificationService.Notify("pkg", $"설치 시작: {id}");
}
else if (item.Data is ("list", _))
{
RunWingetInTerminal("list");
}
else if (item.Data is ("upgrade", _))
{
RunWingetInTerminal("upgrade --include-unknown");
}
return Task.CompletedTask;
}
// ─── winget 검색 ──────────────────────────────────────────────────────────
private record PkgResult(string Name, string Id, string Version, string Source);
private static async Task<List<PkgResult>> SearchAsync(string query, CancellationToken ct)
{
var output = await RunWingetAsync($"search \"{query}\" --accept-source-agreements", ct);
return ParseWingetOutput(output);
}
[GeneratedRegex(@"^(.+?)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(\S+)\s*$")]
private static partial Regex WingetLineRegex();
private static List<PkgResult> ParseWingetOutput(string output)
{
var results = new List<PkgResult>();
var lines = output.Split('\n');
bool pastHeader = false;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd();
// 헤더 구분선 (---) 이후부터 데이터
if (line.StartsWith("---") || line.StartsWith("───"))
{
pastHeader = true;
continue;
}
if (!pastHeader || string.IsNullOrWhiteSpace(line)) continue;
var match = WingetLineRegex().Match(line);
if (match.Success)
{
results.Add(new PkgResult(
match.Groups[1].Value.Trim(),
match.Groups[2].Value.Trim(),
match.Groups[3].Value.Trim(),
match.Groups[4].Value.Trim()));
}
}
return results;
}
// ─── winget 실행 ──────────────────────────────────────────────────────────
private static async Task<bool> CheckWingetAsync()
{
try
{
var output = await RunWingetAsync("--version", CancellationToken.None);
return output.TrimStart().StartsWith('v');
}
catch { return false; }
}
private static async Task<string> RunWingetAsync(string args, CancellationToken ct)
{
using var proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "winget",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8
}
};
proc.Start();
var output = await proc.StandardOutput.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return output;
}
private static void RunWingetInTerminal(string args)
{
try
{
// 사용자에게 진행 상황이 보이도록 터미널 창으로 실행
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/k winget {args}",
UseShellExecute = true
});
}
catch (Exception ex)
{
LogService.Warn($"winget 실행 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,130 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-9: 뽀모도로 타이머 핸들러. "pomo" 프리픽스로 사용합니다.
///
/// 사용법:
/// pomo → 현재 타이머 상태 표시
/// pomo start → 집중 타이머 시작 (25분)
/// pomo break → 휴식 타이머 시작 (5분)
/// pomo stop → 타이머 중지
/// pomo reset → 타이머 초기화
///
/// Enter → 해당 명령 실행.
/// </summary>
public class PomoHandler : IActionHandler
{
public string? Prefix => "pomo";
public PluginMetadata Metadata => new(
"Pomodoro",
"뽀모도로 타이머 — pomo",
"1.0",
"AX",
"집중/휴식 타이머를 관리합니다.");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var svc = PomodoroService.Instance;
var items = new List<LauncherItem>();
// ─── 명령 분기 ────────────────────────────────────────────────────────
if (q is "start" or "focus")
{
items.Add(new LauncherItem("집중 타이머 시작",
$"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행",
null, "__START__", Symbol: Symbols.Timer));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (q is "break" or "rest")
{
items.Add(new LauncherItem("휴식 타이머 시작",
$"{svc.BreakMinutes}분 휴식 모드 시작 · Enter로 실행",
null, "__BREAK__", Symbol: Symbols.Timer));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (q is "stop" or "pause")
{
items.Add(new LauncherItem("타이머 중지",
"현재 타이머를 중지합니다 · Enter로 실행",
null, "__STOP__", Symbol: Symbols.MediaPlay));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (q == "reset")
{
items.Add(new LauncherItem("타이머 초기화",
"타이머를 처음 상태로 초기화합니다 · Enter로 실행",
null, "__RESET__", Symbol: Symbols.Restart));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 기본: 현재 상태 + 명령 목록 ─────────────────────────────────────
var remaining = svc.Remaining;
var timeStr = $"{(int)remaining.TotalMinutes:D2}:{remaining.Seconds:D2}";
var stateStr = svc.Mode switch
{
PomodoroMode.Focus => svc.IsRunning ? $"집중 중 {timeStr} 남음" : $"집중 일시정지 {timeStr} 남음",
PomodoroMode.Break => svc.IsRunning ? $"휴식 중 {timeStr} 남음" : $"휴식 일시정지 {timeStr} 남음",
_ => "대기 중",
};
// 상태 카드
items.Add(new LauncherItem(
"🍅 뽀모도로 타이머",
stateStr,
null, null, Symbol: Symbols.Timer));
// 빠른 명령들
if (!svc.IsRunning)
{
items.Add(new LauncherItem("집중 시작",
$"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행",
null, "__START__", Symbol: Symbols.Timer));
}
else
{
items.Add(new LauncherItem("타이머 중지",
"현재 타이머를 중지합니다 · Enter로 실행",
null, "__STOP__", Symbol: Symbols.MediaPlay));
}
if (svc.Mode == PomodoroMode.Focus && svc.IsRunning)
{
items.Add(new LauncherItem("휴식 시작",
$"{svc.BreakMinutes}분 휴식 모드로 전환 · Enter로 실행",
null, "__BREAK__", Symbol: Symbols.Timer));
}
items.Add(new LauncherItem("타이머 초기화",
"타이머를 처음 상태로 초기화합니다 · Enter로 실행",
null, "__RESET__", Symbol: Symbols.Restart));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string cmd) return Task.CompletedTask;
var svc = PomodoroService.Instance;
switch (cmd)
{
case "__START__": svc.StartFocus(); break;
case "__BREAK__": svc.StartBreak(); break;
case "__STOP__": svc.Stop(); break;
case "__RESET__": svc.Reset(); break;
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,202 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L22-1: 프로세스 상세 조회·정리 핸들러. "proc" 프리픽스로 사용합니다.
///
/// 예: proc → CPU 사용량 상위 프로세스 목록
/// proc top → CPU 상위 15개
/// proc mem → 메모리 상위 15개
/// proc <이름> → 이름 검색 (부분 일치)
/// proc kill <이름> → 이름으로 종료 (첫 번째 일치)
/// proc stats → 전체 통계 (수·CPU합·메모리합)
/// Enter → 프로세스 이름 복사.
/// </summary>
public class ProcHandler : IActionHandler
{
public string? Prefix => "proc";
public PluginMetadata Metadata => new(
"프로세스 관리",
"실행 중인 프로세스 조회·정리 — CPU·메모리 정렬·검색·종료",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
// kill 서브커맨드
if (sub is "kill" or "종료" or "stop")
{
if (parts.Length < 2)
{
items.Add(ErrorItem("예: proc kill <프로세스명>"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var target = parts[1];
var killed = KillProcess(target);
items.Add(killed
? new LauncherItem($"✓ '{target}' 종료 완료", "프로세스가 종료되었습니다", null, null, Symbol: "\uE74D")
: new LauncherItem($"'{target}' 프로세스를 찾을 수 없습니다", "실행 중인지 확인하세요", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 프로세스 목록 수집
Process[] procs;
try { procs = Process.GetProcesses(); }
catch (Exception ex)
{
items.Add(ErrorItem($"프로세스 조회 실패: {ex.Message}"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// stats
if (sub is "stats" or "stat")
{
var totalMem = procs.Sum(p => SafeWorkingSet(p));
var totalCpu = CountHighCpu(procs);
items.Add(new LauncherItem("프로세스 통계", "", null, null, Symbol: "\uE9D9"));
items.Add(CopyItem("전체 프로세스 수", procs.Length.ToString()));
items.Add(CopyItem("전체 메모리 사용", FormatBytes(totalMem)));
items.Add(CopyItem("CPU 10%+ 프로세스", $"{totalCpu}개"));
items.Add(CopyItem("고유 프로세스 종류", procs.Select(p => p.ProcessName).Distinct().Count().ToString()));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 이름 검색
if (!string.IsNullOrWhiteSpace(sub) && sub is not "top" and not "mem" and not "all")
{
var matched = procs
.Where(p => p.ProcessName.Contains(sub, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(p => SafeWorkingSet(p))
.Take(20)
.ToList();
if (matched.Count == 0)
{
items.Add(new LauncherItem($"'{sub}' 프로세스 없음", "실행 중인 프로세스를 검색합니다", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"'{sub}' 검색 결과 {matched.Count}개",
"Enter: 이름 복사 · proc kill <이름>으로 종료", null, null, Symbol: "\uE721"));
foreach (var p in matched)
items.Add(BuildItem(p));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// mem: 메모리 정렬
if (sub is "mem" or "memory" or "메모리")
{
var top = procs.OrderByDescending(p => SafeWorkingSet(p)).Take(15).ToList();
items.Add(new LauncherItem($"메모리 상위 {top.Count}개 프로세스",
"메모리 사용량 내림차순", null, null, Symbol: "\uE9D9"));
foreach (var p in top) items.Add(BuildItem(p));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// top / 기본: CPU 정렬 (WorkingSet 근사치 사용, CPU%는 샘플링 필요)
{
var top = procs
.OrderByDescending(p => SafeWorkingSet(p))
.Take(15)
.ToList();
var label = sub is "top" ? $"CPU/메모리 상위 {top.Count}개" : $"실행 중 프로세스 상위 {top.Count}개";
items.Add(new LauncherItem(label,
$"전체 {procs.Length}개 실행 중 · proc mem / proc <검색어> / proc kill <이름>",
null, null, Symbol: "\uE9D9"));
foreach (var p in top) items.Add(BuildItem(p));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("프로세스", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ──────────────────────────────────────────────────────────────
private static LauncherItem BuildItem(Process p)
{
var mem = SafeWorkingSet(p);
var name = p.ProcessName;
var pid = p.Id;
var sub = $"PID {pid} · {FormatBytes(mem)}";
return new LauncherItem(name, sub, null, ("copy", name), Symbol: "\uE9D9");
}
private static long SafeWorkingSet(Process p)
{
try { return p.WorkingSet64; }
catch { return 0; }
}
private static int CountHighCpu(Process[] procs)
{
// CPU 퍼센트를 정확히 측정하려면 2회 샘플링이 필요하므로
// 여기서는 스레드 수 > 5 를 기준으로 근사
int count = 0;
foreach (var p in procs)
{
try { if (p.Threads.Count > 5) count++; }
catch { }
}
return count;
}
private static bool KillProcess(string name)
{
var targets = Process.GetProcessesByName(name);
if (targets.Length == 0)
{
// 확장자 없이 시도
var noExt = System.IO.Path.GetFileNameWithoutExtension(name);
targets = Process.GetProcessesByName(noExt);
}
if (targets.Length == 0) return false;
foreach (var p in targets)
{
try { p.Kill(); } catch { }
}
return true;
}
private static string FormatBytes(long bytes)
{
if (bytes >= 1_073_741_824) return $"{bytes / 1_073_741_824.0:F1} GB";
if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F0} MB";
if (bytes >= 1_024) return $"{bytes / 1_024.0:F0} KB";
return $"{bytes} B";
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE9D9");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,268 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L21-3: PowerShell 명령 생성기 핸들러. "ps" 프리픽스로 사용합니다.
///
/// 예: ps → 카테고리별 자주 쓰는 명령 목록
/// ps <키워드> → 명령 검색 (예: ps process, ps service, ps file)
/// ps file → 파일/디렉토리 명령 목록
/// ps process → 프로세스 명령 목록
/// ps network → 네트워크 명령 목록
/// ps service → 서비스 명령 목록
/// ps registry → 레지스트리 명령 목록
/// ps string → 문자열 처리 명령 목록
/// ps date → 날짜/시간 명령 목록
/// ps pipe → 파이프라인 예시
/// ps run <명령> → 직접 PowerShell 실행
/// Enter → 클립보드 복사 (또는 PS 터미널 실행).
/// </summary>
public class PsHandler : IActionHandler
{
public string? Prefix => "ps";
public PluginMetadata Metadata => new(
"PS",
"PowerShell 명령 생성기 — 파일·프로세스·네트워크·서비스·레지스트리",
"1.0",
"AX");
private record PsCmd(string Cmd, string Description, string Category);
private static readonly PsCmd[] Commands =
[
// 파일/디렉토리
new("Get-ChildItem -Path . -Recurse", "현재 폴더 재귀 목록", "file"),
new("Get-ChildItem -Filter *.log -Recurse", "*.log 파일 재귀 검색", "file"),
new("Copy-Item src.txt dst.txt", "파일 복사", "file"),
new("Move-Item src.txt dst.txt", "파일 이동/이름변경", "file"),
new("Remove-Item -Path file.txt -Force", "파일 강제 삭제", "file"),
new("New-Item -ItemType Directory -Path .\\NewFolder", "새 폴더 생성", "file"),
new("Get-Content file.txt", "파일 내용 출력", "file"),
new("Get-Content file.txt -Tail 20", "파일 마지막 20줄 (tail)", "file"),
new("Set-Content file.txt \"내용\"", "파일 쓰기", "file"),
new("Add-Content file.txt \"추가 내용\"", "파일에 내용 추가", "file"),
new("Test-Path C:\\path\\to\\file", "경로 존재 여부 확인", "file"),
new("Resolve-Path .\\relative\\path", "상대 경로 → 절대 경로", "file"),
new("Get-Item file.txt | Select-Object Length,LastWriteTime", "파일 크기·수정일 조회", "file"),
new("(Get-ChildItem -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB", "폴더 크기 계산 (MB)", "file"),
new("Compress-Archive -Path .\\folder -DestinationPath out.zip","폴더 압축 (.zip)", "file"),
new("Expand-Archive -Path file.zip -DestinationPath .\\out", "zip 압축 해제", "file"),
// 프로세스
new("Get-Process", "실행 중인 프로세스 목록", "process"),
new("Get-Process -Name chrome | Sort-Object CPU -Desc", "Chrome 프로세스 CPU 순 정렬", "process"),
new("Stop-Process -Name notepad -Force", "notepad 강제 종료", "process"),
new("Stop-Process -Id 1234 -Force", "PID로 프로세스 종료", "process"),
new("Start-Process chrome.exe", "프로세스 실행", "process"),
new("Start-Process notepad -Verb RunAs", "관리자 권한으로 실행", "process"),
new("Get-Process | Where-Object {$_.CPU -gt 10} | Sort-Object CPU -Desc | Select -First 5", "CPU 10% 이상 상위 5개", "process"),
new("Get-WmiObject Win32_Process | Select-Object Name,ProcessId,CommandLine", "프로세스 명령줄 조회", "process"),
new("(Get-Process -Id $PID).Path", "현재 PowerShell 실행 경로", "process"),
// 서비스
new("Get-Service", "모든 서비스 목록", "service"),
new("Get-Service | Where-Object {$_.Status -eq 'Running'}", "실행 중인 서비스만", "service"),
new("Start-Service -Name wuauserv", "Windows Update 서비스 시작", "service"),
new("Stop-Service -Name wuauserv", "서비스 중지", "service"),
new("Restart-Service -Name Spooler", "인쇄 스풀러 재시작", "service"),
new("Set-Service -Name Fax -StartupType Disabled", "서비스 시작 유형 변경 (비활성화)", "service"),
new("Get-Service -Name sshd", "SSH 서비스 상태 확인", "service"),
// 네트워크
new("Test-NetConnection google.com -Port 443", "도메인+포트 연결 테스트", "network"),
new("Test-NetConnection 8.8.8.8", "IP ping 테스트", "network"),
new("Get-NetIPAddress -AddressFamily IPv4", "IPv4 주소 목록", "network"),
new("Get-NetAdapter | Where-Object {$_.Status -eq 'Up'}", "활성 네트워크 어댑터 목록", "network"),
new("Get-NetTCPConnection -State Listen", "Listen 상태 포트 목록", "network"),
new("Get-NetTCPConnection | Where-Object {$_.LocalPort -eq 80}","포트 80 연결 조회", "network"),
new("Resolve-DnsName google.com", "DNS 조회", "network"),
new("(Invoke-WebRequest -Uri 'https://api.ipify.org').Content", "공인 IP 주소 확인", "network"),
new("Invoke-WebRequest -Uri 'http://localhost:8080/health'", "HTTP GET 요청", "network"),
// 레지스트리
new("Get-ItemProperty HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "시작 프로그램 확인", "registry"),
new("Get-ChildItem HKLM:\\SOFTWARE", "HKLM\\SOFTWARE 하위 키 목록", "registry"),
new("New-ItemProperty -Path HKCU:\\Software -Name MyVal -Value 1 -PropertyType DWORD", "레지스트리 값 쓰기", "registry"),
new("Remove-ItemProperty -Path HKCU:\\Software -Name MyVal", "레지스트리 값 삭제", "registry"),
// 문자열·데이터
new("\"hello world\" -replace 'world','PowerShell'", "문자열 치환", "string"),
new("\" text \".Trim()", "문자열 앞뒤 공백 제거", "string"),
new("[System.Web.HttpUtility]::UrlEncode('hello world')", "URL ", "string"),
new("[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('hello'))", "Base64 인코딩", "string"),
new("[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('aGVsbG8='))", "Base64 디코딩", "string"),
new("Get-FileHash file.txt -Algorithm SHA256", "파일 SHA256 해시", "string"),
new("$text | ConvertTo-Json", "객체 → JSON 변환", "string"),
new("Get-Content file.json | ConvertFrom-Json", "JSON 파일 파싱", "string"),
new("Import-Csv data.csv", "CSV 파일 파싱", "string"),
new("Export-Csv -Path out.csv -NoTypeInformation", "CSV 파일 내보내기", "string"),
// 날짜/시간
new("Get-Date", "현재 날짜·시간", "date"),
new("Get-Date -Format 'yyyy-MM-dd HH:mm:ss'", "날짜 포맷 지정", "date"),
new("(Get-Date).AddDays(30)", "30일 후 날짜", "date"),
new("(Get-Date) - (Get-Date '2024-01-01')", "날짜 차이 계산", "date"),
new("[datetime]::UtcNow", "UTC 현재 시각", "date"),
new("[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()", "Unix 타임스탬프 (초)", "date"),
new("[DateTimeOffset]::FromUnixTimeSeconds(1700000000).LocalDateTime", "Unix 타임스탬프 → 날짜", "date"),
// 파이프라인
new("Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 | Format-Table", "상위 10 프로세스 테이블 출력", "pipe"),
new("Get-ChildItem *.txt | ForEach-Object { $_.Name }", "txt 파일 이름만 추출", "pipe"),
new("1..10 | ForEach-Object { $_ * $_ }", "1~10 제곱수 생성", "pipe"),
new("'a','b','c' | Where-Object { $_ -ne 'b' }", "배열 필터링", "pipe"),
new("Get-Content log.txt | Select-String 'ERROR'", "파일에서 ERROR 줄 검색", "pipe"),
new("Get-EventLog -LogName System -Newest 50 | Where-Object {$_.EntryType -eq 'Error'}", "시스템 이벤트 오류 조회", "pipe"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("PowerShell 명령 생성기",
"ps file · process · network · service · registry · string · date · pipe · run <명령>",
null, null, Symbol: "\uE756"));
AddCategoryOverview(items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// ps run <명령>
if (sub == "run" && parts.Length >= 2)
{
var cmd = parts[1];
items.Add(new LauncherItem(cmd, "Enter → PowerShell에서 실행",
null, ("run", cmd), Symbol: "\uE756"));
items.Add(new LauncherItem("클립보드 복사", cmd, null, ("copy", cmd), Symbol: "\uE756"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 카테고리 조회
var catMatch = Commands.Where(c => c.Category.Equals(sub, StringComparison.OrdinalIgnoreCase)).ToList();
if (catMatch.Count > 0)
{
var catName = sub switch
{
"file" => "파일/디렉토리",
"process" => "프로세스",
"service" => "서비스",
"network" => "네트워크",
"registry" => "레지스트리",
"string" => "문자열·데이터",
"date" => "날짜/시간",
"pipe" => "파이프라인 예시",
_ => sub
};
items.Add(new LauncherItem($"── {catName} ──", $"{catMatch.Count}개 명령", null, null, Symbol: "\uE756"));
foreach (var c in catMatch)
items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description,
null, ("copy", c.Cmd), Symbol: "\uE756"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 키워드 검색
var keyword = string.Join(" ", parts);
var found = Commands.Where(c =>
c.Cmd.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
c.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count > 0)
{
items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE756"));
foreach (var c in found)
items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description,
null, ("copy", c.Cmd), Symbol: "\uE756"));
}
else
{
items.Add(new LauncherItem($"'{keyword}' 결과 없음",
"ps file · process · network · service · registry · string · date · pipe",
null, null, Symbol: "\uE783"));
AddCategoryOverview(items);
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("PS", "클립보드에 복사했습니다.");
}
catch { }
break;
case ("run", string cmd):
RunInPowerShell(cmd);
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static void AddCategoryOverview(List<LauncherItem> items)
{
var cats = new (string Key, string Label)[]
{
("file", "파일/디렉토리 — Get-ChildItem · Copy-Item · Remove-Item · Compress-Archive"),
("process", "프로세스 — Get-Process · Stop-Process · Start-Process"),
("service", "서비스 — Get-Service · Start-Service · Stop-Service"),
("network", "네트워크 — Test-NetConnection · Get-NetIPAddress · Resolve-DnsName"),
("registry", "레지스트리 — Get-ItemProperty · New-ItemProperty · Remove-ItemProperty"),
("string", "문자열·데이터 — ConvertTo-Json · Get-FileHash · Import-Csv"),
("date", "날짜/시간 — Get-Date · AddDays · UnixTimeSeconds"),
("pipe", "파이프라인 — Sort-Object · Where-Object · ForEach-Object"),
};
foreach (var (key, label) in cats)
items.Add(new LauncherItem($"ps {key}", label, null, null, Symbol: "\uE756"));
}
private static void RunInPowerShell(string cmd)
{
try
{
// Windows Terminal 우선
var wtPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@"Microsoft\WindowsApps\wt.exe");
if (System.IO.File.Exists(wtPath))
{
Process.Start(new ProcessStartInfo
{
FileName = wtPath,
Arguments = $"PowerShell -NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"",
UseShellExecute = true
});
return;
}
Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"",
UseShellExecute = true
});
}
catch { }
}
private static string TruncateStr(string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
}

View File

@@ -0,0 +1,127 @@
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using AxCopilot.SDK;
using AxCopilot.Services;
using QRCoder;
namespace AxCopilot.Handlers;
/// <summary>
/// L27-3: QR 코드 생성 핸들러. "qr" 프리픽스로 사용합니다.
///
/// 예: qr https://google.com → QR 코드 생성 (Enter: 클립보드 복사)
/// qr 안녕하세요 → 한국어 텍스트 QR 생성
/// qr save https://... → QR PNG 파일 저장
/// Enter → QR 이미지를 클립보드에 복사 (붙여넣기로 사용).
/// </summary>
public class QrHandler : IActionHandler
{
public string? Prefix => "qr";
public PluginMetadata Metadata => new(
"QR 코드",
"QR 코드 생성 — 텍스트/URL → 클립보드 PNG 복사",
"1.0",
"AX");
private const int PixelsPerModule = 10;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
"QR 코드 생성",
"qr {텍스트 또는 URL} → Enter: QR PNG 클립보드 복사",
null, null, Symbol: "\uE8C8"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// save 명령 감지
bool saveToFile = false;
var text = q;
if (q.StartsWith("save ", StringComparison.OrdinalIgnoreCase))
{
saveToFile = true;
text = q[5..].Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
items.Add(new LauncherItem("QR 생성할 텍스트를 입력하세요",
"qr {텍스트} 또는 qr save {텍스트}", null, null, Symbol: "\uE8C8"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
int byteLen = System.Text.Encoding.UTF8.GetByteCount(text);
var typeHint = Uri.TryCreate(text, UriKind.Absolute, out _) ? "URL" : "텍스트";
items.Add(new LauncherItem(
$"QR 생성: {(text.Length > 60 ? text[..60] + "" : text)}",
saveToFile
? $"{typeHint} · {byteLen}바이트 · Enter: PNG 파일 저장"
: $"{typeHint} · {byteLen}바이트 · Enter: PNG 클립보드 복사",
null, (saveToFile ? "save" : "copy", text), Symbol: "\uE8C8"));
if (!saveToFile)
{
items.Add(new LauncherItem(
"qr save ...",
"PNG 파일로 저장하려면 qr save {텍스트} 입력",
null, null, Symbol: "\uE74E"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not (string action, string text)) return Task.CompletedTask;
try
{
byte[] png = GenerateQrPng(text);
if (action == "save")
{
var path = Path.Combine(Path.GetTempPath(), $"qr_{DateTime.Now:yyyyMMdd_HHmmss}.png");
File.WriteAllBytes(path, png);
// 탐색기에서 파일 선택 상태로 열기
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
NotificationService.Notify("qr", $"QR 저장: {path}");
}
else
{
// PNG → BitmapImage → 클립보드
using var ms = new MemoryStream(png);
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
Application.Current.Dispatcher.Invoke(() => Clipboard.SetImage(bitmap));
NotificationService.Notify("qr", "QR 이미지가 클립보드에 복사되었습니다.");
}
}
catch (Exception ex)
{
NotificationService.Notify("qr", $"QR 생성 실패: {ex.Message}");
}
return Task.CompletedTask;
}
private static byte[] GenerateQrPng(string text)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
using var code = new PngByteQRCode(data);
return code.GetGraphic(PixelsPerModule);
}
}

View File

@@ -0,0 +1,127 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-4: 파라미터 퀵링크 핸들러. "ql" 예약어로 사용합니다.
/// 예: ql maps 강남역 → "maps" 키워드 URL에 "강남역" 치환 후 열기
/// ql jira PROJ-1234 → "jira" 키워드 URL에 티켓 번호 치환
/// ql (목록) → 등록된 퀵링크 목록 표시
///
/// 퀵링크는 설정 → 일반 → 퀵링크 탭에서 등록합니다.
/// </summary>
public class QuickLinkHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => "ql";
public PluginMetadata Metadata => new(
"QuickLink",
"파라미터 퀵링크 — ql [키워드] [인자]",
"1.0",
"AX");
public QuickLinkHandler(SettingsService settings) => _settings = settings;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var links = _settings.Settings.QuickLinks;
// 등록된 퀵링크 없음
if (links.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"등록된 퀵링크 없음",
"설정 → 일반 → 퀵링크에서 추가하세요. 예: keyword=maps, url=https://map.naver.com/p/search/{0}",
null, null, Symbol: Symbols.Globe)
]);
}
var items = new List<LauncherItem>();
var parts = query.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
// 쿼리 없음 — 전체 목록 표시
foreach (var link in links)
{
items.Add(new LauncherItem(
link.Name.Length > 0 ? link.Name : link.Keyword,
$"ql {link.Keyword} [인자] · {link.Description} · {link.UrlTemplate}",
null, null, Symbol: Symbols.Globe));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var keyword = parts[0].ToLowerInvariant();
var argQuery = parts.Length > 1 ? parts[1] : "";
// 키워드로 정확 일치 검색
var matched = links.Where(l => l.Keyword.ToLowerInvariant() == keyword).ToList();
if (matched.Count > 0 && !string.IsNullOrWhiteSpace(argQuery))
{
// 인자가 있으면 URL 치환 후 실행 항목 생성
foreach (var link in matched)
{
var url = UrlTemplateEngine.ExpandFromQuery(link.UrlTemplate, argQuery);
items.Add(new LauncherItem(
$"{(link.Name.Length > 0 ? link.Name : link.Keyword)}: {argQuery}",
url,
null, url, Symbol: Symbols.Globe));
}
}
else
{
// 키워드 퍼지 검색 (부분 일치)
var fuzzy = links
.Where(l => l.Keyword.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
l.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase))
.ToList();
if (fuzzy.Count == 0)
{
items.Add(new LauncherItem(
$"'{keyword}'에 해당하는 퀵링크 없음",
"설정에서 새 퀵링크를 추가하세요",
null, null, Symbol: Symbols.Globe));
}
else
{
foreach (var link in fuzzy)
{
var hint = UrlTemplateEngine.GetPlaceholders(link.UrlTemplate);
var ph = hint.Count > 0 ? $" · 인자: {string.Join(", ", hint)}" : "";
items.Add(new LauncherItem(
$"ql {link.Keyword}{ph}",
link.Description.Length > 0 ? link.Description : link.UrlTemplate,
null, null, Symbol: Symbols.Globe));
}
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string url && !string.IsNullOrWhiteSpace(url))
{
try
{
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"퀵링크 열기 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,324 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-2: 랜덤 생성기 핸들러. "rand" 프리픽스로 사용합니다.
///
/// 예: rand → 사용법 목록
/// rand → 1~100 기본 난수
/// rand 50 → 1~50 난수
/// rand 10 99 → 10~99 난수
/// rand str → 랜덤 문자열 (12자, 영숫자)
/// rand str 20 → 20자 랜덤 문자열
/// rand str 16 alpha → 16자 알파벳만
/// rand str 8 num → 8자 숫자만
/// rand str 12 hex → 12자 hex 문자열
/// rand str 16 special → 특수문자 포함
/// rand color → 랜덤 HEX 색상
/// rand pick 항목1 항목2 → 목록에서 무작위 선택
/// rand dice → 주사위 1d6
/// rand dice 2d6 → 2개 주사위
/// rand dice 1d20 → 20면체 주사위
/// rand coin → 동전 던지기
/// rand shuffle a b c d → 목록 셔플
/// rand uuid → UUID v4
/// rand token → 보안 토큰 (32자 hex)
/// rand pin → 6자리 PIN
/// rand pin 4 → 4자리 PIN
/// Enter → 결과 복사.
/// </summary>
public class RandHandler : IActionHandler
{
public string? Prefix => "rand";
public PluginMetadata Metadata => new(
"Rand",
"랜덤 생성기 — 숫자·문자열·색상·주사위·UUID·토큰·PIN·셔플",
"1.0",
"AX");
private static readonly Random _rng = Random.Shared;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 기본: 1~100 난수 + 사용법
var n = _rng.Next(1, 101);
items.Add(new LauncherItem($"{n}",
"1~100 랜덤 숫자 · Enter 복사", null, ("copy", n.ToString()), Symbol: "\uE8D0"));
items.Add(new LauncherItem("사용법",
"rand <max> / rand <min> <max> / rand str / rand color / rand dice / rand pick …",
null, null, Symbol: "\uE8D0"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
// rand str [length] [charset]
case "str" or "string" or "문자열":
{
int len = 12;
if (parts.Length >= 2 && int.TryParse(parts[1], out var l)) len = Math.Clamp(l, 1, 256);
var charset = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "alnum";
if (parts.Length == 2 && !int.TryParse(parts[1], out _)) charset = parts[1].ToLowerInvariant();
var cs = GetCharset(charset);
if (string.IsNullOrEmpty(cs)) { items.Add(ErrorItem("charset: alpha/num/alnum/hex/special")); break; }
var result = RandStr(len, cs);
items.Add(new LauncherItem(result, $"{len}자 랜덤 문자열 ({charset}) · Enter 복사",
null, ("copy", result), Symbol: "\uE8D0"));
// 다른 자릿수 미리 생성
foreach (var altLen in new[] { 8, 16, 32 }.Where(x => x != len))
items.Add(new LauncherItem(RandStr(altLen, cs), $"{altLen}자 ({charset})",
null, ("copy", RandStr(altLen, cs)), Symbol: "\uE8D0"));
break;
}
// rand color
case "color" or "색상" or "colour":
{
for (int i = 0; i < 5; i++)
{
var r = _rng.Next(256); var g = _rng.Next(256); var b = _rng.Next(256);
var hex = $"#{r:X2}{g:X2}{b:X2}";
var rgb = $"rgb({r}, {g}, {b})";
var hsl = RgbToHsl(r, g, b);
items.Add(new LauncherItem(hex, $"{rgb} {hsl} · Enter 복사",
null, ("copy", hex), Symbol: "\uE8D0"));
}
break;
}
// rand dice [NdS]
case "dice" or "주사위":
{
var spec = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "1d6";
if (!TryParseDice(spec, out var dCount, out var dSides))
{ items.Add(ErrorItem("형식: 1d6 / 2d10 / 1d20")); break; }
var rolls = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
var total = rolls.Sum();
var detail = string.Join(" + ", rolls);
items.Add(new LauncherItem($"{dCount}d{dSides} → {total}",
$"각 주사위: {detail} · Enter 복사", null, ("copy", total.ToString()), Symbol: "\uE8D0"));
if (dCount > 1)
{
items.Add(CopyItem("합계", total.ToString()));
items.Add(CopyItem("상세", detail));
items.Add(CopyItem("최솟값", rolls.Min().ToString()));
items.Add(CopyItem("최댓값", rolls.Max().ToString()));
}
// 다시 굴리기 미리보기
items.Add(new LauncherItem("── 다시 굴리기 (미리보기) ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 3; i++)
{
var r2 = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
items.Add(new LauncherItem($"{r2.Sum()}", string.Join(" + ", r2),
null, ("copy", r2.Sum().ToString()), Symbol: "\uE8D0"));
}
break;
}
// rand coin
case "coin" or "동전":
{
var result = _rng.Next(2) == 0 ? "앞면 (Head)" : "뒷면 (Tail)";
items.Add(new LauncherItem(result, "동전 던지기 · Enter 복사",
null, ("copy", result), Symbol: "\uE8D0"));
// 연속 5회
items.Add(new LauncherItem("── 연속 5회 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 5; i++)
items.Add(new LauncherItem(_rng.Next(2) == 0 ? "앞면 ⬤" : "뒷면 ○",
$"#{i + 1}", null, null, Symbol: "\uE8D0"));
break;
}
// rand pick item1 item2 ...
case "pick" or "선택":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: rand pick 치킨 피자 짜장면")); break; }
var pool = parts[1..];
var picked = pool[_rng.Next(pool.Length)];
items.Add(new LauncherItem(picked,
$"{pool.Length}개 중 선택 · Enter 복사",
null, ("copy", picked), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 다시 선택 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < Math.Min(3, pool.Length); i++)
items.Add(new LauncherItem(pool[_rng.Next(pool.Length)],
$"후보 {i + 1}", null, null, Symbol: "\uE8D0"));
break;
}
// rand shuffle item1 item2 ...
case "shuffle" or "섞기":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: rand shuffle a b c d e")); break; }
var list = parts[1..].ToList();
for (int i = list.Count - 1; i > 0; i--)
{
var j = _rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
var joined = string.Join(" ", list);
items.Add(new LauncherItem(joined,
$"{list.Count}개 셔플 · Enter 복사",
null, ("copy", joined), Symbol: "\uE8D0"));
items.Add(CopyItem("쉼표 구분", string.Join(", ", list)));
for (int r = 0; r < list.Count; r++)
items.Add(new LauncherItem($"#{r + 1} {list[r]}", "", null, null, Symbol: "\uE8D0"));
break;
}
// rand uuid
case "uuid":
{
for (int i = 0; i < 5; i++)
{
var guid = Guid.NewGuid().ToString();
items.Add(new LauncherItem(guid, $"UUID v4 · Enter 복사",
null, ("copy", guid), Symbol: "\uE8D0"));
}
break;
}
// rand token
case "token" or "secret" or "key":
{
int tLen = 32;
if (parts.Length >= 2 && int.TryParse(parts[1], out var tl)) tLen = Math.Clamp(tl, 8, 128);
var tokenBytes = new byte[tLen];
System.Security.Cryptography.RandomNumberGenerator.Fill(tokenBytes);
var tokenHex = BitConverter.ToString(tokenBytes).Replace("-", "").ToLowerInvariant();
var tokenB64 = Convert.ToBase64String(tokenBytes);
items.Add(new LauncherItem(tokenHex, $"{tLen}바이트 보안 토큰 (hex) · Enter 복사",
null, ("copy", tokenHex), Symbol: "\uE8D0"));
items.Add(CopyItem("Hex", tokenHex));
items.Add(CopyItem("Base64", tokenB64));
break;
}
// rand pin [length]
case "pin":
{
int pLen = 6;
if (parts.Length >= 2 && int.TryParse(parts[1], out var pl)) pLen = Math.Clamp(pl, 4, 12);
var pin = string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10)));
items.Add(new LauncherItem(pin, $"{pLen}자리 PIN · Enter 복사",
null, ("copy", pin), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 추가 PIN ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 4; i++)
items.Add(new LauncherItem(
string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10))),
$"{pLen}자리", null, null, Symbol: "\uE8D0"));
break;
}
default:
{
// rand <max> 또는 rand <min> <max>
if (int.TryParse(sub, out var max))
{
int min = 1;
if (parts.Length >= 2 && int.TryParse(parts[1], out var mx)) { min = max; max = mx; }
if (min > max) (min, max) = (max, min);
var n = _rng.Next(min, max + 1);
items.Add(new LauncherItem($"{n}",
$"{min}~{max} 랜덤 숫자 · Enter 복사",
null, ("copy", n.ToString()), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 추가 결과 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 4; i++)
items.Add(new LauncherItem(_rng.Next(min, max + 1).ToString(),
$"{min}~{max}", null, null, Symbol: "\uE8D0"));
}
else
{
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
"rand / rand <n> / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin",
null, null, Symbol: "\uE783"));
}
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Rand", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string GetCharset(string name) => name switch
{
"alpha" or "알파" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
"num" or "숫자" => "0123456789",
"alnum" or "영숫자" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
"hex" => "0123456789abcdef",
"lower" => "abcdefghijklmnopqrstuvwxyz",
"upper" => "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"special" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*",
_ => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
};
private static string RandStr(int len, string charset)
{
var sb = new StringBuilder(len);
for (int i = 0; i < len; i++) sb.Append(charset[_rng.Next(charset.Length)]);
return sb.ToString();
}
private static bool TryParseDice(string s, out int count, out int sides)
{
count = sides = 0;
var d = s.IndexOf('d');
if (d < 0) return false;
var left = d == 0 ? "1" : s[..d];
var right = s[(d + 1)..];
return int.TryParse(left, out count) && count >= 1 && count <= 100 &&
int.TryParse(right, out sides) && sides >= 2 && sides <= 10000;
}
private static string RgbToHsl(int r, int g, int b)
{
var rf = r / 255.0; var gf = g / 255.0; var bf = b / 255.0;
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var l = (max + min) / 2;
if (max == min) return $"hsl(0, 0%, {l:P0})";
var d = max - min;
var s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
var h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0)
: max == gf ? (bf - rf) / d + 2
: (rf - gf) / d + 4;
h /= 6;
return $"hsl({h * 360:F0}, {s:P0}, {l:P0})";
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8D0");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,205 @@
using Microsoft.Win32;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다.
///
/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록
/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회
/// reg search DisplayName → 값 이름으로 검색 (제한적)
/// reg HKCU\...\Run → 실행 항목 조회
/// Enter → 값을 클립보드에 복사.
///
/// 쓰기/삭제 기능 없음 — 조회 전용.
/// </summary>
public class RegHandler : IActionHandler
{
public string? Prefix => "reg";
public PluginMetadata Metadata => new(
"Reg",
"레지스트리 조회 — HKCU · HKLM 키·값 빠른 조회",
"1.0",
"AX");
// 자주 쓰는 즐겨찾기 경로
private static readonly (string Label, string Path)[] Favorites =
[
("현재 사용자 Run", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
("모든 사용자 Run", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
("설치된 프로그램 (32bit)", @"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
("설치된 프로그램 (64bit)", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
("환경 변수 (사용자)", @"HKCU\Environment"),
("환경 변수 (시스템)", @"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
("Internet Explorer 설정", @"HKCU\SOFTWARE\Microsoft\Internet Explorer\Main"),
("Windows 테마", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes"),
("탐색기 설정", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("레지스트리 조회",
"예: reg HKCU\\Software\\Microsoft / reg HKLM\\SOFTWARE\\...",
null, null, Symbol: "\uE8BE"));
items.Add(new LauncherItem("── 즐겨찾기 ──", "", null, null, Symbol: "\uE8BE"));
foreach (var (label, path) in Favorites)
items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
if (sub == "search" || sub == "find")
{
var keyword = parts.Length > 1 ? parts[1] : "";
if (string.IsNullOrWhiteSpace(keyword))
{
items.Add(new LauncherItem("검색어 입력", "예: reg search DisplayName", null, null, Symbol: "\uE783"));
}
else
{
// 즐겨찾기 경로에서 해당 이름 검색
items.Add(new LauncherItem($"'{keyword}' 즐겨찾기에서 검색", "일치하는 경로:", null, null, Symbol: "\uE8BE"));
foreach (var (label, path) in Favorites.Where(f =>
f.Label.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
f.Path.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
{
items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 레지스트리 경로 조회
var regPath = q;
items.AddRange(QueryRegistry(regPath));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("Reg", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
break;
case ("query", string path):
// 이미 GetItemsAsync에서 처리됨 — 재조회용 트리거 (런처 재갱신 불가 시 알림만)
NotificationService.Notify("Reg", $"조회: {path.Split('\\').Last()}");
break;
}
return Task.CompletedTask;
}
// ── 레지스트리 조회 ──────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> QueryRegistry(string path)
{
var (hive, subKey) = SplitPath(path);
if (hive == null)
{
yield return new LauncherItem("형식 오류",
"HKCU 또는 HKLM으로 시작하는 경로를 입력하세요", null, null, Symbol: "\uE783");
yield break;
}
RegistryKey? key = null;
string? openError = null;
try { key = hive.OpenSubKey(subKey, writable: false); }
catch (Exception ex) { openError = ex.Message; }
if (openError != null)
{
yield return new LauncherItem("접근 오류", openError, null, null, Symbol: "\uE783");
yield break;
}
if (key == null)
{
yield return new LauncherItem("키 없음", $"'{path}' 키가 존재하지 않습니다", null, null, Symbol: "\uE946");
yield break;
}
using (key)
{
// 하위 키 목록
var subKeys = key.GetSubKeyNames();
if (subKeys.Length > 0)
{
yield return new LauncherItem($"하위 키 {subKeys.Length}개", path, null, null, Symbol: "\uE8BE");
foreach (var sk in subKeys.Take(10))
{
var fullPath = path.TrimEnd('\\') + @"\" + sk;
yield return new LauncherItem($"[{sk}]", fullPath, null, ("copy", fullPath), Symbol: "\uE8BE");
}
}
// 값 목록
var valueNames = key.GetValueNames();
if (valueNames.Length > 0)
{
yield return new LauncherItem($"── 값 {valueNames.Length}개 ──", "", null, null, Symbol: "\uE8BE");
foreach (var vn in valueNames.Take(20))
{
var val = key.GetValue(vn);
var valStr = FormatValue(val);
var display = string.IsNullOrEmpty(vn) ? "(기본값)" : vn;
yield return new LauncherItem(
display,
valStr.Length > 80 ? valStr[..80] + "…" : valStr,
null, ("copy", valStr), Symbol: "\uE8BE");
}
}
if (subKeys.Length == 0 && valueNames.Length == 0)
yield return new LauncherItem("빈 키", "하위 키와 값이 없습니다", null, null, Symbol: "\uE946");
}
}
private static (RegistryKey? Hive, string SubKey) SplitPath(string path)
{
path = path.Replace('/', '\\');
var sep = path.IndexOf('\\');
var hiveStr = sep >= 0 ? path[..sep].ToUpperInvariant() : path.ToUpperInvariant();
var sub = sep >= 0 ? path[(sep + 1)..] : "";
var hive = hiveStr switch
{
"HKCU" or "HKEY_CURRENT_USER" => Registry.CurrentUser,
"HKLM" or "HKEY_LOCAL_MACHINE" => Registry.LocalMachine,
"HKCR" or "HKEY_CLASSES_ROOT" => Registry.ClassesRoot,
"HKU" or "HKEY_USERS" => Registry.Users,
"HKCC" or "HKEY_CURRENT_CONFIG" => Registry.CurrentConfig,
_ => null,
};
return (hive, sub);
}
private static string FormatValue(object? val) => val switch
{
null => "(null)",
string s => s,
int i => $"{i} (0x{i:X8})",
long l => $"{l} (0x{l:X16})",
byte[] bytes => BitConverter.ToString(bytes).Replace("-", " "),
string[] arr => string.Join(" | ", arr),
_ => val.ToString() ?? "",
};
}

View File

@@ -0,0 +1,340 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다.
///
/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치
/// re /old/new/ → 치환 모드 (결과 클립보드 복사)
/// re patterns → 공통 패턴 라이브러리 표시
/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행)
/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사.
/// </summary>
public class RegexHandler : IActionHandler
{
public string? Prefix => "re";
public PluginMetadata Metadata => new(
"Regex",
"정규식 테스터 — 매치 · 치환 · 패턴 라이브러리",
"1.0",
"AX");
// ── 공통 패턴 라이브러리 ─────────────────────────────────────────────────
private static readonly (string Name, string Pattern, string Desc)[] CommonPatterns =
[
("이메일", @"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", " "),
("URL", @"https?://[^\s/$.?#].[^\s]*", "HTTP/HTTPS URL"),
("전화번호", @"0\d{1,2}-\d{3,4}-\d{4}", "한국 전화번호 (하이픈)"),
("날짜", @"\d{4}[-./]\d{1,2}[-./]\d{1,2}", "날짜 (YYYY-MM-DD 등)"),
("숫자", @"\d+(?:\.\d+)?", "정수 또는 소수"),
("한글", @"[가-힣]+", "한글 문자열"),
("영문", @"[a-zA-Z]+", "영문 문자열"),
("IP 주소", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "IPv4 주소"),
("16진수 색상", @"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b", "HEX 색상 코드"),
("빈 줄", @"^\s*$", "빈 줄 (멀티라인 모드 필요)"),
("HTML 태그", @"<[^>]+>", "HTML 태그"),
("JSON 키", @"""([^""]+)""\s*:", "JSON 키 이름"),
("UUID", @"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "UUID"),
("우편번호", @"\b\d{5}\b", "5자리 우편번호"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 안내 + 패턴 라이브러리 미리보기
items.Add(new LauncherItem(
"정규식 테스터",
"패턴 입력 후 Enter → 클립보드 텍스트에서 매치. /old/new/ 형식으로 치환",
null, null, Symbol: "\uE773"));
items.Add(new LauncherItem(
"re patterns",
"공통 패턴 라이브러리 표시",
null, ("show_patterns", ""), Symbol: "\uE8A4"));
foreach (var (name, pattern, desc) in CommonPatterns.Take(5))
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "patterns" 서브커맨드
if (q.Equals("patterns", StringComparison.OrdinalIgnoreCase))
{
foreach (var (name, pattern, desc) in CommonPatterns)
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 공통 패턴 이름 검색
var matchedPatterns = CommonPatterns
.Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
p.Desc.Contains(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matchedPatterns.Count > 0)
{
foreach (var (name, pattern, desc) in matchedPatterns)
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
}
// 치환 모드 /old/new/
if (q.StartsWith('/') && q.Length > 2)
{
var parts = q.Split('/', StringSplitOptions.None);
// /old/new/ → parts = ["", "old", "new", ""]
if (parts.Length >= 3)
{
var oldPat = parts[1];
var newStr = parts.Length >= 3 ? parts[2] : "";
var flags = parts.Length >= 4 ? parts[3] : "";
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(oldPat))
{
try
{
var opts = BuildOptions(flags);
var result = Regex.Replace(clipText, oldPat, newStr, opts);
var changed = result != clipText;
items.Add(new LauncherItem(
changed ? "치환 완료 — 클립보드 복사" : "치환 없음 (패턴 불일치)",
result.Length > 120 ? result[..120] + "…" : result,
null,
("replace_result", result),
Symbol: "\uE8AC"));
}
catch (Exception ex)
{
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
}
}
else
{
items.Add(new LauncherItem(
$"치환: /{oldPat}/ → {newStr}",
"클립보드에 텍스트를 복사한 뒤 실행하세요",
null, ("replace_pattern", oldPat, newStr), Symbol: "\uE8AC"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 플래그 처리: "flags:im pattern"
string pattern2 = q;
string flagStr = "";
if (q.StartsWith("flags:", StringComparison.OrdinalIgnoreCase))
{
var spaceIdx = q.IndexOf(' ');
if (spaceIdx > 0)
{
flagStr = q[6..spaceIdx];
pattern2 = q[(spaceIdx + 1)..].Trim();
}
}
// 매치 모드
if (!string.IsNullOrEmpty(pattern2))
{
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText))
{
try
{
var opts = BuildOptions(flagStr);
var matches = Regex.Matches(clipText, pattern2, opts);
if (matches.Count == 0)
{
items.Add(new LauncherItem(
"매치 없음",
$"패턴 [{pattern2}]이 클립보드 텍스트와 일치하지 않습니다",
null, null, Symbol: "\uE783"));
}
else
{
// 요약 항목
items.Add(new LauncherItem(
$"{matches.Count}개 매치됨",
$"패턴: {pattern2} | 전체 복사: Enter",
null,
("all_matches", string.Join("\n", matches.Cast<Match>().Select(m => m.Value))),
Symbol: "\uE773"));
// 개별 매치 항목 (최대 15개)
foreach (Match m in matches.Cast<Match>().Take(15))
{
var groupInfo = m.Groups.Count > 1
? " · 그룹: " + string.Join(", ", m.Groups.Cast<Group>().Skip(1).Select(g => g.Value))
: "";
items.Add(new LauncherItem(
m.Value,
$"위치 {m.Index}{groupInfo}",
null,
("single_match", m.Value),
Symbol: "\uE773"));
}
if (matches.Count > 15)
items.Add(new LauncherItem(
$"… +{matches.Count - 15}개 더",
"전체 보기: 첫 번째 항목 Enter",
null, null, Symbol: "\uE712"));
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
}
}
else
{
// 클립보드 없음 → 패턴만 표시
items.Add(new LauncherItem(
$"패턴: {pattern2}",
"클립보드에 텍스트를 복사한 뒤 실행하세요",
null,
("pattern_apply", pattern2),
Symbol: "\uE773"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("all_matches", string copyText1):
TryCopyToClipboard(copyText1);
break;
case ("replace_result", string copyText2):
TryCopyToClipboard(copyText2);
break;
case ("single_match", string copyText3):
TryCopyToClipboard(copyText3);
break;
case ("pattern_apply", string pattern):
// 패턴을 클립보드에 복사 (또는 클립보드 텍스트에 즉시 적용)
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText))
{
try
{
var matches = Regex.Matches(clipText, pattern);
if (matches.Count > 0)
{
var result = string.Join("\n", matches.Cast<Match>().Select(m => m.Value));
TryCopyToClipboard(result);
NotificationService.Notify("Regex", $"{matches.Count}개 매치 복사됨");
}
else
{
NotificationService.Notify("Regex", "매치 없음");
}
}
catch
{
TryCopyToClipboard(pattern);
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
}
}
else
{
TryCopyToClipboard(pattern);
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
}
break;
case ("replace_pattern", string oldPat, string newStr):
var src = GetClipboardText();
if (!string.IsNullOrEmpty(src))
{
try
{
var replaced = Regex.Replace(src, oldPat, newStr);
TryCopyToClipboard(replaced);
NotificationService.Notify("Regex", "치환 결과를 클립보드에 복사했습니다");
}
catch (Exception ex)
{
NotificationService.Notify("Regex", $"오류: {ex.Message}");
}
}
break;
case ("show_patterns", _):
// 패턴 라이브러리 목록 표시 — 런처 입력창에 "re patterns" 입력
var launcher = System.Windows.Application.Current?.Windows
.OfType<Views.LauncherWindow>()
.FirstOrDefault();
launcher?.SetInputText("re patterns ");
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static RegexOptions BuildOptions(string flags)
{
var opts = RegexOptions.None;
if (flags.Contains('i', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.IgnoreCase;
if (flags.Contains('m', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Multiline;
if (flags.Contains('s', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Singleline;
return opts;
}
private static string? GetClipboardText()
{
try
{
string? text = null;
System.Windows.Application.Current.Dispatcher.Invoke(
() => text = Clipboard.ContainsText() ? Clipboard.GetText() : null);
return text;
}
catch { return null; }
}
private static void TryCopyToClipboard(string text)
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,288 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다.
///
/// 예: remind → 오늘 등록된 알림 목록
/// remind 15:00 보고서 제출 → 오후 3시에 알림
/// remind 오후3시 팀장보고 → 한국어 시각 파싱
/// remind del 1 → 1번 알림 취소
/// remind clear → 지난 알림 정리
/// </summary>
public class RemindHandler : IActionHandler
{
public string? Prefix => "remind";
public PluginMetadata Metadata => new(
"알림",
"오늘 특정 시각 알림 — 시간 설정 · 취소 · 목록",
"1.0",
"AX");
private sealed class RemindEntry
{
public int Id { get; set; }
public DateTime Time { get; set; }
public string Message { get; set; } = "";
public bool Fired { get; set; }
public CancellationTokenSource? Cts { get; set; }
}
private static readonly List<RemindEntry> _reminders = [];
private static readonly Dictionary<int, CancellationTokenSource> _ctsDic = [];
private static readonly object _lock = new();
private static int _nextId = 1;
// 외부(TodayHandler)에서 오늘 알림 목록 조회용
internal static List<(DateTime Time, string Message)> GetTodayReminders()
{
var today = DateTime.Today;
lock (_lock)
{
return _reminders
.Where(r => !r.Fired && r.Time.Date == today)
.Select(r => (r.Time, r.Message))
.OrderBy(r => r.Time)
.ToList();
}
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
List<RemindEntry> pending;
lock (_lock)
pending = _reminders.Where(r => !r.Fired).OrderBy(r => r.Time).ToList();
if (pending.Count == 0)
{
items.Add(new LauncherItem("등록된 알림 없음",
"remind HH:mm 메시지 또는 remind 오후3시 메시지",
null, null, Symbol: "\uE787"));
}
else
{
items.Add(new LauncherItem($"알림 {pending.Count}개",
"remind <시각> <메시지> / remind del <번호>",
null, null, Symbol: "\uE787"));
foreach (var r in pending)
{
var left = r.Time > DateTime.Now
? FormatLeft(r.Time - DateTime.Now)
: "시간 지남";
items.Add(new LauncherItem(
$"#{r.Id} {r.Time:HH:mm} — {r.Message}",
$"남은 시간: {left} · Enter로 취소",
null, ("del", r.Id.ToString()), Symbol: "\uE787"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// del 명령
if (sub is "del" or "cancel" or "취소")
{
var idStr = parts.Length > 1 ? parts[1].Trim() : "";
if (!int.TryParse(idStr, out var delId))
{
items.Add(new LauncherItem("번호를 입력하세요",
"예: remind del 1",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
RemindEntry? target;
lock (_lock) target = _reminders.FirstOrDefault(r => r.Id == delId);
if (target == null)
items.Add(new LauncherItem($"#{delId} 알림을 찾을 수 없습니다", "",
null, null, Symbol: "\uE783"));
else
items.Add(new LauncherItem(
$"#{delId} 알림 취소: {target.Time:HH:mm} {target.Message}",
"Enter로 취소합니다",
null, ("del", delId.ToString()), Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// clear 명령
if (sub == "clear")
{
items.Add(new LauncherItem("지난 알림 정리",
"발화된 알림을 목록에서 제거합니다 · Enter 실행",
null, ("clear", ""), Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 시각 파싱 시도
var (timeStr, msgStr) = SplitTimeAndMessage(q);
if (!string.IsNullOrWhiteSpace(timeStr) && TryParseTime(timeStr, out var alarmTime))
{
var leftText = alarmTime > DateTime.Now
? FormatLeft(alarmTime - DateTime.Now)
: "이미 지난 시각 (내일로 설정됩니다)";
var message = string.IsNullOrWhiteSpace(msgStr) ? "(메시지 없음)" : msgStr;
var encoded = $"{alarmTime:O}|{message}";
items.Add(new LauncherItem(
$"알림 설정: {alarmTime:HH:mm} — {message}",
$"{leftText} · Enter로 설정",
null, ("set", encoded), Symbol: "\uE787"));
}
else
{
items.Add(new LauncherItem($"시각 파싱 실패: '{q}'",
"예: remind 15:00 보고서 제출 / remind 오후3시 팀장보고",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("set", string encoded):
{
var idx = encoded.IndexOf('|');
if (idx < 0) break;
var timeIso = encoded[..idx];
var message = encoded[(idx + 1)..];
if (!DateTime.TryParse(timeIso, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var alarmTime))
break;
var cts = new CancellationTokenSource();
int id;
lock (_lock)
{
id = _nextId++;
var entry = new RemindEntry { Id = id, Time = alarmTime, Message = message, Cts = cts };
_reminders.Add(entry);
_ctsDic[id] = cts;
}
NotificationService.Notify("알림", $"#{id} {alarmTime:HH:mm} {message} 설정됨");
_ = RunReminderAsync(id, alarmTime, message, cts.Token);
break;
}
case ("del", string idStr) when int.TryParse(idStr, out var delId):
{
RemindEntry? entry;
lock (_lock)
{
entry = _reminders.FirstOrDefault(r => r.Id == delId);
if (entry != null) _reminders.Remove(entry);
if (_ctsDic.TryGetValue(delId, out var c)) { c.Cancel(); _ctsDic.Remove(delId); }
}
if (entry != null)
NotificationService.Notify("알림", $"#{delId} 알림 취소됨");
break;
}
case ("clear", _):
{
int cleared;
lock (_lock)
{
cleared = _reminders.RemoveAll(r => r.Fired);
}
NotificationService.Notify("알림", $"지난 알림 {cleared}개 정리됨");
break;
}
}
return Task.CompletedTask;
}
// ── 알림 실행 ────────────────────────────────────────────────────────────
private static async Task RunReminderAsync(int id, DateTime at, string message, CancellationToken token)
{
try
{
var delay = at - DateTime.Now;
if (delay > TimeSpan.Zero)
await Task.Delay(delay, token);
lock (_lock)
{
var entry = _reminders.FirstOrDefault(r => r.Id == id);
if (entry != null) entry.Fired = true;
_ctsDic.Remove(id);
}
NotificationService.Notify("⏰ 알림", $"{at:HH:mm} {message}");
}
catch (OperationCanceledException) { }
}
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
private static (string timeStr, string message) SplitTimeAndMessage(string q)
{
var sp = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (sp.Length == 0) return ("", "");
return (sp[0], sp.Length > 1 ? sp[1] : "");
}
private static bool TryParseTime(string s, out DateTime result)
{
result = DateTime.MinValue;
s = s.Trim();
// HH:mm 또는 H:mm
if (System.Text.RegularExpressions.Regex.IsMatch(s, @"^\d{1,2}:\d{2}$"))
{
var colonParts = s.Split(':');
if (int.TryParse(colonParts[0], out var h) &&
int.TryParse(colonParts[1], out var m) &&
h is >= 0 and <= 23 && m is >= 0 and <= 59)
{
result = DateTime.Today.AddHours(h).AddMinutes(m);
if (result <= DateTime.Now) result = result.AddDays(1);
return true;
}
}
// 한국어 시각: 오전N시M분, 오후N시M분, N시M분, 오전N시, 오후N시, N시
var korMatch = System.Text.RegularExpressions.Regex.Match(s,
@"^(오전|오후)?(\d{1,2})시((\d{1,2})분)?$");
if (korMatch.Success)
{
var meridiem = korMatch.Groups[1].Value;
if (!int.TryParse(korMatch.Groups[2].Value, out var h)) return false;
var minStr = korMatch.Groups[4].Value;
var m = string.IsNullOrEmpty(minStr) ? 0 : int.TryParse(minStr, out var mp) ? mp : 0;
if (meridiem == "오후" && h < 12) h += 12;
if (meridiem == "오전" && h == 12) h = 0;
if (h is < 0 or > 23 || m is < 0 or > 59) return false;
result = DateTime.Today.AddHours(h).AddMinutes(m);
if (result <= DateTime.Now) result = result.AddDays(1);
return true;
}
return false;
}
private static string FormatLeft(TimeSpan ts)
{
if (ts.TotalSeconds < 60)
return $"{(int)ts.TotalSeconds}초 후";
if (ts.TotalMinutes < 60)
return $"{(int)ts.TotalMinutes}분 후";
var h = (int)ts.TotalHours;
var m = (int)(ts.TotalMinutes - h * 60);
return m > 0 ? $"{h}시간 {m}분 후" : $"{h}시간 후";
}
}

View File

@@ -0,0 +1,171 @@
using System.IO;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다.
///
/// 예: sched → 등록된 스케줄 목록
/// sched 이름 → 이름으로 필터
/// sched new → 새 스케줄 편집기 열기
/// sched edit 이름 → 기존 스케줄 편집
/// sched del 이름 → 스케줄 삭제
/// sched toggle 이름 → 활성/비활성 전환 (Enter)
/// </summary>
public class ScheduleHandler : IActionHandler
{
private readonly SettingsService _settings;
public ScheduleHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "sched";
public PluginMetadata Metadata => new(
"Scheduler",
"자동화 스케줄 — sched",
"1.0",
"AX");
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
// "new" — 새 스케줄
if (cmd == "new")
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("새 스케줄 만들기",
"편집기에서 트리거 시각과 실행 액션을 설정합니다",
null, "__new__", Symbol: "\uE710")
});
}
// "edit 이름"
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 스케줄 편집", "편집기 열기",
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
});
}
// "del 이름" or "delete 이름"
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 스케줄 삭제",
"Enter로 삭제 확인",
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
});
}
// 목록 표시
var schedules = _settings.Settings.Schedules;
var filter = q.ToLowerInvariant();
var items = new List<LauncherItem>();
foreach (var s in schedules)
{
if (!string.IsNullOrEmpty(filter) &&
!s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var nextRun = SchedulerService.ComputeNextRun(s);
var nextStr = nextRun.HasValue ? nextRun.Value.ToString("MM/dd HH:mm") : "─";
var trigger = SchedulerService.TriggerLabel(s);
var symbol = s.Enabled ? "\uE916" : "\uE8D8"; // 타이머 / 멈춤
var actionIcon = s.ActionType == "notification" ? "🔔" : "▶";
var actionName = s.ActionType == "notification"
? s.ActionTarget
: Path.GetFileNameWithoutExtension(s.ActionTarget);
var subtitle = s.Enabled
? $"{trigger} {s.TriggerTime} · {actionIcon} {actionName} · 다음: {nextStr}"
: $"[비활성] {trigger} {s.TriggerTime} · {actionIcon} {actionName}";
items.Add(new LauncherItem(
s.Name, subtitle, null, s, Symbol: symbol));
}
if (items.Count == 0 && string.IsNullOrEmpty(filter))
{
items.Add(new LauncherItem(
"등록된 스케줄 없음",
"'sched new'로 자동화 스케줄을 추가하세요",
null, null, Symbol: Symbols.Info));
}
items.Add(new LauncherItem(
"새 스케줄 만들기",
"sched new · 시각·요일 기반 앱 실행 / 알림 자동화",
null, "__new__", Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s)
{
if (s == "__new__")
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.ScheduleEditorWindow(null, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__edit__"))
{
var name = s["__edit__".Length..];
var entry = _settings.Settings.Schedules
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.ScheduleEditorWindow(entry, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__del__"))
{
var name = s["__del__".Length..];
var entry = _settings.Settings.Schedules
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
_settings.Settings.Schedules.Remove(entry);
_settings.Save();
NotificationService.Notify("AX Copilot", $"스케줄 '{name}' 삭제됨");
}
return Task.CompletedTask;
}
}
// 스케줄 항목 Enter → 활성/비활성 토글
if (item.Data is ScheduleEntry se)
{
se.Enabled = !se.Enabled;
_settings.Save();
var state = se.Enabled ? "활성화" : "비활성화";
NotificationService.Notify("AX Copilot", $"스케줄 '{se.Name}' {state}됨");
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,301 @@
using System.IO;
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다.
///
/// 예: session → 저장된 세션 목록
/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용)
/// session new 이름 → 새 세션 편집기 열기
/// session edit 이름 → 기존 세션 편집기 열기
/// session del 이름 → 세션 삭제
///
/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다.
/// </summary>
public class SessionHandler : IActionHandler
{
private readonly SettingsService _settings;
public SessionHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "session";
public PluginMetadata Metadata => new(
"AppSession",
"앱 세션 스냅 — session",
"1.0",
"AX");
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
// "new [이름]" — 새 세션 만들기
if (cmd == "new")
{
var name = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "새 세션";
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{name}' 세션 만들기",
"편집기에서 앱 목록과 스냅 레이아웃을 설정합니다",
null, $"__new__{name}", Symbol: "\uE710")
});
}
// "edit 이름" — 기존 세션 편집
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 세션 편집",
"편집기 열기",
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
});
}
// "del 이름" or "delete 이름" — 세션 삭제
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 세션 삭제",
"Enter로 삭제 확인 (되돌릴 수 없습니다)",
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
});
}
// 세션 목록 (필터 적용)
var sessions = _settings.Settings.AppSessions;
var filter = q.ToLowerInvariant();
var items = new List<LauncherItem>();
foreach (var s in sessions)
{
if (!string.IsNullOrEmpty(filter) &&
!s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!s.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var appNames = string.Join(", ",
s.Apps.Take(3).Select(a =>
string.IsNullOrEmpty(a.Label)
? Path.GetFileNameWithoutExtension(a.Path)
: a.Label));
items.Add(new LauncherItem(
s.Name,
$"{s.Apps.Count}개 앱 · {appNames}",
null, s,
Symbol: "\uE8A1")); // 창 레이아웃 아이콘
}
if (items.Count == 0 && string.IsNullOrEmpty(filter))
{
items.Add(new LauncherItem(
"저장된 세션 없음",
"'session new 이름'으로 앱 세션을 만드세요",
null, null,
Symbol: Symbols.Info));
}
// 새 세션 만들기 (항상 표시)
items.Add(new LauncherItem(
"새 세션 만들기",
"session new [이름] · 앱 목록 + 스냅 레이아웃 지정",
null, "__new__새 세션",
Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s)
{
if (s.StartsWith("__new__"))
{
var name = s["__new__".Length..];
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.SessionEditorWindow(null, _settings);
win.InitialName = name;
win.Show();
});
return;
}
if (s.StartsWith("__edit__"))
{
var name = s["__edit__".Length..];
var session = _settings.Settings.AppSessions
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.SessionEditorWindow(session, _settings);
win.Show();
});
return;
}
if (s.StartsWith("__del__"))
{
var name = s["__del__".Length..];
var session = _settings.Settings.AppSessions
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (session != null)
{
_settings.Settings.AppSessions.Remove(session);
_settings.Save();
NotificationService.Notify("AX Copilot", $"세션 '{name}' 삭제됨");
}
return;
}
}
if (item.Data is Models.AppSession appSession)
await LaunchSessionAsync(appSession, ct);
}
// ─── 세션 실행 로직 ───────────────────────────────────────────────────
private static async Task LaunchSessionAsync(Models.AppSession session, CancellationToken ct)
{
NotificationService.Notify("AX Copilot", $"'{session.Name}' 세션 시작...");
LogService.Info($"세션 실행 시작: {session.Name} ({session.Apps.Count}개 앱)");
int launched = 0, failed = 0;
foreach (var app in session.Apps)
{
ct.ThrowIfCancellationRequested();
if (app.DelayMs > 0)
await Task.Delay(app.DelayMs, ct);
if (string.IsNullOrWhiteSpace(app.Path)) continue;
System.Diagnostics.Process? proc = null;
try
{
proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = app.Path,
Arguments = app.Arguments ?? "",
UseShellExecute = true
});
launched++;
}
catch (Exception ex)
{
LogService.Warn($"세션 앱 실행 실패: {app.Path} — {ex.Message}");
failed++;
continue;
}
// 스냅 위치 없거나 none → 그냥 실행
if (proc == null || string.IsNullOrEmpty(app.SnapPosition) || app.SnapPosition == "none")
continue;
// 창이 나타날 때까지 대기 (최대 6초)
var hWnd = IntPtr.Zero;
var deadline = DateTime.UtcNow.AddSeconds(6);
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
try { proc.Refresh(); } catch { break; }
hWnd = proc.MainWindowHandle;
if (hWnd != IntPtr.Zero) break;
await Task.Delay(200, ct);
}
if (hWnd == IntPtr.Zero)
{
LogService.Warn($"세션: 창 핸들 획득 실패 ({app.Path})");
continue;
}
// 창이 완전히 렌더링될 시간 허용
await Task.Delay(250, ct);
ApplySnapToWindow(hWnd, app.SnapPosition);
}
var msg = failed > 0
? $"'{session.Name}' 실행 완료 ({launched}개 성공, {failed}개 실패)"
: $"'{session.Name}' 실행 완료 ({launched}개 앱)";
NotificationService.Notify("AX Copilot", msg);
LogService.Info($"세션 실행 완료: {msg}");
}
// ─── 스냅 적용 (SnapHandler와 동일한 좌표 계산) ──────────────────────
private static void ApplySnapToWindow(IntPtr hwnd, string snapKey)
{
if (snapKey == "full")
{
ShowWindow(hwnd, SW_MAXIMIZE);
return;
}
var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(hMonitor, ref mi)) return;
var w = mi.rcWork;
int mw = w.right - w.left;
int mh = w.bottom - w.top;
int mx = w.left;
int my = w.top;
var (x, y, cw, ch) = snapKey switch
{
"left" => (mx, my, mw / 2, mh),
"right" => (mx + mw / 2, my, mw / 2, mh),
"top" => (mx, my, mw, mh / 2),
"bottom" => (mx, my + mh / 2, mw, mh / 2),
"tl" => (mx, my, mw / 2, mh / 2),
"tr" => (mx + mw / 2, my, mw / 2, mh / 2),
"bl" => (mx, my + mh / 2, mw / 2, mh / 2),
"br" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2),
"third-l" => (mx, my, mw / 3, mh),
"third-c" => (mx + mw / 3, my, mw / 3, mh),
"third-r" => (mx + mw * 2 / 3,my, mw / 3, mh),
"two3-l" => (mx, my, mw * 2 / 3, mh),
"two3-r" => (mx + mw / 3, my, mw * 2 / 3, mh),
"center" => (mx + mw / 10, my + mh / 10, mw * 8 / 10, mh * 8 / 10),
_ => (mx, my, mw, mh)
};
ShowWindow(hwnd, SW_RESTORE);
SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER);
}
// ─── P/Invoke ─────────────────────────────────────────────────────────
private const uint SWP_SHOWWINDOW = 0x0040;
private const uint SWP_NOZORDER = 0x0004;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
private const int SW_RESTORE = 9;
private const int SW_MAXIMIZE = 3;
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor, rcWork;
public uint dwFlags;
}
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("user32.dll")] private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
}

View File

@@ -0,0 +1,408 @@
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L24-3: 자주 틀리는 한국어 맞춤법 핸들러. "spell" 프리픽스로 사용합니다.
///
/// 예: spell → 클립보드 텍스트 맞춤법 검사
/// spell 되 → "되/돼" 관련 오류 목록
/// spell <단어> → 해당 단어 맞춤법 확인
/// spell list → 전체 오류 목록 카테고리
/// Enter → 올바른 표현 클립보드 복사
/// </summary>
public class SpellHandler : IActionHandler
{
public string? Prefix => "spell";
public PluginMetadata Metadata => new(
"맞춤법",
"자주 틀리는 한국어 맞춤법 참조 — 되/돼·안/않·혼동어·외래어 등",
"1.0",
"AX");
private sealed record SpellEntry(string Wrong, string Correct, string Explanation, string Category);
private static readonly SpellEntry[] Entries =
[
// ── 되/돼 ────────────────────────────────────────────────────────────
new("됬다", "됐다", "됐다(되었다의 준말)", "되/돼"),
new("됬어", "됐어", "됐어(되었어의 준말)", "되/돼"),
new("됬나요", "됐나요", "됐나요(되었나요의 준말)", "되/돼"),
new("됬습니다", "됐습니다", "됐습니다(되었습니다의 준말)", "되/돼"),
new("돼었다", "됐다", "됐다(되었다의 준말)", "되/돼"),
new("안됬어", "안 됐어", "됐다(되었다의 준말), 안은 띄어씀", "되/돼"),
new("어떻게됬나요", "어떻게 됐나요","됐나요(되었나요의 준말)", "되/돼"),
new("잘됬다", "잘됐다", "잘됐다(잘 되었다의 준말)", "되/돼"),
new("해됬다", "해 됐다", "됐다(되었다의 준말)", "되/돼"),
new("이렇게됬다", "이렇게 됐다", "됐다(되었다의 준말), 띄어씀 주의", "되/돼"),
// ── 안/않 ────────────────────────────────────────────────────────────
new("안되다", "안 되다", "'안'은 부사이므로 띄어씀", "안/않"),
new("안하다", "안 하다", "'안'은 부사이므로 띄어씀", "안/않"),
new("않되다", "안 되다", "'않다'는 '아니하다'의 준말로 용언", "안/않"),
new("하지않다", "하지 않다", "'않다'는 용언, 앞 단어와 띄어씀", "안/않"),
new("하지않고", "하지 않고", "'않고'는 용언, 앞 단어와 띄어씀", "안/않"),
new("하지않아", "하지 않아", "'않아'는 용언, 앞 단어와 띄어씀", "안/않"),
new("되지않다", "되지 않다", "'않다'는 용언, 앞 단어와 띄어씀", "안/않"),
new("않됩니다", "안 됩니다", "'않다'는 '아니하다', '안'은 부사", "안/않"),
// ── 혼동어 ──────────────────────────────────────────────────────────
new("로서/로써", "로서(자격), 로써(수단)", "의미 구분: 학생으로서(자격), 말로써(수단)", "혼동어"),
new("낫다/낳다", "낫다(회복), 낳다(출산)", "의미 구분: 병이 낫다 / 아이를 낳다", "혼동어"),
new("맞히다/맞추다","맞히다(정답), 맞추다(조합)","의미 구분: 문제를 맞히다 / 퍼즐을 맞추다", "혼동어"),
new("반드시/반듯이","반드시(꼭), 반듯이(곧게)", "의미 구분: 반드시 와라 / 반듯이 서라", "혼동어"),
new("부치다/붙이다","부치다(편지), 붙이다(접착)","의미 구분: 편지를 부치다 / 우표를 붙이다", "혼동어"),
new("이따가/있다가","이따가(나중에), 있다가(머물다가)","의미 구분: 이따가 와라 / 집에 있다가 나와", "혼동어"),
new("웬/왠", "웬(어떤), 왠지(왜인지)", "의미 구분: 웬 일이니 / 왠지 모르게", "혼동어"),
new("어떻게/어떡해","어떻게(방법), 어떡해(어떻게 해)","의미 구분: 어떻게 해야 하나 / 이걸 어떡해", "혼동어"),
new("바라다/바래다","바라다(희망), 바래다(색이 변함)","의미 구분: 합격을 바란다 / 색이 바랬다", "혼동어"),
new("~던지/~든지", "~던지(과거경험), ~든지(선택)","의미 구분: 얼마나 좋았던지 / 뭐든지 해라", "혼동어"),
new("틀리다/다르다","틀리다(오답), 다르다(차이)", "의미 구분: 답이 틀렸다 / 나와 다르다", "혼동어"),
// ── 맞춤법 ──────────────────────────────────────────────────────────
new("설레임", "설렘", "표준어는 '설렘'", "맞춤법"),
new("오랫만에", "오랜만에", "표준어는 '오랜만에'", "맞춤법"),
new("왠만하면", "웬만하면", "표준어는 '웬만하면'", "맞춤법"),
new("몇일", "며칠", "표준어는 '며칠'", "맞춤법"),
new("어의없다", "어이없다", "표준어는 '어이없다'", "맞춤법"),
new("내노라하는", "내로라하는", "표준어는 '내로라하는'", "맞춤법"),
new("금새", "금세", "표준어는 '금세' ('금시에'의 준말)", "맞춤법"),
new("새벽녁", "새벽녘", "표준어는 '새벽녘'", "맞춤법"),
new("무릎쓰고", "무릅쓰고", "표준어는 '무릅쓰고'", "맞춤법"),
new("짜집기", "짜깁기", "표준어는 '짜깁기'", "맞춤법"),
new("역활", "역할", "표준어는 '역할'", "맞춤법"),
new("희안하다", "희한하다", "표준어는 '희한하다'", "맞춤법"),
new("요컨데", "요컨대", "표준어는 '요컨대'", "맞춤법"),
new("알맞는", "알맞은", "형용사이므로 '알맞은'이 맞음", "맞춤법"),
new("예쁘다/이쁘다","예쁘다가 표준어","'이쁘다'는 비표준어", "맞춤법"),
new("이따위", "이따위", "표준어 (맞음)", "맞춤법"),
new("구렛나루", "구레나룻", "표준어는 '구레나룻'", "맞춤법"),
new("꼭두각시", "꼭두각시", "표준어 (맞음)", "맞춤법"),
// ── 띄어쓰기 ────────────────────────────────────────────────────────
new("할수있다", "할 수 있다", "의존명사 '수'는 띄어씀", "띄어쓰기"),
new("해야한다", "해야 한다", "'한다'는 독립 서술어, 띄어씀", "띄어쓰기"),
new("것같다", "것 같다", "의존명사 '것'은 띄어씀", "띄어쓰기"),
new("수밖에없다", "수밖에 없다", "'없다'는 독립 서술어, 띄어씀", "띄어쓰기"),
new("한번", "한 번(횟수), 한번(시도)","횟수=한 번 / 시도=한번", "띄어쓰기"),
new("이상한거같다", "이상한 것 같다","'것'은 의존명사로 띄어씀", "띄어쓰기"),
new("될것같다", "될 것 같다", "'것'은 의존명사로 띄어씀", "띄어쓰기"),
new("할것이다", "할 것이다", "'것'은 의존명사로 띄어씀", "띄어쓰기"),
new("있을때", "있을 때", "'때'는 의존명사로 띄어씀", "띄어쓰기"),
new("모를때", "모를 때", "'때'는 의존명사로 띄어씀", "띄어쓰기"),
// ── 외래어 ──────────────────────────────────────────────────────────
new("리더쉽", "리더십", "표준 외래어: ~ship은 '십'으로", "외래어"),
new("멤버쉽", "멤버십", "표준 외래어: ~ship은 '십'으로", "외래어"),
new("파트너쉽", "파트너십", "표준 외래어: ~ship은 '십'으로", "외래어"),
new("인턴쉽", "인턴십", "표준 외래어: ~ship은 '십'으로", "외래어"),
new("메세지", "메시지", "표준 외래어: message → 메시지", "외래어"),
new("써비스", "서비스", "표준 외래어: service → 서비스", "외래어"),
new("케릭터", "캐릭터", "표준 외래어: character → 캐릭터", "외래어"),
new("컨텐츠", "콘텐츠", "표준 외래어: contents → 콘텐츠", "외래어"),
new("엑기스", "엑스", "표준 외래어: extract → 엑기스 (비표준)","외래어"),
new("렌트카", "렌터카", "표준 외래어: rent-a-car → 렌터카", "외래어"),
new("쥬스", "주스", "표준 외래어: juice → 주스", "외래어"),
new("후라이", "프라이", "표준 외래어: fry → 프라이", "외래어"),
new("후라이팬", "프라이팬", "표준 외래어: frying pan → 프라이팬", "외래어"),
new("비스켓", "비스킷", "표준 외래어: biscuit → 비스킷", "외래어"),
new("떼레비", "텔레비전", "표준 외래어: television → 텔레비전", "외래어"),
];
private static readonly string[] CategoryOrder = ["되/돼", "안/않", "혼동어", "맞춤법", "띄어쓰기", "외래어", "사용자"];
// ─── 사용자 정의 항목 (L29-3) ────────────────────────────────────────────
private sealed record CustomSpell(
[property: JsonPropertyName("wrong")] string Wrong,
[property: JsonPropertyName("correct")] string Correct,
[property: JsonPropertyName("desc")] string Desc);
private static readonly string CustomPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "spell_custom.json");
private static readonly JsonSerializerOptions JsonOpt = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static List<CustomSpell> LoadCustom()
{
try
{
if (!File.Exists(CustomPath)) return [];
return JsonSerializer.Deserialize<List<CustomSpell>>(File.ReadAllText(CustomPath)) ?? [];
}
catch { return []; }
}
private static void SaveCustom(List<CustomSpell> list)
{
try
{
var dir = Path.GetDirectoryName(CustomPath)!;
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(CustomPath, JsonSerializer.Serialize(list, JsonOpt));
}
catch { }
}
private IEnumerable<SpellEntry> AllEntries()
{
foreach (var e in Entries) yield return e;
foreach (var c in LoadCustom())
yield return new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자");
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// ── add 명령 (L29-3) ─────────────────────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var parts = q[4..].Trim().Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
items.Add(new LauncherItem("사용법: spell add {틀린표현} {올바른표현} [설명]",
"예: spell add 어의없다 어이없다 어이(기가 막혀)가 올바른 표현",
null, null, Symbol: "\uE710"));
}
else
{
var wrong = parts[0];
var correct = parts[1];
var desc = parts.Length >= 3 ? parts[2] : "사용자 추가 항목";
items.Add(new LauncherItem(
$"맞춤법 추가: ❌ {wrong} → ✅ {correct}",
desc,
null, ("add", $"{wrong}\t{correct}\t{desc}"), Symbol: "\uE710"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del 명령 (L29-3) ─────────────────────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var wrong = q[4..].Trim();
var custom = LoadCustom();
var found = custom.FirstOrDefault(c => c.Wrong.Equals(wrong, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
items.Add(new LauncherItem($"사용자 항목 삭제: {found.Wrong} → {found.Correct}",
found.Desc, null, ("del", found.Wrong), Symbol: "\uE74D"));
}
else
{
items.Add(new LauncherItem($"'{wrong}' 사용자 항목을 찾을 수 없습니다",
"기본 항목은 삭제할 수 없습니다", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── custom 명령 (사용자 항목만 보기) ──────────────────────────────────
if (q.Equals("custom", StringComparison.OrdinalIgnoreCase) ||
q.Equals("사용자", StringComparison.OrdinalIgnoreCase))
{
var custom = LoadCustom();
if (custom.Count == 0)
{
items.Add(new LauncherItem("사용자 항목이 없습니다",
"spell add {틀린표현} {올바른표현} [설명] 으로 추가하세요",
null, null, Symbol: "\uE7BA"));
}
else
{
items.Add(new LauncherItem($"사용자 맞춤법 항목 {custom.Count}개", "",
null, null, Symbol: "\uE7BA"));
foreach (var c in custom)
items.Add(MakeSpellItem(new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자")));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드 텍스트 맞춤법 검사
string clipText = "";
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipText = Clipboard.GetText();
});
}
catch { }
if (!string.IsNullOrWhiteSpace(clipText))
{
var found = AllEntries().Where(e =>
clipText.Contains(e.Wrong, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count > 0)
{
items.Add(new LauncherItem($"클립보드에서 맞춤법 오류 {found.Count}개 발견",
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
foreach (var e in found)
items.Add(MakeSpellItem(e));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
else
{
items.Add(new LauncherItem("클립보드 텍스트에서 오류를 찾지 못했습니다",
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
null, null, Symbol: "\uE7BA"));
}
}
else
{
items.Add(new LauncherItem($"한국어 맞춤법 참조 {Entries.Length}개",
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
null, null, Symbol: "\uE7BA"));
}
// 카테고리 목록
foreach (var cat in CategoryOrder)
{
var cnt = Entries.Count(e => e.Category == cat);
items.Add(new LauncherItem($"spell {cat}", $"{cat} ({cnt}개)",
null, null, Symbol: "\uE7BA"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var kw = q.ToLowerInvariant();
// "list" → 카테고리별 개수
if (kw == "list")
{
var all = AllEntries().ToList();
items.Add(new LauncherItem($"맞춤법 오류 목록 총 {all.Count}개", "", null, null, Symbol: "\uE7BA"));
foreach (var cat in CategoryOrder)
{
var cnt = all.Count(e => e.Category == cat);
if (cnt == 0) continue;
items.Add(new LauncherItem(cat, $"{cnt}개 · spell {cat}로 목록 보기",
null, null, Symbol: "\uE7BA"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 카테고리 키워드 매핑
var catMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["되"] = "되/돼",
["돼"] = "됩니다",
["됐"] = "됩니다",
["됩"] = "됩니다",
["되/돼"] = "됩니다",
["안"] = "안/않",
["않"] = "안/않",
["안/않"] = "안/않",
["혼동"] = "혼동어",
["혼동어"] = "혼동어",
["맞춤법"] = "맞춤법",
["맞춤"] = "맞춤법",
["띄어"] = "띄어쓰기",
["띄어쓰기"] = "띄어쓰기",
["외래어"] = "외래어",
["외래"] = "외래어",
};
// 카테고리 직접 매핑
foreach (var cat in CategoryOrder)
{
if (cat.Equals(q, StringComparison.OrdinalIgnoreCase) ||
catMap.TryGetValue(q, out var mappedCat) && mappedCat == cat)
{
var catList = Entries.Where(e => e.Category == cat).ToList();
items.Add(new LauncherItem($"{cat} {catList.Count}개",
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
foreach (var e in catList)
items.Add(MakeSpellItem(e));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// catMap으로 카테고리 찾기
if (catMap.TryGetValue(q, out var foundCat) && CategoryOrder.Contains(foundCat))
{
var catList = Entries.Where(e => e.Category == foundCat).ToList();
items.Add(new LauncherItem($"{foundCat} {catList.Count}개",
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
foreach (var e in catList)
items.Add(MakeSpellItem(e));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 키워드 검색 (내장 + 사용자 항목)
var searched = AllEntries().Where(e =>
e.Wrong.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
e.Correct.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
e.Explanation.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
if (searched.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"'{q}' 검색 결과 {searched.Count}개",
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
foreach (var e in searched)
items.Add(MakeSpellItem(e));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("맞춤법", "올바른 표현을 복사했습니다.");
}
catch { }
}
else if (item.Data is ("add", string addData))
{
var parts = addData.Split('\t');
if (parts.Length >= 3)
{
var custom = LoadCustom();
custom.RemoveAll(c => c.Wrong.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
custom.Add(new CustomSpell(parts[0], parts[1], parts[2]));
SaveCustom(custom);
NotificationService.Notify("맞춤법", $"'{parts[0]} → {parts[1]}' 항목이 추가되었습니다.");
}
}
else if (item.Data is ("del", string delWrong))
{
var custom = LoadCustom();
custom.RemoveAll(c => c.Wrong.Equals(delWrong, StringComparison.OrdinalIgnoreCase));
SaveCustom(custom);
NotificationService.Notify("맞춤법", $"'{delWrong}' 항목이 삭제되었습니다.");
}
return Task.CompletedTask;
}
private static LauncherItem MakeSpellItem(SpellEntry e) =>
new($"❌ {e.Wrong} → ✅ {e.Correct}",
e.Explanation,
null, ("copy", e.Correct), Symbol: "\uE7BA");
}

View File

@@ -0,0 +1,467 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L18-1: SQL 포맷터·분석기 핸들러. "sql" 프리픽스로 사용합니다.
///
/// 예: sql → 클립보드 SQL 포맷 (들여쓰기 정렬)
/// sql mini → SQL 미니파이 (공백·줄바꿈 제거)
/// sql upper → 키워드 대문자로 변환
/// sql lower → 키워드 소문자로 변환
/// sql stats → 테이블·컬럼·조건 수 분석
/// sql tables → FROM/JOIN 테이블 목록 추출
/// sql select <table> → SELECT * FROM <table> 생성
/// Enter → 결과 복사.
/// 외부 라이브러리 없이 순수 구현.
/// </summary>
public partial class SqlHandler : IActionHandler
{
public string? Prefix => "sql";
public PluginMetadata Metadata => new(
"SQL",
"SQL 포맷터·분석기 — 들여쓰기 · 미니파이 · 키워드 · 테이블 추출",
"1.0",
"AX");
// SQL 키워드 목록
private static readonly string[] Keywords =
[
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON", "AND", "OR", "NOT",
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW", "DROP TABLE", "DROP INDEX",
"ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "RENAME TO",
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET", "DISTINCT",
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
"CASE", "WHEN", "THEN", "ELSE", "END",
"IN", "NOT IN", "EXISTS", "NOT EXISTS", "BETWEEN", "LIKE", "IS NULL", "IS NOT NULL",
"AS", "WITH", "RECURSIVE",
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "NULLIF", "CAST",
"SUBSTRING", "TRIM", "UPPER", "LOWER", "LENGTH", "REPLACE",
"NOW", "CURRENT_DATE", "CURRENT_TIMESTAMP", "DATE_FORMAT",
"BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION",
"PRIMARY KEY", "FOREIGN KEY", "REFERENCES", "UNIQUE", "NOT NULL", "DEFAULT",
"INDEX", "CONSTRAINT",
];
// 새 줄 시작 키워드
private static readonly HashSet<string> NewlineKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON",
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET",
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW",
"DROP TABLE", "ALTER TABLE",
"WITH", "AND", "OR",
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipboard = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("SQL 포맷터·분석기",
"클립보드 SQL 포맷 · sql mini / upper / lower / stats / tables",
null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql", "들여쓰기 포맷", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql mini", "미니파이 (한 줄)", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql upper", "키워드 대문자", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql lower", "키워드 소문자", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql stats", "테이블·컬럼·조건 분석", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql tables", "FROM/JOIN 테이블 목록", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql select T", "SELECT 쿼리 빠른 생성", null, null, Symbol: "\uE8F1"));
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 미리보기: 기본 포맷
var preview = Format(clipboard);
var prevLine = preview.Split('\n').FirstOrDefault() ?? "";
items.Add(new LauncherItem("클립보드 SQL 포맷",
prevLine.Length > 60 ? prevLine[..60] + "…" : prevLine,
null, ("copy", preview), Symbol: "\uE8F1"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// sql select <table> — 클립보드 없이도 동작
if (sub == "select")
{
var table = parts.Length > 1 ? parts[1].Trim() : "your_table";
var generated = BuildSelectTemplate(table);
items.Add(new LauncherItem($"SELECT * FROM {table}",
"Enter → 복사", null, ("copy", generated), Symbol: "\uE8F1"));
foreach (var line in generated.Split('\n').Take(8))
items.Add(new LauncherItem(line, "", null, null, Symbol: "\uE8F1"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (sub)
{
case "format":
case "fmt":
case "pretty":
{
var result = Format(clipboard);
items.Add(new LauncherItem("SQL 포맷 완료",
$"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 8);
break;
}
case "mini":
case "minify":
case "compact":
{
var result = Minify(clipboard);
items.Add(new LauncherItem("SQL 미니파이 완료",
$"{result.Length}자 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
var prev = result.Length > 80 ? result[..80] + "…" : result;
items.Add(new LauncherItem(prev, "", null, ("copy", result), Symbol: "\uE8F1"));
break;
}
case "upper":
{
var result = TransformKeywords(clipboard, upper: true);
items.Add(new LauncherItem("키워드 대문자 변환",
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 6);
break;
}
case "lower":
{
var result = TransformKeywords(clipboard, upper: false);
items.Add(new LauncherItem("키워드 소문자 변환",
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 6);
break;
}
case "stats":
case "stat":
case "analyze":
{
items.AddRange(BuildStatsItems(clipboard));
break;
}
case "tables":
case "table":
{
var tables = ExtractTables(clipboard);
items.Add(new LauncherItem($"테이블 {tables.Count}개",
"FROM / JOIN 에서 추출", null, null, Symbol: "\uE8F1"));
foreach (var t in tables)
items.Add(new LauncherItem(t, "", null, ("copy", t), Symbol: "\uE8F1"));
if (tables.Count == 0)
items.Add(new LauncherItem("테이블 없음", "FROM 절이 없습니다", null, null, Symbol: "\uE946"));
break;
}
default:
{
// 기본 동작: 포맷
var result = Format(clipboard);
items.Add(new LauncherItem("SQL 포맷 완료",
$"{result.Split('\n').Length}줄", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 8);
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("SQL", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── SQL 포맷 ─────────────────────────────────────────────────────────────
private static string Format(string sql)
{
// 정규화: 여러 공백 → 1개, 개행 제거
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
var sb = new StringBuilder();
var indent = 0;
var tokens = Tokenize(flat);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
// 닫기 괄호 → 들여쓰기 감소
if (upper == ")")
{
indent = Math.Max(0, indent - 1);
if (sb.Length > 0 && sb[^1] != '\n')
sb.AppendLine();
sb.Append(new string(' ', indent * 2));
sb.Append(token);
continue;
}
// 새 줄 시작 키워드
if (NewlineKeywords.Contains(upper))
{
if (sb.Length > 0)
{
sb.AppendLine();
sb.Append(new string(' ', indent * 2));
}
sb.Append(token.ToUpperInvariant());
sb.Append(' ');
continue;
}
// 열기 괄호 → 들여쓰기 증가
if (upper == "(")
{
sb.Append(token);
indent++;
continue;
}
// 쉼표 → 뒤에 공백
if (upper == ",")
{
sb.Append(',');
sb.AppendLine();
sb.Append(new string(' ', indent * 2 + 2));
continue;
}
sb.Append(token);
sb.Append(' ');
}
return sb.ToString().TrimEnd();
}
private static string Minify(string sql)
{
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
// 괄호 주변 공백 제거
flat = SpaceAroundParensRegex().Replace(flat, "$1");
flat = SpaceBeforeCommaRegex().Replace(flat, ",");
return flat;
}
private static string TransformKeywords(string sql, bool upper)
{
var result = sql;
// 긴 키워드부터 처리 (LEFT JOIN before JOIN 등)
foreach (var kw in Keywords.OrderByDescending(k => k.Length))
{
var pattern = $@"\b{Regex.Escape(kw)}\b";
var replacement = upper ? kw.ToUpperInvariant() : kw.ToLowerInvariant();
result = Regex.Replace(result, pattern, replacement, RegexOptions.IgnoreCase);
}
return result;
}
private static List<LauncherItem> BuildStatsItems(string sql)
{
var items = new List<LauncherItem>();
var upper = sql.ToUpperInvariant();
var tables = ExtractTables(sql);
var selCols = ExtractSelectColumns(sql);
var wheres = CountConditions(sql);
var joins = CountMatches(upper, @"\bJOIN\b");
var subqs = CountMatches(upper, @"\bSELECT\b") - 1;
var orderBy = upper.Contains("ORDER BY");
var groupBy = upper.Contains("GROUP BY");
var hasLimit = upper.Contains("LIMIT");
var dml = DetectDml(upper);
items.Add(new LauncherItem($"SQL 분석 [{dml}]",
$"테이블 {tables.Count}개 · JOIN {joins}개 · WHERE 조건 {wheres}개",
null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("DML 유형", dml, null, ("copy", dml), Symbol: "\uE8F1"));
items.Add(new LauncherItem("테이블 수", $"{tables.Count}개", null, ("copy", $"{tables.Count}"), Symbol: "\uE8F1"));
items.Add(new LauncherItem("JOIN 수", $"{joins}개", null, ("copy", $"{joins}"), Symbol: "\uE8F1"));
items.Add(new LauncherItem("WHERE 조건 수", $"{wheres}개", null, ("copy", $"{wheres}"), Symbol: "\uE8F1"));
if (selCols.Count > 0)
items.Add(new LauncherItem("SELECT 컬럼", $"{selCols.Count}개", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("서브쿼리", $"{Math.Max(0, subqs)}개", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("GROUP BY", groupBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("ORDER BY", orderBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("LIMIT", hasLimit ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("전체 길이", $"{sql.Length}자", null, null, Symbol: "\uE8F1"));
return items;
}
private static List<string> ExtractTables(string sql)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matches = TableRegex().Matches(sql);
foreach (Match m in matches)
{
var t = m.Groups[1].Value.Trim().Trim('[', ']', '`', '"');
if (!string.IsNullOrWhiteSpace(t) && !IsKeyword(t))
result.Add(t);
}
return result.ToList();
}
private static List<string> ExtractSelectColumns(string sql)
{
var m = SelectColsRegex().Match(sql);
if (!m.Success) return new List<string>();
var cols = m.Groups[1].Value;
return cols.Split(',').Select(c => c.Trim()).Where(c => !string.IsNullOrEmpty(c)).ToList();
}
private static int CountConditions(string sql)
{
var upper = sql.ToUpperInvariant();
var idx = upper.IndexOf("WHERE", StringComparison.Ordinal);
if (idx < 0) return 0;
var wherePart = upper[idx..];
// AND/OR 수 + 1
return Regex.Matches(wherePart, @"\b(AND|OR)\b").Count + 1;
}
private static int CountMatches(string text, string pattern) =>
Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count;
private static string DetectDml(string upper)
{
if (upper.TrimStart().StartsWith("SELECT")) return "SELECT";
if (upper.TrimStart().StartsWith("INSERT")) return "INSERT";
if (upper.TrimStart().StartsWith("UPDATE")) return "UPDATE";
if (upper.TrimStart().StartsWith("DELETE")) return "DELETE";
if (upper.TrimStart().StartsWith("CREATE")) return "CREATE";
if (upper.TrimStart().StartsWith("ALTER")) return "ALTER";
if (upper.TrimStart().StartsWith("DROP")) return "DROP";
if (upper.TrimStart().StartsWith("WITH")) return "CTE/WITH";
return "기타";
}
private static bool IsKeyword(string s) =>
Keywords.Any(k => k.Equals(s, StringComparison.OrdinalIgnoreCase));
private static List<string> Tokenize(string sql)
{
var tokens = new List<string>();
var current = new StringBuilder();
var inStr = false;
var strChar = ' ';
for (var i = 0; i < sql.Length; i++)
{
var c = sql[i];
if (inStr)
{
current.Append(c);
if (c == strChar) inStr = false;
continue;
}
if (c is '\'' or '"' or '`')
{
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
current.Append(c);
inStr = true; strChar = c;
continue;
}
if (c is '(' or ')' or ',')
{
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
tokens.Add(c.ToString());
continue;
}
if (c == ' ')
{
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
continue;
}
current.Append(c);
}
if (current.Length > 0) tokens.Add(current.ToString().Trim());
return tokens.Where(t => !string.IsNullOrEmpty(t)).ToList();
}
private static string BuildSelectTemplate(string table) =>
$"SELECT\n *\nFROM\n {table}\nWHERE\n 1 = 1\nLIMIT 100;";
private static void AddPreview(List<LauncherItem> items, string text, int maxLines)
{
foreach (var line in text.Split('\n').Take(maxLines))
{
var t = line.TrimEnd();
if (!string.IsNullOrWhiteSpace(t))
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE8F1"));
}
}
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"\s*([(),])\s*")]
private static partial Regex SpaceAroundParensRegex();
[GeneratedRegex(@"\s+,")]
private static partial Regex SpaceBeforeCommaRegex();
[GeneratedRegex(@"(?:FROM|JOIN)\s+([\w\.\[\]`""]+)", RegexOptions.IgnoreCase)]
private static partial Regex TableRegex();
[GeneratedRegex(@"SELECT\s+(.*?)\s+FROM", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex SelectColsRegex();
}

View File

@@ -0,0 +1,341 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다.
///
/// 예: ssh → 저장된 SSH 호스트 목록
/// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터
/// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22)
/// ssh add name user@host:22 → 이름 지정하여 추가
/// ssh del <이름> → 호스트 삭제
/// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결.
/// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도).
/// </summary>
public class SshHandler : IActionHandler
{
private readonly SettingsService _settings;
public SshHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "ssh";
public PluginMetadata Metadata => new(
"SSH",
"SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var hosts = _settings.Settings.SshHosts;
if (string.IsNullOrWhiteSpace(q))
{
if (hosts.Count == 0)
{
items.Add(new LauncherItem(
"SSH 호스트 없음",
"ssh add user@host 또는 ssh add 이름 user@host:22",
null, null, Symbol: "\uE968"));
}
else
{
foreach (var h in hosts)
items.Add(MakeHostItem(h));
}
items.Add(new LauncherItem(
"ssh add user@host",
"새 호스트 추가",
null, null, Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// ── add ─────────────────────────────────────────────────────────
if (sub == "add")
{
SshHostEntry? entry = null;
if (parts.Length == 2)
{
// ssh add user@host[:port]
entry = ParseUserHost(parts[1]);
if (entry != null) entry.Name = entry.Host;
}
else if (parts.Length == 3)
{
// ssh add <name> user@host[:port]
entry = ParseUserHost(parts[2]);
if (entry != null) entry.Name = parts[1];
}
if (entry != null)
{
items.Add(new LauncherItem(
$"추가: {entry.Name}",
$"{entry.User}@{entry.Host}:{entry.Port}",
null,
("add", entry),
Symbol: "\uE710"));
}
else
{
items.Add(new LauncherItem(
"형식: ssh add user@host[:port]",
"또는: ssh add 이름 user@host:22",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del ─────────────────────────────────────────────────────────
if (sub == "del" || sub == "delete" || sub == "rm")
{
var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "";
var toDelete = hosts
.Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)
|| h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase))
.ToList();
if (toDelete.Count == 0)
{
items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783"));
}
else
{
foreach (var h in toDelete)
items.Add(new LauncherItem(
$"삭제: {h.Name}",
$"{h.User}@{h.Host}:{h.Port}",
null,
("del", h.Id),
Symbol: "\uE74D"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 검색 (이름 / 호스트 / 사용자 / 메모) ─────────────────────────
var filtered = hosts.Where(h =>
h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.Host.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.User.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count > 0)
{
foreach (var h in filtered)
items.Add(MakeHostItem(h));
}
else
{
// 직접 접속 시도 (user@host 형식)
var entry = ParseUserHost(q);
if (entry != null)
{
items.Add(new LauncherItem(
$"연결: {entry.User}@{entry.Host}:{entry.Port}",
"Enter → Windows Terminal로 연결",
null,
("connect", entry),
Symbol: "\uE968"));
items.Add(new LauncherItem(
$"저장 후 연결",
$"이름: {entry.Host}",
null,
("add_connect", entry),
Symbol: "\uE710"));
}
else
{
items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("connect", SshHostEntry h):
ConnectSsh(h);
break;
case ("add", SshHostEntry h):
_settings.Settings.SshHosts.RemoveAll(e =>
e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase)
&& e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase)
&& e.Port == h.Port);
_settings.Settings.SshHosts.Add(h);
_settings.Save();
NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다.");
break;
case ("add_connect", SshHostEntry h):
if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host;
_settings.Settings.SshHosts.Add(h);
_settings.Save();
ConnectSsh(h);
break;
case ("del", string id):
var removed = _settings.Settings.SshHosts
.RemoveAll(e => e.Id == id);
if (removed > 0)
{
_settings.Save();
NotificationService.Notify("SSH", "호스트를 삭제했습니다.");
}
break;
// 호스트 항목 직접 Enter
case SshHostEntry host:
ConnectSsh(host);
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeHostItem(SshHostEntry h)
{
var portStr = h.Port != 22 ? $":{h.Port}" : "";
return new LauncherItem(
h.Name,
$"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}",
null,
h,
Symbol: "\uE968");
}
/// <summary>Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.</summary>
private static void ConnectSsh(SshHostEntry h)
{
try
{
// Windows Terminal (wt.exe) 우선
var wtPath = FindExecutable("wt.exe");
if (!string.IsNullOrEmpty(wtPath))
{
var portArgs = h.Port != 22 ? $" -p {h.Port}" : "";
var userHost = string.IsNullOrEmpty(h.User)
? h.Host : $"{h.User}@{h.Host}";
Process.Start(new ProcessStartInfo
{
FileName = wtPath,
Arguments = $"ssh {userHost}{portArgs}",
UseShellExecute = true,
});
return;
}
// PuTTY 대체
var puttyPath = FindExecutable("putty.exe");
if (!string.IsNullOrEmpty(puttyPath))
{
var userHost = string.IsNullOrEmpty(h.User)
? h.Host : $"{h.User}@{h.Host}";
Process.Start(new ProcessStartInfo
{
FileName = puttyPath,
Arguments = $"-ssh {userHost} -P {h.Port}",
UseShellExecute = true,
});
return;
}
// PowerShell 폴백
var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : "";
var cmd = string.IsNullOrEmpty(h.User)
? $"ssh {h.Host}{portArgs2}"
: $"ssh {h.User}@{h.Host}{portArgs2}";
Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoExit -Command \"{cmd}\"",
UseShellExecute = true,
});
}
catch (Exception ex)
{
NotificationService.Notify("SSH 오류", ex.Message);
}
}
/// <summary>PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.</summary>
private static string? FindExecutable(string exe)
{
// PATH 검색
var envPath = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var full = System.IO.Path.Combine(dir.Trim(), exe);
if (System.IO.File.Exists(full)) return full;
}
// 일반 설치 경로
var candidates = new[]
{
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft\\WindowsApps", exe),
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe),
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe),
};
return candidates.FirstOrDefault(System.IO.File.Exists);
}
/// <summary>user@host:port 형식을 파싱합니다.</summary>
private static SshHostEntry? ParseUserHost(string s)
{
if (string.IsNullOrEmpty(s)) return null;
string user = "";
string host = s;
int port = 22;
// user@host 파싱
var atIdx = s.IndexOf('@');
if (atIdx >= 0)
{
user = s[..atIdx];
host = s[(atIdx + 1)..];
}
// host:port 파싱
var colonIdx = host.LastIndexOf(':');
if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p))
{
port = p;
host = host[..colonIdx];
}
if (string.IsNullOrEmpty(host)) return null;
return new SshHostEntry
{
Id = Guid.NewGuid().ToString(),
Name = host,
Host = host,
Port = port,
User = user,
};
}
}

View File

@@ -0,0 +1,230 @@
using Microsoft.Win32;
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다.
///
/// 예: startup → 전체 시작 프로그램 목록
/// startup search ms → "ms" 포함 항목 필터
/// startup folder → 시작 프로그램 폴더 열기
/// Enter → 항목 경로를 클립보드에 복사.
/// </summary>
public class StartupHandler : IActionHandler
{
public string? Prefix => "startup";
public PluginMetadata Metadata => new(
"Startup",
"시작 프로그램 조회 — 레지스트리 · 폴더 · 필터",
"1.0",
"AX");
// 조회할 레지스트리 키 경로들
private static readonly (string Path, string Scope)[] RegKeys =
[
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "현재 사용자"),
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "현재 사용자 (1회)"),
(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "현재 사용자 (32bit)"),
];
private static readonly (string Path, string Scope)[] RegKeysHKLM =
[
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "모든 사용자"),
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "모든 사용자 (1회)"),
(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "모든 사용자 (32bit)"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var allEntries = CollectAllEntries();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"시작 프로그램 {allEntries.Count}개",
"레지스트리 + 시작 폴더",
null, null, Symbol: "\uE7FC"));
items.Add(new LauncherItem("startup folder", "시작 폴더 열기", null, ("open_folder", ""), Symbol: "\uE7FC"));
// 그룹별 표시
var byScope = allEntries.GroupBy(e => e.Scope).OrderBy(g => g.Key);
foreach (var group in byScope)
{
items.Add(new LauncherItem($"── {group.Key} ({group.Count()}개) ──", "", null, null, Symbol: "\uE7FC"));
foreach (var e in group.Take(10))
items.Add(MakeEntryItem(e));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "folder":
{
items.Add(new LauncherItem("시작 폴더 열기 (현재 사용자)", GetStartupFolderUser(),
null, ("open_folder", GetStartupFolderUser()), Symbol: "\uE7FC"));
items.Add(new LauncherItem("시작 폴더 열기 (모든 사용자)", GetStartupFolderCommon(),
null, ("open_folder", GetStartupFolderCommon()), Symbol: "\uE7FC"));
break;
}
case "search":
case "find":
{
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(keyword))
{
items.Add(new LauncherItem("검색어 입력", "예: startup search teams", null, null, Symbol: "\uE783"));
break;
}
var filtered = allEntries.Where(e =>
e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
else
foreach (var e in filtered)
items.Add(MakeEntryItem(e));
break;
}
default:
{
// 검색어로 처리
var keyword = q.ToLowerInvariant();
var filtered = allEntries.Where(e =>
e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
else
foreach (var e in filtered.Take(15))
items.Add(MakeEntryItem(e));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("Startup", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
break;
case ("open_folder", string path):
var folderPath = string.IsNullOrEmpty(path) ? GetStartupFolderUser() : path;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folderPath,
UseShellExecute = true,
});
}
catch (Exception ex)
{
NotificationService.Notify("Startup", $"폴더 열기 실패: {ex.Message}");
}
break;
}
return Task.CompletedTask;
}
// ── 레지스트리 수집 ────────────────────────────────────────────────────────
private record StartupEntry(string Name, string Command, string Scope, string Source);
private static List<StartupEntry> CollectAllEntries()
{
var result = new List<StartupEntry>();
// HKCU
foreach (var (regPath, scope) in RegKeys)
result.AddRange(ReadRegistryRun(Registry.CurrentUser, regPath, scope));
// HKLM
foreach (var (regPath, scope) in RegKeysHKLM)
result.AddRange(ReadRegistryRun(Registry.LocalMachine, regPath, scope));
// 시작 폴더 (현재 사용자)
result.AddRange(ReadStartupFolder(GetStartupFolderUser(), "현재 사용자 (폴더)"));
// 시작 폴더 (모든 사용자)
result.AddRange(ReadStartupFolder(GetStartupFolderCommon(), "모든 사용자 (폴더)"));
return result;
}
private static IEnumerable<StartupEntry> ReadRegistryRun(RegistryKey hive, string path, string scope)
{
RegistryKey? key;
try { key = hive.OpenSubKey(path, writable: false); }
catch { yield break; }
if (key == null) yield break;
using (key)
{
foreach (var name in key.GetValueNames())
{
var cmd = key.GetValue(name)?.ToString() ?? "";
yield return new StartupEntry(name, cmd, scope, path);
}
}
}
private static IEnumerable<StartupEntry> ReadStartupFolder(string folderPath, string scope)
{
if (!Directory.Exists(folderPath)) yield break;
foreach (var file in Directory.EnumerateFiles(folderPath, "*.lnk"))
{
yield return new StartupEntry(
Path.GetFileNameWithoutExtension(file),
file,
scope,
folderPath);
}
}
private static string GetStartupFolderUser() =>
Environment.GetFolderPath(Environment.SpecialFolder.Startup);
private static string GetStartupFolderCommon() =>
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartup);
private static LauncherItem MakeEntryItem(StartupEntry e)
{
var cmdShort = e.Command.Length > 70 ? e.Command[..70] + "…" : e.Command;
return new LauncherItem(
e.Name,
$"{e.Scope} · {cmdShort}",
null,
("copy", e.Command),
Symbol: "\uE7FC");
}
}

View File

@@ -0,0 +1,459 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-3: 문자열 조작 도구 핸들러. "str" 프리픽스로 사용합니다.
///
/// 예: str → 클립보드 텍스트 조작 메뉴
/// str escape html → HTML 특수문자 이스케이프
/// str unescape html → HTML 이스케이프 해제
/// str escape url → URL 인코딩 (퍼센트)
/// str unescape url → URL 디코딩
/// str escape json → JSON 문자열 이스케이프
/// str escape regex → 정규식 이스케이프
/// str repeat 3 → 클립보드 텍스트 3회 반복
/// str repeat 5 , → 쉼표 구분 5회 반복
/// str pad 20 → 20자 우측 공백 패딩
/// str pad 20 left → 좌측 패딩
/// str pad 20 * right → 지정 문자로 우측 패딩
/// str wrap 80 → 80자 줄바꿈
/// str lines → 줄 수·단어·문자 통계
/// str sort → 줄 정렬 (오름차순)
/// str sort desc → 줄 정렬 (내림차순)
/// str unique → 중복 줄 제거
/// str join , → 여러 줄 → 쉼표 구분 한 줄
/// str split , → 쉼표 구분 → 여러 줄
/// str replace a b → 텍스트 내 a를 b로 교체
/// str extract email → 이메일 주소 추출
/// str extract url → URL 추출
/// str extract number → 숫자 추출
/// Enter → 결과 복사.
/// </summary>
public partial class StrHandler : IActionHandler
{
public string? Prefix => "str";
public PluginMetadata Metadata => new(
"Str",
"문자열 조작 도구 — HTML/URL/JSON 이스케이프·반복·패딩·줄 정렬·추출",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 읽기
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("문자열 조작 도구",
"str escape/unescape / str repeat / str pad / str sort / str extract …",
null, null, Symbol: "\uE8AB"));
if (!string.IsNullOrWhiteSpace(clipboard))
{
var preview = clipboard!.Length > 40 ? clipboard[..40] + "…" : clipboard;
items.Add(new LauncherItem($"클립보드: \"{preview}\"",
$"{clipboard.Length}자 · 아래 서브커맨드로 조작", null, null, Symbol: "\uE8AB"));
BuildQuickMenu(items, clipboard!);
}
else
{
items.Add(new LauncherItem("클립보드가 비어 있습니다", "텍스트를 복사한 뒤 사용하세요",
null, null, Symbol: "\uE946"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
var text = parts.Length > 1
? string.Join(" ", parts[1..]) // 인라인 텍스트 (없으면 클립보드)
: clipboard ?? "";
// escape / unescape
if (sub is "escape" or "esc" or "unescape" or "unesc")
{
var isEscape = sub is "escape" or "esc";
var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "html";
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); }
else
{
var (html, url, json, reg) = (
isEscape ? HtmlEncode(src) : HtmlDecode(src),
isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src),
isEscape ? JsonEscape(src) : JsonUnescape(src),
isEscape ? RegexEscape(src) : src
);
var label = isEscape ? "이스케이프" : "이스케이프 해제";
items.Add(new LauncherItem($"── {label} ──", "", null, null, Symbol: "\uE8AB"));
items.Add(CopyItem("HTML", isEscape ? HtmlEncode(src) : HtmlDecode(src)));
items.Add(CopyItem("URL", isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src)));
items.Add(CopyItem("JSON", isEscape ? JsonEscape(src) : JsonUnescape(src)));
if (isEscape) items.Add(CopyItem("Regex", RegexEscape(src)));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// repeat
if (sub == "repeat")
{
int count = 3;
string sep = "";
string src = clipboard ?? "";
if (parts.Length >= 2 && int.TryParse(parts[1], out var n)) count = Math.Clamp(n, 1, 100);
if (parts.Length >= 3) sep = parts[2] == "\\n" ? "\n" : parts[2] == "\\t" ? "\t" : parts[2];
if (parts.Length >= 4) src = string.Join(" ", parts[3..]);
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("반복할 텍스트가 없습니다")); }
else
{
var result = string.Join(sep, Enumerable.Repeat(src, count));
items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result,
$"{count}회 반복 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
items.Add(CopyItem("결과", result));
items.Add(CopyItem("결과 길이", $"{result.Length}자"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// pad
if (sub == "pad")
{
if (parts.Length < 2 || !int.TryParse(parts[1], out var width))
{ items.Add(ErrorItem("예: str pad 20 / str pad 20 left / str pad 20 * right")); }
else
{
var side = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "right";
var padChar = ' ';
if (parts.Length >= 4 && parts[2].Length == 1) { padChar = parts[2][0]; side = parts[3].ToLowerInvariant(); }
if (parts.Length >= 3 && parts[2].Length == 1 && !"left right both".Contains(parts[2])) padChar = parts[2][0];
var src = clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("클립보드에 텍스트가 없습니다")); }
else
{
var result = side switch
{
"left" => src.PadLeft(width, padChar),
"both" => src.PadLeft((src.Length + width) / 2, padChar).PadRight(width, padChar),
_ => src.PadRight(width, padChar)
};
items.Add(new LauncherItem($"\"{result}\"", $"{side} 패딩 {width}자 · Enter 복사",
null, ("copy", result), Symbol: "\uE8AB"));
items.Add(CopyItem("결과", result));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// wrap
if (sub == "wrap")
{
if (parts.Length < 2 || !int.TryParse(parts[1], out var cols))
{ items.Add(ErrorItem("예: str wrap 80")); }
else
{
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("줄바꿈할 텍스트가 없습니다")); }
else
{
var result = WordWrap(src, cols);
var preview = result.Split('\n').Take(5);
items.Add(new LauncherItem($"{cols}자 줄바꿈",
$"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
foreach (var line in preview)
items.Add(new LauncherItem(line.Length > 60 ? line[..60] + "…" : line, "",
null, null, Symbol: "\uE8AB"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// sort
if (sub == "sort")
{
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
var desc = parts.Length >= 2 && parts[1].ToLowerInvariant() is "desc" or "d";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("정렬할 텍스트가 없습니다")); }
else
{
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var sorted = desc ? lines.OrderByDescending(l => l).ToArray()
: lines.OrderBy(l => l).ToArray();
var result = string.Join("\n", sorted);
items.Add(new LauncherItem($"{sorted.Length}줄 {(desc ? "" : "")} 정렬",
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
foreach (var line in sorted.Take(6))
items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "",
null, null, Symbol: "\uE8AB"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// unique
if (sub is "unique" or "dedup")
{
var src = parts.Length >= 2 ? string.Join(" ", parts[1..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("중복 제거할 텍스트가 없습니다")); }
else
{
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var unique = lines.Distinct(StringComparer.Ordinal).ToArray();
var result = string.Join("\n", unique);
items.Add(new LauncherItem($"{lines.Length}줄 → {unique.Length}줄 (중복 {lines.Length - unique.Length}개 제거)",
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
foreach (var line in unique.Take(6))
items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "",
null, null, Symbol: "\uE8AB"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// join
if (sub == "join")
{
var sep = parts.Length >= 2 ? parts[1] : ",";
if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t";
var src = clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("연결할 텍스트가 없습니다")); }
else
{
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var result = string.Join(sep, lines);
items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result,
$"{lines.Length}줄 연결 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
items.Add(CopyItem("결과", result));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// split
if (sub == "split")
{
var sep = parts.Length >= 2 ? parts[1] : ",";
if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t";
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분리할 텍스트가 없습니다")); }
else
{
var splitted = src.Split(sep, StringSplitOptions.None);
var result = string.Join("\n", splitted);
items.Add(new LauncherItem($"'{sep}'로 분리 → {splitted.Length}개",
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
foreach (var item in splitted.Take(8))
items.Add(new LauncherItem(item.Length > 60 ? item[..60] : item, "",
null, null, Symbol: "\uE8AB"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// replace
if (sub == "replace")
{
if (parts.Length < 3) { items.Add(ErrorItem("예: str replace 찾을텍스트 바꿀텍스트")); }
else
{
var from = parts[1];
var to = parts[2];
var src = parts.Length >= 4 ? string.Join(" ", parts[3..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); }
else
{
var count = 0;
var result = ReplaceCount(src, from, to, out count);
items.Add(new LauncherItem($"'{from}' → '{to}' ({count}개 교체)",
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
items.Add(CopyItem("결과", result));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// extract
if (sub == "extract")
{
var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "url";
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("추출할 텍스트가 없습니다")); }
else
{
var matches = target switch
{
"email" => EmailRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
"url" => UrlRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
"num" or "number" or "숫자" =>
NumberRegex().Matches(src).Select(m => m.Value).ToList(),
"ip" => IpRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
_ => new List<string>()
};
if (matches.Count == 0)
items.Add(new LauncherItem($"'{target}' 패턴 없음", src.Length > 40 ? src[..40] : src,
null, null, Symbol: "\uE8AB"));
else
{
items.Add(new LauncherItem($"{target} {matches.Count}개 추출",
"Enter로 전체 복사", null, ("copy", string.Join("\n", matches)), Symbol: "\uE8AB"));
foreach (var m in matches.Take(10))
items.Add(new LauncherItem(m, "", null, ("copy", m), Symbol: "\uE8AB"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// lines
if (sub is "lines" or "info" or "count")
{
var src = clipboard ?? "";
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분석할 텍스트가 없습니다")); }
else
{
var lineArr = src.Split('\n');
var words = src.Split(new[] {' ','\t','\n','\r'}, StringSplitOptions.RemoveEmptyEntries);
items.Add(new LauncherItem($"{lineArr.Length}줄 · {words.Length}단어 · {src.Length}자",
"텍스트 분석", null, null, Symbol: "\uE8AB"));
items.Add(CopyItem("전체 줄 수", lineArr.Length.ToString()));
items.Add(CopyItem("빈 줄 수", lineArr.Count(l => string.IsNullOrWhiteSpace(l)).ToString()));
items.Add(CopyItem("단어 수", words.Length.ToString()));
items.Add(CopyItem("전체 문자 수", src.Length.ToString()));
items.Add(CopyItem("공백 제외 문자", src.Count(c => !char.IsWhiteSpace(c)).ToString()));
items.Add(CopyItem("바이트 (UTF-8)", Encoding.UTF8.GetByteCount(src).ToString()));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 알 수 없는 커맨드
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
"escape · unescape · repeat · pad · wrap · sort · unique · join · split · replace · extract · lines",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Str", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static void BuildQuickMenu(List<LauncherItem> items, string src)
{
items.Add(CopyItem("HTML 이스케이프", HtmlEncode(src)));
items.Add(CopyItem("URL 인코딩", Uri.EscapeDataString(src)));
items.Add(CopyItem("JSON 이스케이프", JsonEscape(src)));
}
private static string HtmlEncode(string s) => s
.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;").Replace("'", "&#39;");
private static string HtmlDecode(string s) =>
HttpUtility.HtmlDecode(s);
private static string JsonEscape(string s)
{
var sb = new StringBuilder();
foreach (var c in s)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20) sb.Append($"\\u{(int)c:X4}");
else sb.Append(c);
break;
}
}
return sb.ToString();
}
private static string JsonUnescape(string s) =>
s.Replace("\\\"", "\"").Replace("\\\\", "\\")
.Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t");
private static string RegexEscape(string s) => Regex.Escape(s);
private static string WordWrap(string text, int cols)
{
var words = text.Split(' ');
var sb = new StringBuilder();
int colPos = 0;
foreach (var word in words)
{
if (colPos + word.Length + 1 > cols && colPos > 0) { sb.Append('\n'); colPos = 0; }
else if (colPos > 0) { sb.Append(' '); colPos++; }
sb.Append(word);
colPos += word.Length;
}
return sb.ToString();
}
private static string ReplaceCount(string src, string from, string to, out int count)
{
count = 0;
var sb = new StringBuilder();
int pos = 0;
while (true)
{
var idx = src.IndexOf(from, pos, StringComparison.Ordinal);
if (idx < 0) { sb.Append(src[pos..]); break; }
sb.Append(src[pos..idx]);
sb.Append(to);
count++;
pos = idx + from.Length;
}
return sb.ToString();
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8AB");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
[GeneratedRegex(@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")]
private static partial Regex EmailRegex();
[GeneratedRegex(@"https?://[^\s""'<>]+")]
private static partial Regex UrlRegex();
[GeneratedRegex(@"-?\d+(\.\d+)?")]
private static partial Regex NumberRegex();
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
private static partial Regex IpRegex();
}

View File

@@ -0,0 +1,281 @@
using System.Net;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L9-2: IP 서브넷 계산기 핸들러. "subnet" 프리픽스로 사용합니다.
///
/// 예: subnet 192.168.1.0/24 → 네트워크 정보 전체 표시
/// subnet 10.0.0.5/24 → 해당 IP가 속한 서브넷 분석
/// subnet 255.255.255.0 → 서브넷 마스크 → CIDR 변환
/// subnet 192.168.1.0 24 → 슬래시 없이 입력 가능
/// subnet range 192.168.1.10-50 → IP 범위 정보
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class SubnetHandler : IActionHandler
{
public string? Prefix => "subnet";
public PluginMetadata Metadata => new(
"Subnet",
"IP 서브넷 계산기 — CIDR · 네트워크 · 호스트 범위",
"1.0",
"AX");
// 자주 쓰는 CIDR 참조표
private static readonly (int Prefix, int Hosts, string Desc)[] CidrRef =
[
(24, 254, "/24 — 클래스 C (254 호스트)"),
(25, 126, "/25 — 128개 분할 (126 호스트)"),
(26, 62, "/26 — 64개 분할 (62 호스트)"),
(27, 30, "/27 — 32개 분할 (30 호스트)"),
(28, 14, "/28 — 16개 분할 (14 호스트)"),
(29, 6, "/29 — 8개 분할 (6 호스트)"),
(30, 2, "/30 — 포인트-투-포인트 (2 호스트)"),
(23, 510, "/23 — 512개 블록 (510 호스트)"),
(22, 1022, "/22 — 1024개 블록 (1022 호스트)"),
(20, 4094, "/20 — 4096개 블록 (4094 호스트)"),
(16, 65534, "/16 — 클래스 B (65534 호스트)"),
(8, 16777214, "/8 — 클래스 A (16M 호스트)"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
"서브넷 계산기",
"예: subnet 192.168.1.0/24 또는 subnet 10.0.0.5 24",
null, null, Symbol: "\uE968"));
// CIDR 참조표 일부
foreach (var (cidrLen, hosts, desc) in CidrRef.Take(6))
{
items.Add(new LauncherItem(
desc,
$"호스트: {hosts:N0}개",
null,
("copy", desc),
Symbol: "\uE968"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "range" 서브커맨드
if (q.StartsWith("range ", StringComparison.OrdinalIgnoreCase))
{
var rangeStr = q[6..].Trim();
items.AddRange(BuildRangeItems(rangeStr));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 서브넷 마스크 입력 (예: 255.255.255.0)
if (TryParseMask(q, out var cidrFromMask))
{
items.Add(new LauncherItem(
$"CIDR: /{cidrFromMask}",
$"서브넷 마스크 {q} = /{cidrFromMask}",
null,
("copy", $"/{cidrFromMask}"),
Symbol: "\uE968"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// CIDR 파싱: "IP/prefix" 또는 "IP prefix"
if (!TryParseCidr(q, out var ip, out var prefix))
{
items.Add(new LauncherItem("형식 오류", "예: 192.168.1.0/24", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.AddRange(BuildSubnetItems(ip, prefix));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Subnet", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 서브넷 계산 ──────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildSubnetItems(uint ip, int prefix)
{
var mask = prefix == 0 ? 0u : ~0u << (32 - prefix);
var network = ip & mask;
var broadcast = network | ~mask;
var firstHost = prefix < 31 ? network + 1 : network;
var lastHost = prefix < 31 ? broadcast - 1 : broadcast;
var hostCount = prefix >= 31 ? (1u << (32 - prefix))
: (broadcast - network - 1);
var summaryText = $"""
네트워크: {ToIp(network)}/{prefix}
서브넷마스크: {ToIp(mask)}
첫 호스트: {ToIp(firstHost)}
마지막 호스트: {ToIp(lastHost)}
브로드캐스트: {ToIp(broadcast)}
사용 가능 호스트: {hostCount:N0}개
""";
yield return new LauncherItem(
$"{ToIp(network)}/{prefix}",
$"호스트 {hostCount:N0}개 · 전체 복사: Enter",
null,
("copy", summaryText),
Symbol: "\uE968");
yield return new LauncherItem("서브넷 마스크", ToIp(mask), null, ("copy", ToIp(mask)), Symbol: "\uE968");
yield return new LauncherItem("네트워크 주소", ToIp(network), null, ("copy", ToIp(network)), Symbol: "\uE968");
yield return new LauncherItem("브로드캐스트", ToIp(broadcast), null, ("copy", ToIp(broadcast)), Symbol: "\uE968");
yield return new LauncherItem("첫 호스트", ToIp(firstHost), null, ("copy", ToIp(firstHost)), Symbol: "\uE968");
yield return new LauncherItem("마지막 호스트", ToIp(lastHost), null, ("copy", ToIp(lastHost)), Symbol: "\uE968");
yield return new LauncherItem(
"사용 가능 호스트",
$"{hostCount:N0}개",
null,
("copy", hostCount.ToString()),
Symbol: "\uE968");
// 입력 IP가 이 서브넷에 속하는지 확인
if ((ip & mask) == network && ip != network && ip != broadcast)
{
yield return new LauncherItem(
$"입력 IP {ToIp(ip)}",
"이 서브넷에 속함 ✓",
null, null, Symbol: "\uE73E");
}
// 이진 표현
yield return new LauncherItem(
"이진 마스크",
ToBinary(mask),
null,
("copy", ToBinary(mask)),
Symbol: "\uE8C4");
}
private static IEnumerable<LauncherItem> BuildRangeItems(string rangeStr)
{
// "192.168.1.10-50" 또는 "192.168.1.10-192.168.1.50"
var parts = rangeStr.Split('-', 2);
if (parts.Length != 2)
{
yield return new LauncherItem("형식 오류", "예: 192.168.1.10-50", null, null, Symbol: "\uE783");
yield break;
}
if (!TryParseIp(parts[0].Trim(), out var startIp))
{
yield return new LauncherItem("IP 형식 오류", parts[0], null, null, Symbol: "\uE783");
yield break;
}
uint endIp;
if (uint.TryParse(parts[1].Trim(), out var lastOctet))
{
endIp = (startIp & 0xFFFFFF00) | lastOctet;
}
else if (!TryParseIp(parts[1].Trim(), out endIp))
{
yield return new LauncherItem("IP 형식 오류", parts[1], null, null, Symbol: "\uE783");
yield break;
}
if (endIp < startIp)
{
yield return new LauncherItem("오류", "끝 IP가 시작 IP보다 작습니다", null, null, Symbol: "\uE783");
yield break;
}
var count = endIp - startIp + 1;
yield return new LauncherItem(
$"{ToIp(startIp)} — {ToIp(endIp)}",
$"{count}개 IP",
null,
("copy", $"{ToIp(startIp)} - {ToIp(endIp)} ({count}개)"),
Symbol: "\uE968");
yield return new LauncherItem("시작 IP", ToIp(startIp), null, ("copy", ToIp(startIp)), Symbol: "\uE968");
yield return new LauncherItem("끝 IP", ToIp(endIp), null, ("copy", ToIp(endIp)), Symbol: "\uE968");
yield return new LauncherItem("IP 수", $"{count:N0}개", null, ("copy", count.ToString()), Symbol: "\uE968");
}
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
private static bool TryParseCidr(string s, out uint ip, out int prefix)
{
ip = 0; prefix = 24;
// "IP/prefix" 형식
var slashIdx = s.IndexOf('/');
if (slashIdx >= 0)
{
if (!TryParseIp(s[..slashIdx].Trim(), out ip)) return false;
if (!int.TryParse(s[(slashIdx + 1)..].Trim(), out prefix)) return false;
prefix = Math.Clamp(prefix, 0, 32);
return true;
}
// "IP prefix" 형식 (공백 구분)
var spaceIdx = s.LastIndexOf(' ');
if (spaceIdx >= 0 && int.TryParse(s[(spaceIdx + 1)..].Trim(), out var p))
{
prefix = Math.Clamp(p, 0, 32);
return TryParseIp(s[..spaceIdx].Trim(), out ip);
}
// IP만 입력 → /24 기본
return TryParseIp(s.Trim(), out ip);
}
private static bool TryParseIp(string s, out uint ip)
{
ip = 0;
if (!IPAddress.TryParse(s, out var addr)) return false;
if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false;
var bytes = addr.GetAddressBytes();
ip = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16)
| ((uint)bytes[2] << 8) | (uint)bytes[3];
return true;
}
private static bool TryParseMask(string s, out int prefix)
{
prefix = 0;
if (!TryParseIp(s, out var mask)) return false;
if (mask == 0) { prefix = 0; return true; }
// 유효한 서브넷 마스크인지 확인 (연속된 1 뒤에 0)
var inverted = ~mask;
if ((inverted & (inverted + 1)) != 0) return false;
prefix = 0;
var tmp = mask;
while ((tmp & 0x80000000) != 0) { prefix++; tmp <<= 1; }
return true;
}
private static string ToIp(uint ip) =>
$"{ip >> 24}.{(ip >> 16) & 0xFF}.{(ip >> 8) & 0xFF}.{ip & 0xFF}";
private static string ToBinary(uint val) =>
$"{Convert.ToString((int)(val >> 16), 2).PadLeft(16, '0')} {Convert.ToString((int)(val & 0xFFFF), 2).PadLeft(16, '0')}";
}

View File

@@ -0,0 +1,376 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L16-4: 텍스트 → 표 변환 핸들러. "table" 프리픽스로 사용합니다.
///
/// 클립보드 텍스트(탭 구분, CSV, 공백 정렬)를 표로 변환합니다.
///
/// 예: table → 클립보드 → 마크다운 표 변환
/// table csv → 마크다운 → CSV 변환
/// table html → 마크다운 → HTML 테이블 변환
/// table flip → 행·열 전치(transpose)
/// table sort 2 → 2번 열 기준 정렬
/// table add <헤더> → 새 행 추가 (탭 구분)
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class TableHandler : IActionHandler
{
public string? Prefix => "table";
public PluginMetadata Metadata => new(
"Table",
"텍스트·CSV → 마크다운·HTML 표 변환 — 전치 · 정렬 · 추가",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 읽기
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipboard = Clipboard.GetText();
});
}
catch { /* 클립보드 접근 실패 */ }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("텍스트 → 표 변환기",
"클립보드 탭·CSV·공백 구분 텍스트를 표로 변환 · table csv / html / flip / sort N",
null, null, Symbol: "\uE81E"));
items.Add(new LauncherItem("table", "→ 마크다운 표", null, null, Symbol: "\uE81E"));
items.Add(new LauncherItem("table csv", "→ CSV 변환", null, null, Symbol: "\uE81E"));
items.Add(new LauncherItem("table html", "→ HTML 테이블", null, null, Symbol: "\uE81E"));
items.Add(new LauncherItem("table flip", "행·열 전치 (transpose)", null, null, Symbol: "\uE81E"));
items.Add(new LauncherItem("table sort 2","2열 기준 정렬", null, null, Symbol: "\uE81E"));
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"탭·쉼표·공백 구분 표 데이터를 복사한 뒤 사용하세요",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 미리보기
var previewRows = ParseTable(clipboard);
if (previewRows.Count > 0)
{
var md = ToMarkdown(previewRows);
items.Add(new LauncherItem($"마크다운 표 변환 ({previewRows.Count}행 × {previewRows[0].Count}열)",
"Enter → 변환 결과 복사", null, ("copy", md), Symbol: "\uE81E"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"표 데이터를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
var rows = ParseTable(clipboard);
if (rows.Count == 0)
{
items.Add(new LauncherItem("표로 변환할 수 없습니다",
"탭·쉼표·공백 구분 데이터가 아닌 것 같습니다",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (sub)
{
case "csv":
{
var csv = ToCsv(rows);
items.Add(new LauncherItem($"CSV 변환 ({rows.Count}행 × {rows[0].Count}열)",
"Enter → CSV 복사", null, ("copy", csv), Symbol: "\uE81E"));
AddPreview(items, csv);
break;
}
case "html":
{
var html = ToHtml(rows);
items.Add(new LauncherItem($"HTML 테이블 ({rows.Count}행 × {rows[0].Count}열)",
"Enter → HTML 복사", null, ("copy", html), Symbol: "\uE81E"));
AddPreview(items, html);
break;
}
case "md":
case "markdown":
{
var md = ToMarkdown(rows);
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
AddPreview(items, md);
break;
}
case "flip":
case "transpose":
{
var flipped = Transpose(rows);
var md = ToMarkdown(flipped);
items.Add(new LauncherItem($"전치됨 ({flipped.Count}행 × {flipped[0].Count}열)",
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
var csv = ToCsv(flipped);
items.Add(new LauncherItem("CSV로 복사", $"{flipped.Count}행 × {flipped[0].Count}열",
null, ("copy", csv), Symbol: "\uE81E"));
break;
}
case "sort":
{
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var ci) ? ci - 1 : 0;
var sorted = SortByColumn(rows, colIdx);
var md = ToMarkdown(sorted);
items.Add(new LauncherItem($"{colIdx + 1}열 기준 정렬",
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
AddPreview(items, md);
break;
}
default:
{
// 기본: 마크다운 변환
var md = ToMarkdown(rows);
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
var csv = ToCsv(rows);
items.Add(new LauncherItem("CSV로 복사", $"{rows.Count}행",
null, ("copy", csv), Symbol: "\uE81E"));
var html = ToHtml(rows);
items.Add(new LauncherItem("HTML로 복사", "테이블 태그",
null, ("copy", html), Symbol: "\uE81E"));
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Table", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 파서 ─────────────────────────────────────────────────────────────────
/// <summary>탭, 쉼표(CSV), 연속 공백 순으로 자동 감지하여 표로 파싱</summary>
private static List<List<string>> ParseTable(string text)
{
var lines = text.Split('\n')
.Select(l => l.TrimEnd('\r'))
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
if (lines.Count == 0) return new List<List<string>>();
// 구분자 자동 감지
var tabCount = lines[0].Count(c => c == '\t');
var commaCount = lines[0].Count(c => c == ',');
char delimiter;
if (tabCount > 0) delimiter = '\t';
else if (commaCount > 0) delimiter = ',';
else delimiter = '\t'; // 공백 구분은 단순 분할
var rows = new List<List<string>>();
foreach (var line in lines)
{
var cols = delimiter == ','
? ParseCsvLine(line)
: line.Split(delimiter).Select(c => c.Trim()).ToList();
rows.Add(cols);
}
// 열 수 통일 (가장 많은 열 수 기준 패딩)
var maxCols = rows.Max(r => r.Count);
foreach (var row in rows)
while (row.Count < maxCols) row.Add("");
return rows;
}
private static List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var current = new StringBuilder();
var inQuote = false;
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (c == '"')
{
if (inQuote && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; }
else inQuote = !inQuote;
}
else if (c == ',' && !inQuote)
{
result.Add(current.ToString());
current.Clear();
}
else current.Append(c);
}
result.Add(current.ToString());
return result;
}
// ── 변환 ─────────────────────────────────────────────────────────────────
private static string ToMarkdown(List<List<string>> rows)
{
if (rows.Count == 0) return "";
var sb = new StringBuilder();
var maxCols = rows.Max(r => r.Count);
// 각 열 최대 너비
var widths = new int[maxCols];
for (var c = 0; c < maxCols; c++)
widths[c] = rows.Max(r => c < r.Count ? r[c].Length : 0);
// 헤더
sb.Append('|');
for (var c = 0; c < maxCols; c++)
sb.Append($" {rows[0][c].PadRight(widths[c])} |");
sb.AppendLine();
// 구분선
sb.Append('|');
for (var c = 0; c < maxCols; c++)
sb.Append($" {new string('-', Math.Max(widths[c], 3))} |");
sb.AppendLine();
// 데이터 행
for (var r = 1; r < rows.Count; r++)
{
sb.Append('|');
for (var c = 0; c < maxCols; c++)
{
var cell = c < rows[r].Count ? rows[r][c] : "";
sb.Append($" {cell.PadRight(widths[c])} |");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private static string ToCsv(List<List<string>> rows)
{
var sb = new StringBuilder();
foreach (var row in rows)
{
sb.AppendLine(string.Join(",", row.Select(c =>
c.Contains(',') || c.Contains('"') || c.Contains('\n')
? $"\"{c.Replace("\"", "\"\"")}\"" : c)));
}
return sb.ToString().TrimEnd();
}
private static string ToHtml(List<List<string>> rows)
{
if (rows.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("<table>");
// 헤더
sb.AppendLine(" <thead><tr>");
foreach (var cell in rows[0])
sb.AppendLine($" <th>{EscHtml(cell)}</th>");
sb.AppendLine(" </tr></thead>");
// 바디
sb.AppendLine(" <tbody>");
for (var r = 1; r < rows.Count; r++)
{
sb.AppendLine(" <tr>");
foreach (var cell in rows[r])
sb.AppendLine($" <td>{EscHtml(cell)}</td>");
sb.AppendLine(" </tr>");
}
sb.AppendLine(" </tbody>");
sb.Append("</table>");
return sb.ToString();
}
private static List<List<string>> Transpose(List<List<string>> rows)
{
if (rows.Count == 0) return rows;
var maxCols = rows.Max(r => r.Count);
var result = new List<List<string>>();
for (var c = 0; c < maxCols; c++)
{
var row = new List<string>();
for (var r = 0; r < rows.Count; r++)
row.Add(c < rows[r].Count ? rows[r][c] : "");
result.Add(row);
}
return result;
}
private static List<List<string>> SortByColumn(List<List<string>> rows, int colIdx)
{
if (rows.Count <= 1) return rows;
var header = rows[0];
var data = rows.Skip(1).ToList();
data.Sort((a, b) =>
{
var va = colIdx < a.Count ? a[colIdx] : "";
var vb = colIdx < b.Count ? b[colIdx] : "";
// 숫자이면 숫자 비교
if (double.TryParse(va, out var na) && double.TryParse(vb, out var nb))
return na.CompareTo(nb);
return string.Compare(va, vb, StringComparison.OrdinalIgnoreCase);
});
var result = new List<List<string>> { header };
result.AddRange(data);
return result;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static void AddPreview(List<LauncherItem> items, string text)
{
var lines = text.Split('\n').Take(3);
foreach (var line in lines)
{
var t = line.Length > 60 ? line[..60] + "…" : line;
if (!string.IsNullOrWhiteSpace(t))
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE81E"));
}
}
private static string EscHtml(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
}

View File

@@ -0,0 +1,264 @@
using System.Diagnostics;
using System.IO;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다.
/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.
///
/// 사용법:
/// tag → 전체 태그 목록 (태그명 + 파일 수)
/// tag work → "work" 태그가 부여된 파일 목록
/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가
/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거
/// tag clear C:\path\file.docx → 파일의 모든 태그 제거
/// </summary>
public class TagHandler : IActionHandler
{
public string? Prefix => "tag";
public PluginMetadata Metadata => new(
"FileTag",
"파일 태그 — tag",
"1.0",
"AX",
"파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.");
private static FileTagService Tags => FileTagService.Instance;
// ─── GetItemsAsync ───────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// ── add 명령: tag add <태그> <경로> ────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx > 0)
{
var tag = rest[..spaceIdx].Trim();
var path = rest[(spaceIdx + 1)..].Trim();
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 추가: [{tag}] {Path.GetFileName(path)}",
$"{path} · Enter로 추가",
null,
ValueTuple.Create("__TAG_ADD__", tag, path),
Symbol: Symbols.Tag)
]);
}
return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx");
}
// ── del 명령: tag del <태그> <경로> ────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx > 0)
{
var tag = rest[..spaceIdx].Trim();
var path = rest[(spaceIdx + 1)..].Trim();
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 제거: [{tag}] {Path.GetFileName(path)}",
$"{path} · Enter로 제거",
null,
ValueTuple.Create("__TAG_DEL__", tag, path),
Symbol: Symbols.Delete)
]);
}
return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx");
}
// ── clear 명령: tag clear <경로> ───────────────────────────────────
if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase))
{
var path = q[6..].Trim();
if (!string.IsNullOrEmpty(path))
{
var currentTags = Tags.GetTags(path);
var tagList = currentTags.Count > 0
? string.Join(", ", currentTags.Select(t => $"[{t}]"))
: "태그 없음";
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 전체 제거: {Path.GetFileName(path)}",
$"{tagList} · Enter로 모두 삭제",
null,
ValueTuple.Create("__TAG_CLEAR__", path),
Symbol: Symbols.Delete)
]);
}
return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx");
}
// ── 태그 검색 또는 전체 목록 ─────────────────────────────────────────
return string.IsNullOrEmpty(q)
? BuildTagListItems()
: BuildTagSearchItems(q);
}
// ─── ExecuteAsync ────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 태그 추가
if (item.Data is ValueTuple<string, string, string> addCmd &&
addCmd.Item1 == "__TAG_ADD__")
{
Tags.AddTag(addCmd.Item3, addCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}");
return Task.CompletedTask;
}
// 태그 제거
if (item.Data is ValueTuple<string, string, string> delCmd &&
delCmd.Item1 == "__TAG_DEL__")
{
Tags.RemoveTag(delCmd.Item3, delCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}");
return Task.CompletedTask;
}
// 전체 태그 제거
if (item.Data is ValueTuple<string, string> clearCmd &&
clearCmd.Item1 == "__TAG_CLEAR__")
{
Tags.ClearTags(clearCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}");
return Task.CompletedTask;
}
// 파일/폴더 열기
if (item.Data is string filePath)
{
try
{
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private static Task<IEnumerable<LauncherItem>> BuildTagListItems()
{
var allTags = Tags.GetAllTags();
if (allTags.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"등록된 태그가 없습니다",
"tag add [태그] [] ",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"사용법 보기",
"tag add work C:\\path\\file.docx / tag work / tag clear C:\\path",
null, null, Symbol: Symbols.Info),
]);
}
var items = allTags
.OrderByDescending(kv => kv.Value)
.ThenBy(kv => kv.Key)
.Take(12)
.Select(kv => new LauncherItem(
$"[{kv.Key}]",
$"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기",
null, null,
Symbol: Symbols.Tag))
.ToList<LauncherItem>();
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static Task<IEnumerable<LauncherItem>> BuildTagSearchItems(string q)
{
var allTags = Tags.GetAllTags();
// 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순
var matchedTags = allTags.Keys
.Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(t => t)
.ToList();
if (!matchedTags.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"[{q}] ",
"tag add [태그] [경로]로 태그를 추가하세요",
null, null, Symbol: Symbols.Info)
]);
}
var items = new List<LauncherItem>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tag in matchedTags.Take(2))
{
var files = Tags.GetFilesByTag(tag);
foreach (var path in files.Take(6))
{
if (!seen.Add(path)) continue;
var isDir = Directory.Exists(path);
var isFile = File.Exists(path);
var symbol = isDir ? Symbols.Folder
: isFile ? Symbols.File
: Symbols.Warning;
var hint = isDir ? "폴더 열기"
: isFile ? "파일 열기"
: "경로를 찾을 수 없음";
items.Add(new LauncherItem(
Path.GetFileName(path),
$"[{tag}] · {path} · {hint}",
null, path,
Symbol: symbol));
}
}
if (!items.Any())
{
items.Add(new LauncherItem(
$"[{q}] 태그 파일 접근 불가",
"파일이 삭제되었거나 경로가 변경되었을 수 있습니다",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static Task<IEnumerable<LauncherItem>> HelpItems(string usage, string example) =>
Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"사용법: {usage}",
example,
null, null, Symbol: Symbols.Info)
]);
}

View File

@@ -0,0 +1,259 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L18-2: 텍스트 케이스 변환 핸들러. "text" 프리픽스로 사용합니다.
///
/// 예: text → 클립보드 텍스트 모든 케이스 변환 목록
/// text camel → camelCase
/// text pascal → PascalCase
/// text snake → snake_case
/// text kebab → kebab-case
/// text slug → url-slug (소문자 + 하이픈)
/// text upper → UPPER CASE
/// text lower → lower case
/// text title → Title Case
/// text sentence → Sentence case
/// text const → SCREAMING_SNAKE_CASE
/// text dot → dot.case
/// text reverse → 문자 순서 뒤집기
/// text trim → 앞뒤 공백·줄바꿈 제거
/// Enter → 결과 복사.
/// </summary>
public partial class TextCaseHandler : IActionHandler
{
public string? Prefix => "text";
public PluginMetadata Metadata => new(
"Text",
"텍스트 케이스 변환 — camelCase · snake_case · PascalCase · slug 등",
"1.0",
"AX");
private record CaseItem(string Name, string Key, Func<string, string> Convert);
private static readonly CaseItem[] Cases =
[
new("camelCase", "camel", ToCamel),
new("PascalCase", "pascal", ToPascal),
new("snake_case", "snake", ToSnake),
new("SCREAMING_SNAKE_CASE", "const", ToConst),
new("kebab-case", "kebab", ToKebab),
new("URL slug", "slug", ToSlug),
new("dot.case", "dot", ToDot),
new("UPPER CASE", "upper", s => s.ToUpperInvariant()),
new("lower case", "lower", s => s.ToLowerInvariant()),
new("Title Case", "title", ToTitle),
new("Sentence case", "sentence", ToSentence),
new("뒤집기 (reverse)", "reverse", s => new string(s.Reverse().ToArray())),
new("공백 정리 (trim)", "trim", s => s.Trim()),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipboard = Clipboard.GetText().Trim();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("텍스트 케이스 변환기",
"클립보드 텍스트를 다양한 케이스로 변환 · text camel / snake / pascal / kebab…",
null, null, Symbol: "\uE8AB"));
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
// 케이스 목록만 안내
foreach (var c in Cases)
items.Add(new LauncherItem($"text {c.Key}", c.Name, null, null, Symbol: "\uE8AB"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 클립보드 텍스트 → 모든 케이스 변환 목록
var preview = clipboard.Length > 30 ? clipboard[..30] + "…" : clipboard;
items.Add(new LauncherItem($"입력: \"{preview}\"", $"{clipboard.Length}자 · 아래에서 선택",
null, null, Symbol: "\uE8AB"));
foreach (var c in Cases)
{
var result = TrySafeConvert(c.Convert, clipboard);
items.Add(new LauncherItem(result, c.Name,
null, ("copy", result), Symbol: "\uE8AB"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 서브커맨드로 특정 케이스 변환
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// 인라인 텍스트 입력 지원: text camel hello world → helloWorld
var inlineText = parts.Length > 1 ? parts[1] : clipboard;
if (string.IsNullOrWhiteSpace(inlineText))
{
items.Add(new LauncherItem("텍스트가 없습니다",
"클립보드에 텍스트를 복사하거나 text camel <직접입력> 형식으로 사용하세요",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var caseItem = Cases.FirstOrDefault(c =>
c.Key.Equals(sub, StringComparison.OrdinalIgnoreCase));
if (caseItem != null)
{
var result = TrySafeConvert(caseItem.Convert, inlineText);
var sourceLabel = parts.Length > 1 ? $"입력: \"{inlineText}\"" : $"클립보드: \"{(inlineText.Length > 30 ? inlineText[..30] + "…" : inlineText)}\"";
items.Add(new LauncherItem(result,
$"{caseItem.Name} · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
items.Add(new LauncherItem(sourceLabel, "원본", null, ("copy", inlineText), Symbol: "\uE8AB"));
// 다른 케이스도 함께 표시
items.Add(new LauncherItem("── 다른 케이스 ──", "", null, null, Symbol: "\uE8AB"));
foreach (var c in Cases.Where(c => c.Key != sub))
{
var r = TrySafeConvert(c.Convert, inlineText);
if (r != result)
items.Add(new LauncherItem(r, c.Name, null, ("copy", r), Symbol: "\uE8AB"));
}
}
else
{
// 알 수 없는 서브커맨드 → 모든 케이스 변환
items.Add(new LauncherItem($"알 수 없는 케이스: '{sub}'",
"camel · pascal · snake · const · kebab · slug · dot · upper · lower · title · sentence · reverse · trim",
null, null, Symbol: "\uE783"));
if (!string.IsNullOrWhiteSpace(clipboard))
{
foreach (var c in Cases)
{
var r = TrySafeConvert(c.Convert, clipboard);
items.Add(new LauncherItem(r, c.Name, null, ("copy", r), Symbol: "\uE8AB"));
}
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Text", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 변환 함수 ─────────────────────────────────────────────────────────────
private static string TrySafeConvert(Func<string, string> fn, string input)
{
try { return fn(input); }
catch { return input; }
}
/// <summary>입력을 단어 토큰 배열로 분리 (공백, 언더스코어, 하이픈, 대문자 경계)</summary>
private static string[] Tokenize(string s)
{
// camelCase/PascalCase 분리
var withSpaces = CamelBoundaryRegex().Replace(s, "$1 $2");
// 구분자 → 공백
var normalized = SeparatorRegex().Replace(withSpaces, " ");
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
private static string ToCamel(string s)
{
var words = Tokenize(s);
if (words.Length == 0) return s;
var sb = new StringBuilder(words[0].ToLowerInvariant());
for (var i = 1; i < words.Length; i++)
sb.Append(char.ToUpperInvariant(words[i][0]) + words[i][1..].ToLowerInvariant());
return sb.ToString();
}
private static string ToPascal(string s)
{
var words = Tokenize(s);
var sb = new StringBuilder();
foreach (var w in words)
sb.Append(char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant());
return sb.ToString();
}
private static string ToSnake(string s) =>
string.Join("_", Tokenize(s).Select(w => w.ToLowerInvariant()));
private static string ToConst(string s) =>
string.Join("_", Tokenize(s).Select(w => w.ToUpperInvariant()));
private static string ToKebab(string s) =>
string.Join("-", Tokenize(s).Select(w => w.ToLowerInvariant()));
private static string ToSlug(string s)
{
var normalized = s.Normalize(NormalizationForm.FormD);
var ascii = new StringBuilder();
foreach (var c in normalized)
if (c < 128) ascii.Append(c);
var slug = SeparatorRegex().Replace(ascii.ToString().ToLowerInvariant(), "-");
slug = NonSlugRegex().Replace(slug, "");
slug = MultipleDashRegex().Replace(slug, "-");
return slug.Trim('-');
}
private static string ToDot(string s) =>
string.Join(".", Tokenize(s).Select(w => w.ToLowerInvariant()));
private static string ToTitle(string s)
{
var words = s.Split(' ');
return string.Join(" ", words.Select(w =>
w.Length == 0 ? w : char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant()));
}
private static string ToSentence(string s)
{
if (string.IsNullOrEmpty(s)) return s;
var lower = s.ToLowerInvariant();
return char.ToUpperInvariant(lower[0]) + lower[1..];
}
[GeneratedRegex(@"([a-z])([A-Z])")]
private static partial Regex CamelBoundaryRegex();
[GeneratedRegex(@"[\s\-_./\\]+")]
private static partial Regex SeparatorRegex();
[GeneratedRegex(@"[^a-z0-9\-]")]
private static partial Regex NonSlugRegex();
[GeneratedRegex(@"-{2,}")]
private static partial Regex MultipleDashRegex();
}

View File

@@ -0,0 +1,259 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다.
///
/// 예: tz → 주요 도시 현재 시각 목록
/// tz seoul → 서울 현재 시각
/// tz new york → 뉴욕 현재 시각
/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환
/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class TimeZoneHandler : IActionHandler
{
public string? Prefix => "tz";
public PluginMetadata Metadata => new(
"TimeZone",
"시간대 변환기 — 주요 도시 현재 시각 · 시각 변환",
"1.0",
"AX");
// ── 주요 도시 시간대 목록 ──────────────────────────────────────────────
private static readonly (string City, string TzId, string Flag, string[] Aliases)[] Cities =
[
("서울", "Korea Standard Time", "🇰🇷", ["seoul", "서울", "부산", "인천", "kst", "한국"]),
("도쿄", "Tokyo Standard Time", "🇯🇵", ["tokyo", "도쿄", "osaka", "오사카", "jst", "일본"]),
("베이징", "China Standard Time", "🇨🇳", ["beijing", "베이징", "상하이", "shanghai", "cst", "중국"]),
("방콕", "SE Asia Standard Time", "🇹🇭", ["bangkok", "방콕", "ict", "태국"]),
("두바이", "Arabian Standard Time", "🇦🇪", ["dubai", "두바이", "gst", "uae"]),
("모스크바", "Russia Time Zone 2", "🇷🇺", ["moscow", "모스크바", "msk", "러시아"]),
("파리", "Romance Standard Time", "🇫🇷", ["paris", "파리", "cet", "프랑스"]),
("런던", "GMT Standard Time", "🇬🇧", ["london", "런던", "gmt", "영국"]),
("뉴욕", "Eastern Standard Time", "🇺🇸", ["new york", "뉴욕", "nyc", "est", "동부"]),
("시카고", "Central Standard Time", "🇺🇸", ["chicago", "시카고", "cst", "중부"]),
("로스앤젤레스","Pacific Standard Time", "🇺🇸", ["los angeles", "la", "로스앤젤레스", "pst", "서부"]),
("시드니", "AUS Eastern Standard Time", "🇦🇺", ["sydney", "시드니", "aest", "호주"]),
("싱가포르", "Singapore Standard Time", "🇸🇬", ["singapore", "싱가포르", "sgt"]),
("뭄바이", "India Standard Time", "🇮🇳", ["mumbai", "뭄바이", "ist", "인도"]),
("도하", "Arab Standard Time", "🇶🇦", ["doha", "도하", "ast", "카타르"]),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
var now = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(q))
{
// 주요 도시 현재 시각 목록
items.Add(new LauncherItem(
"주요 도시 현재 시각",
$"기준: UTC {now:HH:mm}",
null, null, Symbol: "\uE917"));
foreach (var (city, tzId, flag, _) in Cities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city}",
$"{timeStr} ({offsetStr})",
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환
if (q.StartsWith("meeting ") || q.StartsWith("미팅 "))
{
var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : "";
return Task.FromResult<IEnumerable<LauncherItem>>(
BuildMeetingItems(timePart, now));
}
// "HH:mm to <city>" 또는 "HH:mm <city>" 변환 모드
var convResult = TryParseConversion(q, now);
if (convResult != null)
return Task.FromResult<IEnumerable<LauncherItem>>(convResult);
// 도시 검색
var matched = Cities
.Where(c => c.Aliases.Any(a => a.Contains(q)))
.ToList();
if (matched.Count > 0)
{
foreach (var (city, tzId, flag, _) in matched)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
$"UTC {offsetStr}",
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
// 이 도시와 서울의 시차
var seoulOffset = GetOffset("Korea Standard Time");
var cityOffset = GetOffset(tzId);
var diff = (cityOffset - seoulOffset).TotalHours;
var diffStr = diff == 0 ? "서울과 동일" :
diff > 0 ? $"서울보다 +{diff:+0;-0}시간" :
$"서울보다 {diff:+0;-0}시간";
items.Add(new LauncherItem(
diffStr,
"서울(KST) 기준 시차",
null, null, Symbol: "\uE8F4"));
}
}
else
{
// 미인식 → 모든 도시 표시
foreach (var (city, tzId, flag, _) in Cities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy_time", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("시간대", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static (string Time, string Offset) GetCityTime(string tzId, DateTimeOffset utcNow)
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = TimeZoneInfo.ConvertTime(utcNow, tz);
var off = tz.GetUtcOffset(utcNow);
var offStr = $"UTC{(off >= TimeSpan.Zero ? "+" : "")}{off.Hours:D2}:{off.Minutes:D2}";
return (local.ToString("HH:mm (ddd)"), offStr);
}
catch
{
return ("--:--", "");
}
}
private static TimeSpan GetOffset(string tzId)
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
return tz.GetUtcOffset(DateTimeOffset.UtcNow);
}
catch { return TimeSpan.Zero; }
}
private IEnumerable<LauncherItem> BuildMeetingItems(string timePart, DateTimeOffset utcNow)
{
var items = new List<LauncherItem>();
if (!TimeOnly.TryParse(timePart, out var meetingTime))
{
items.Add(new LauncherItem("시각 형식 오류",
"HH:mm 형식으로 입력하세요 (예: tz meeting 10:00)",
null, null, Symbol: "\uE783"));
return items;
}
// 서울 기준으로 날짜+시각 설정
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
var seoulDt = new DateTimeOffset(seoulNow.Date.AddHours(meetingTime.Hour).AddMinutes(meetingTime.Minute),
seoulTz.GetUtcOffset(utcNow));
var seoulUtc = seoulDt.ToUniversalTime();
items.Add(new LauncherItem(
$"🇰🇷 서울 미팅 시각: {meetingTime:HH:mm}",
"주요 도시 동일 시각", null, null, Symbol: "\uE917"));
foreach (var (city, tzId, flag, _) in Cities)
{
if (tzId == "Korea Standard Time") continue;
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr}"),
Symbol: "\uE917"));
}
return items;
}
private IEnumerable<LauncherItem>? TryParseConversion(string q, DateTimeOffset utcNow)
{
// "HH:mm to <city>" 또는 "HH:mm <city>" 패턴
var sep = q.Contains(" to ") ? " to " : q.Contains(' ') ? " " : null;
if (sep == null) return null;
var parts = q.Split(sep, 2);
if (parts.Length < 2) return null;
if (!TimeOnly.TryParse(parts[0].Trim(), out var inputTime)) return null;
var cityQuery = parts[1].Trim().ToLowerInvariant();
var targetCities = Cities
.Where(c => c.Aliases.Any(a => a.Contains(cityQuery)))
.ToList();
if (targetCities.Count == 0) return null;
var items = new List<LauncherItem>();
// 서울 기준으로 입력 시각 해석
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
var seoulDt = new DateTimeOffset(
seoulNow.Date.AddHours(inputTime.Hour).AddMinutes(inputTime.Minute),
seoulTz.GetUtcOffset(utcNow));
var seoulUtc = seoulDt.ToUniversalTime();
items.Add(new LauncherItem(
$"🇰🇷 서울 {inputTime:HH:mm} 기준 변환",
"Enter → 클립보드 복사", null, null, Symbol: "\uE8F4"));
foreach (var (city, tzId, flag, _) in targetCities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr}"),
Symbol: "\uE917"));
}
return items;
}
}

View File

@@ -0,0 +1,270 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-2: 타이머·알람 핸들러. "timer" 프리픽스로 사용합니다.
///
/// 예: timer → 사용법 + 실행 중인 타이머 목록
/// timer 30 → 30초 타이머 (Enter로 시작)
/// timer 5m → 5분 타이머
/// timer 1h30m → 1시간 30분 타이머
/// timer 2h → 2시간 타이머
/// timer 10m30s → 10분 30초 타이머
/// timer stop → 모든 타이머 취소
/// timer stop <id> → 특정 타이머 취소
/// Enter → 타이머 시작 (또는 중단).
/// </summary>
public class TimerHandler : IActionHandler
{
public string? Prefix => "timer";
public PluginMetadata Metadata => new(
"Timer",
"타이머·알람 — 초/분/시간 단위 백그라운드 타이머",
"1.0",
"AX");
// 타이머 레코드
private record TimerEntry(int Id, string Label, TimeSpan Duration, DateTime StartAt, CancellationTokenSource Cts);
// 정적 타이머 레지스트리
private static readonly List<TimerEntry> _timers = [];
private static readonly object _lock = new();
private static int _nextId = 1;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("타이머·알람",
"timer 30 / timer 5m / timer 1h30m / timer stop",
null, null, Symbol: "\uE916"));
items.Add(new LauncherItem("사용법", "timer <시간> · 예: 30(초) / 5m / 1h30m / 2h", null, null, Symbol: "\uE916"));
AddRunningTimers(items);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// stop 명령
if (sub is "stop" or "cancel" or "취소")
{
lock (_lock)
{
if (_timers.Count == 0)
{
items.Add(new LauncherItem("실행 중인 타이머 없음", "취소할 타이머가 없습니다",
null, null, Symbol: "\uE916"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 특정 ID 취소
if (parts.Length >= 2 && int.TryParse(parts[1], out var stopId))
{
var t = _timers.FirstOrDefault(x => x.Id == stopId);
if (t != null)
items.Add(new LauncherItem($"타이머 #{t.Id} '{t.Label}' 취소",
$"Enter로 취소합니다", null, ("stop", stopId.ToString()), Symbol: "\uE916"));
else
items.Add(new LauncherItem($"타이머 #{stopId}를 찾을 수 없습니다",
"timer 명령으로 목록을 확인하세요", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 전체 취소
items.Add(new LauncherItem($"모든 타이머 취소 ({_timers.Count}개)",
"Enter로 모두 취소합니다", null, ("stop_all", ""), Symbol: "\uE916"));
foreach (var t in _timers)
items.Add(new LauncherItem($" #{t.Id} {t.Label}",
$"시작: {t.StartAt:HH:mm:ss} 남은: {Remaining(t)}",
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 시간 파싱 시도
if (TryParseTime(sub, out var duration) && duration > TimeSpan.Zero)
{
var label = FormatDuration(duration);
var endTime = DateTime.Now.Add(duration);
items.Add(new LauncherItem($"⏱ {label} 타이머 시작",
$"완료: {endTime:HH:mm:ss} · Enter로 시작",
null, ("start", DurationToSeconds(duration).ToString()), Symbol: "\uE916"));
items.Add(new LauncherItem($"완료 예정", $"{endTime:HH:mm:ss}", null, null, Symbol: "\uE916"));
items.Add(new LauncherItem($"경과 시간", label, null, null, Symbol: "\uE916"));
// 실행 중 타이머 표시
AddRunningTimers(items);
}
else
{
items.Add(new LauncherItem($"형식 오류: '{q}'",
"예: timer 30 / timer 5m / timer 1h30m / timer stop",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("start", string secStr) when long.TryParse(secStr, out var sec):
{
var duration = TimeSpan.FromSeconds(sec);
var label = FormatDuration(duration);
var cts = new CancellationTokenSource();
int id;
lock (_lock)
{
id = _nextId++;
_timers.Add(new TimerEntry(id, label, duration, DateTime.Now, cts));
}
NotificationService.Notify("Timer", $"⏱ #{id} {label} 타이머 시작됩니다.");
_ = RunTimerAsync(id, label, duration, cts.Token);
break;
}
case ("stop", string idStr) when int.TryParse(idStr, out var stopId):
{
TimerEntry? entry = null;
lock (_lock)
{
entry = _timers.FirstOrDefault(t => t.Id == stopId);
if (entry != null) _timers.Remove(entry);
}
if (entry != null)
{
entry.Cts.Cancel();
NotificationService.Notify("Timer", $"⏹ #{entry.Id} '{entry.Label}' 타이머가 취소되었습니다.");
}
break;
}
case ("stop_all", _):
{
List<TimerEntry> all;
lock (_lock)
{
all = [.._timers];
_timers.Clear();
}
foreach (var t in all) t.Cts.Cancel();
NotificationService.Notify("Timer", $"⏹ 타이머 {all.Count}개 모두 취소됐습니다.");
break;
}
}
return Task.CompletedTask;
}
// ── 타이머 실행 ──────────────────────────────────────────────────────────
private static async Task RunTimerAsync(int id, string label, TimeSpan duration, CancellationToken token)
{
try
{
await Task.Delay(duration, token);
// 완료 — 레지스트리에서 제거
lock (_lock) { _timers.RemoveAll(t => t.Id == id); }
NotificationService.Notify("⏰ 타이머 완료", $"#{id} {label} 타이머가 종료되었습니다!");
}
catch (OperationCanceledException)
{
// 취소됨 — 이미 처리됨
}
}
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
private static bool TryParseTime(string s, out TimeSpan ts)
{
ts = TimeSpan.Zero;
s = s.ToLowerInvariant().Trim();
// 숫자만 → 초
if (long.TryParse(s, out var sec))
{
ts = TimeSpan.FromSeconds(sec);
return true;
}
// 복합 형식: 1h30m20s
long hours = 0, minutes = 0, seconds = 0;
var rem = s;
bool found = false;
if (TryExtractUnit(ref rem, 'h', out var h)) { hours = h; found = true; }
if (TryExtractUnit(ref rem, 'm', out var m)) { minutes = m; found = true; }
if (TryExtractUnit(ref rem, 's', out var sv)){ seconds = sv; found = true; }
if (found && rem.Length == 0)
{
ts = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
return true;
}
return false;
}
private static bool TryExtractUnit(ref string s, char unit, out long value)
{
value = 0;
var idx = s.IndexOf(unit);
if (idx <= 0) return false;
var numStr = s[..idx];
if (!long.TryParse(numStr, out value)) return false;
s = s[(idx + 1)..];
return true;
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalSeconds < 60) return $"{(long)ts.TotalSeconds}초";
if (ts.TotalMinutes < 60)
{
var mins = (long)ts.TotalMinutes;
var secs = (long)(ts.TotalSeconds - mins * 60);
return secs > 0 ? $"{mins}분 {secs}초" : $"{mins}분";
}
var h = (long)ts.TotalHours;
var m = (long)(ts.TotalMinutes - h * 60);
var s = (long)(ts.TotalSeconds - h * 3600 - m * 60);
var result = $"{h}시간";
if (m > 0) result += $" {m}분";
if (s > 0) result += $" {s}초";
return result;
}
private static long DurationToSeconds(TimeSpan ts) => (long)ts.TotalSeconds;
private static string Remaining(TimerEntry t)
{
var elapsed = DateTime.Now - t.StartAt;
var left = t.Duration - elapsed;
if (left <= TimeSpan.Zero) return "완료";
return FormatDuration(left);
}
private static void AddRunningTimers(List<LauncherItem> items)
{
List<TimerEntry> running;
lock (_lock) { running = [.._timers]; }
if (running.Count == 0) return;
items.Add(new LauncherItem($"── 실행 중인 타이머 {running.Count}개 ──", "", null, null, Symbol: "\uE916"));
foreach (var t in running)
{
var left = Remaining(t);
items.Add(new LauncherItem($"#{t.Id} {t.Label}", $"남은 시간: {left} · 시작: {t.StartAt:HH:mm:ss}",
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
}
}
}

View File

@@ -0,0 +1,242 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-3: 팁·할인·분할 계산기 핸들러. "tip" 프리픽스로 사용합니다.
///
/// 예: tip 50000 → 50,000원에 대한 팁 퍼센트별 계산
/// tip 50000 15 → 15% 팁 계산
/// tip 50000 / 4 → 4명 분할
/// tip 50000 15 4 → 15% 팁 포함 4명 분할
/// tip 50000 off 20 → 20% 할인가 계산
/// tip 50000 vat → 부가가치세 10% 계산
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class TipHandler : IActionHandler
{
public string? Prefix => "tip";
public PluginMetadata Metadata => new(
"Tip",
"팁·할인·분할 계산기 — 팁 % · 할인 · VAT · 인원 분할",
"1.0",
"AX");
private static readonly int[] DefaultTipRates = [10, 15, 18, 20, 25];
private static readonly int[] DefaultDiscountRates = [5, 10, 15, 20, 30, 50];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("팁·할인·분할 계산기",
"예: tip 50000 / tip 50000 15 / tip 50000 off 20 / tip 50000 / 4",
null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000", "50,000원 팁 계산", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 15 4", "15% 팁 + 4명 분할", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 off 20", "20% 할인가", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 vat", "VAT 10% 계산", null, null, Symbol: "\uE8F0"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// 금액 파싱 (쉼표 제거)
if (!TryParseAmount(parts[0], out var amount))
{
items.Add(new LauncherItem("금액 형식 오류",
"예: tip 50000 또는 tip 50,000", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 서브커맨드 분기
if (parts.Length >= 2)
{
var sub2 = parts[1].ToLowerInvariant();
// 할인: tip 50000 off 20
if (sub2 is "off" or "discount" or "할인")
{
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? r : 10;
items.AddRange(BuildDiscountItems(amount, (double)rate));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// VAT: tip 50000 vat [rate]
if (sub2 is "vat" or "세금" or "tax")
{
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? (double)r : 10.0;
items.AddRange(BuildVatItems(amount, rate));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 분할: tip 50000 / 4
if (sub2 == "/" && parts.Length >= 3 && TryParseAmount(parts[2], out var people2))
{
items.AddRange(BuildSplitItems(amount, 0, (int)people2));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 팁%: tip 50000 15 또는 tip 50000 15 4
if (TryParseAmount(parts[1], out var tipRate))
{
var people = parts.Length >= 3 && TryParseAmount(parts[2], out var p) ? (int)p : 1;
items.AddRange(BuildTipItems(amount, (double)tipRate, people));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 기본: 팁 퍼센트별 목록
items.AddRange(BuildDefaultTipItems(amount));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Tip", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildDefaultTipItems(decimal amount)
{
yield return new LauncherItem(
$"원금 {FormatKrw(amount)}",
"팁 퍼센트별 합계",
null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
foreach (var rate in DefaultTipRates)
{
var tip = amount * rate / 100;
var total = amount + tip;
yield return new LauncherItem(
$"{rate}% → {FormatKrw(total)}",
$"팁 {FormatKrw(tip)} · 합계 {FormatKrw(total)}",
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
}
// 분할 미리보기
yield return new LauncherItem($"2명 분할", FormatKrw(amount / 2), null, ("copy", FormatKrw(amount / 2)), Symbol: "\uE8F0");
yield return new LauncherItem($"4명 분할", FormatKrw(amount / 4), null, ("copy", FormatKrw(amount / 4)), Symbol: "\uE8F0");
}
private static IEnumerable<LauncherItem> BuildTipItems(decimal amount, double tipPct, int people)
{
var tip = amount * (decimal)tipPct / 100;
var total = amount + tip;
var perPerson = people > 1 ? total / people : total;
yield return new LauncherItem(
$"합계 {FormatKrw(total)}",
$"원금 {FormatKrw(amount)} + 팁 {tipPct}% ({FormatKrw(tip)})",
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
yield return new LauncherItem("원금", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"팁 {tipPct}%", FormatKrw(tip), null, ("copy", FormatKrw(tip)), Symbol: "\uE8F0");
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
if (people > 1)
{
yield return new LauncherItem(
$"{people}명 분할",
$"1인당 {FormatKrw(perPerson)}",
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
}
}
private static IEnumerable<LauncherItem> BuildDiscountItems(decimal amount, double discountPct)
{
var discount = amount * (decimal)discountPct / 100;
var discounted = amount - discount;
yield return new LauncherItem(
$"할인가 {FormatKrw(discounted)}",
$"{discountPct}% 할인 (할인액 {FormatKrw(discount)})",
null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
yield return new LauncherItem("원가", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"할인 {discountPct}%", FormatKrw(discount), null, ("copy", FormatKrw(discount)), Symbol: "\uE8F0");
yield return new LauncherItem("할인가", FormatKrw(discounted), null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
// 다른 할인율 비교
yield return new LauncherItem("── 할인율 비교 ──", "", null, null, Symbol: "\uE8F0");
foreach (var rate in DefaultDiscountRates.Where(r => r != (int)discountPct))
{
var d = amount * rate / 100;
yield return new LauncherItem($"{rate}% → {FormatKrw(amount - d)}",
$"할인 {FormatKrw(d)}", null, ("copy", FormatKrw(amount - d)), Symbol: "\uE8F0");
}
}
private static IEnumerable<LauncherItem> BuildVatItems(decimal amount, double vatRate)
{
var vat = amount * (decimal)vatRate / 100;
var withVat = amount + vat;
var exVat = amount / (1 + (decimal)vatRate / 100);
var vatOnly = amount - exVat;
yield return new LauncherItem(
$"VAT 포함 {FormatKrw(withVat)}",
$"VAT {vatRate}% ({FormatKrw(vat)})",
null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
yield return new LauncherItem($"입력액 (VAT 별도)", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"VAT {vatRate}%", FormatKrw(vat), null, ("copy", FormatKrw(vat)), Symbol: "\uE8F0");
yield return new LauncherItem("VAT 포함 합계", FormatKrw(withVat), null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
yield return new LauncherItem("── 역산 (VAT 포함가 입력 시) ──", "", null, null, Symbol: "\uE8F0");
yield return new LauncherItem("공급가액 (VAT 제외)", $"{FormatKrw(exVat)}", null, ("copy", FormatKrw(exVat)), Symbol: "\uE8F0");
yield return new LauncherItem("VAT 금액", $"{FormatKrw(vatOnly)}", null, ("copy", FormatKrw(vatOnly)), Symbol: "\uE8F0");
}
private static IEnumerable<LauncherItem> BuildSplitItems(decimal amount, double tipPct, int people)
{
if (people <= 0) people = 1;
var tip = tipPct > 0 ? amount * (decimal)tipPct / 100 : 0;
var total = amount + tip;
var perPerson = total / people;
var rounded = Math.Ceiling(perPerson / 100) * 100; // 100원 단위 올림
yield return new LauncherItem(
$"{people}명 분할 1인 {FormatKrw(perPerson)}",
$"합계 {FormatKrw(total)}",
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
yield return new LauncherItem($"1인 (정확)", FormatKrw(perPerson), null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
yield return new LauncherItem($"1인 (100원↑)", FormatKrw(rounded), null, ("copy", FormatKrw(rounded)), Symbol: "\uE8F0");
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool TryParseAmount(string s, out decimal result)
{
result = 0;
s = s.Replace(",", "").Replace("원", "").Trim();
return decimal.TryParse(s, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out result);
}
private static string FormatKrw(decimal amount)
{
if (amount == Math.Floor(amount))
return $"{amount:N0}원";
return $"{amount:N2}원";
}
}

View File

@@ -0,0 +1,250 @@
using System.Text.Json;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다.
///
/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황
/// </summary>
public class TodayHandler : IActionHandler
{
public string? Prefix => "today";
public PluginMetadata Metadata => new(
"오늘",
"오늘 업무 통합 뷰 — 날짜·할일·알림·공휴일",
"1.0",
"AX");
private static readonly string TodoPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "todos.json");
private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
// 2025~2027 주요 공휴일 (CalHandler에 직접 접근 불가하므로 독립 정의)
private static readonly Dictionary<DateOnly, string> Holidays = new()
{
// 2025
{ new DateOnly(2025, 1, 1), "신정" },
{ new DateOnly(2025, 1, 28), "설날연휴" },
{ new DateOnly(2025, 1, 29), "설날" },
{ new DateOnly(2025, 1, 30), "설날연휴" },
{ new DateOnly(2025, 3, 1), "삼일절" },
{ new DateOnly(2025, 3, 3), "대체공휴일" },
{ new DateOnly(2025, 5, 5), "어린이날" },
{ new DateOnly(2025, 5, 6), "부처님오신날" },
{ new DateOnly(2025, 6, 6), "현충일" },
{ new DateOnly(2025, 8, 15), "광복절" },
{ new DateOnly(2025, 10, 3), "개천절" },
{ new DateOnly(2025, 10, 5), "추석연휴" },
{ new DateOnly(2025, 10, 6), "추석" },
{ new DateOnly(2025, 10, 7), "추석연휴" },
{ new DateOnly(2025, 10, 8), "대체공휴일" },
{ new DateOnly(2025, 10, 9), "한글날" },
{ new DateOnly(2025, 12, 25), "크리스마스" },
// 2026
{ new DateOnly(2026, 1, 1), "신정" },
{ new DateOnly(2026, 2, 17), "설날연휴" },
{ new DateOnly(2026, 2, 18), "설날" },
{ new DateOnly(2026, 2, 19), "설날연휴" },
{ new DateOnly(2026, 3, 1), "삼일절" },
{ new DateOnly(2026, 3, 2), "대체공휴일" },
{ new DateOnly(2026, 5, 5), "어린이날" },
{ new DateOnly(2026, 5, 24), "부처님오신날" },
{ new DateOnly(2026, 5, 25), "대체공휴일" },
{ new DateOnly(2026, 6, 6), "현충일" },
{ new DateOnly(2026, 6, 8), "대체공휴일" },
{ new DateOnly(2026, 8, 15), "광복절" },
{ new DateOnly(2026, 8, 17), "대체공휴일" },
{ new DateOnly(2026, 9, 24), "추석연휴" },
{ new DateOnly(2026, 9, 25), "추석" },
{ new DateOnly(2026, 9, 26), "추석연휴" },
{ new DateOnly(2026, 10, 3), "개천절" },
{ new DateOnly(2026, 10, 5), "대체공휴일" },
{ new DateOnly(2026, 10, 9), "한글날" },
{ new DateOnly(2026, 12, 25), "크리스마스" },
// 2027
{ new DateOnly(2027, 1, 1), "신정" },
{ new DateOnly(2027, 2, 7), "설날연휴" },
{ new DateOnly(2027, 2, 8), "설날" },
{ new DateOnly(2027, 2, 9), "설날연휴" },
{ new DateOnly(2027, 3, 1), "삼일절" },
{ new DateOnly(2027, 5, 5), "어린이날" },
{ new DateOnly(2027, 5, 13), "부처님오신날" },
{ new DateOnly(2027, 6, 6), "현충일" },
{ new DateOnly(2027, 6, 7), "대체공휴일" },
{ new DateOnly(2027, 8, 15), "광복절" },
{ new DateOnly(2027, 8, 16), "대체공휴일" },
{ new DateOnly(2027, 9, 13), "추석연휴" },
{ new DateOnly(2027, 9, 14), "추석" },
{ new DateOnly(2027, 9, 15), "추석연휴" },
{ new DateOnly(2027, 10, 3), "개천절" },
{ new DateOnly(2027, 10, 4), "대체공휴일" },
{ new DateOnly(2027, 10, 9), "한글날" },
{ new DateOnly(2027, 12, 25), "크리스마스" },
{ new DateOnly(2027, 12, 27), "대체공휴일" },
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var items = new List<LauncherItem>();
var today = DateOnly.FromDateTime(DateTime.Today);
var now = DateTime.Now;
// ─── 항목1: 날짜 헤더 ────────────────────────────────────────────────
var dow = DayNames[(int)today.DayOfWeek];
var isHol = Holidays.TryGetValue(today, out var holName);
var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
string status;
if (isHol) status = $"공휴일 — {holName}";
else if (isWknd) status = "주말";
else status = "평일 (업무일)";
items.Add(new LauncherItem(
$"{today:yyyy년 MM월 dd일} ({dow}요일)",
status,
null, ("copy", $"{today:yyyy-MM-dd} ({dow}) {status}"),
Symbol: "\uE8BF"));
// ─── 항목2: 할일 ─────────────────────────────────────────────────────
var (pendingCount, recentTitles) = LoadPendingTodos();
var todoSub = pendingCount == 0
? "미완료 할일 없음"
: string.Join(" / ", recentTitles.Take(3));
items.Add(new LauncherItem(
$"미완료 할일 {pendingCount}건",
todoSub,
null, null, Symbol: "\uE762"));
// ─── 항목3: 알림 ─────────────────────────────────────────────────────
var todayReminders = RemindHandler.GetTodayReminders();
if (todayReminders.Count == 0)
{
items.Add(new LauncherItem("오늘 알림 없음",
"remind HH:mm 메시지 로 알림을 설정하세요",
null, null, Symbol: "\uE787"));
}
else
{
var remindSub = string.Join(" / ",
todayReminders.Take(3).Select(r => $"{r.Time:HH:mm} {r.Message}"));
items.Add(new LauncherItem(
$"오늘 알림 {todayReminders.Count}건",
remindSub,
null, null, Symbol: "\uE787"));
}
// ─── 항목4: 다음 공휴일 ──────────────────────────────────────────────
var nextHol = Holidays.Keys
.Where(d => d > today)
.OrderBy(d => d)
.FirstOrDefault();
if (nextHol != default)
{
var diff = nextHol.DayNumber - today.DayNumber;
var nextDow = DayNames[(int)nextHol.DayOfWeek];
items.Add(new LauncherItem(
$"다음 공휴일: {nextHol:MM/dd} ({nextDow}) {Holidays[nextHol]}",
$"D-{diff}일",
null, ("copy", $"{nextHol:yyyy-MM-dd} {Holidays[nextHol]} D-{diff}"),
Symbol: "\uE787"));
}
else
{
items.Add(new LauncherItem("다음 공휴일 정보 없음", "",
null, null, Symbol: "\uE787"));
}
// ─── 항목5: 이번달 잔여 업무일 ───────────────────────────────────────
var lastDay = new DateOnly(today.Year, today.Month,
DateTime.DaysInMonth(today.Year, today.Month));
var remaining = CountWorkdaysFrom(today, lastDay);
var total = CountWorkdays(today.Year, today.Month);
items.Add(new LauncherItem(
$"이번달 잔여 업무일 {remaining}일",
$"{today.Year}년 {today.Month}월 총 업무일: {total}일",
null, null, Symbol: "\uE8BF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("오늘", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 할일 파싱 ────────────────────────────────────────────────────────────
private static (int pending, List<string> titles) LoadPendingTodos()
{
try
{
if (!System.IO.File.Exists(TodoPath)) return (0, []);
var json = System.IO.File.ReadAllText(TodoPath, System.Text.Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Array) return (0, []);
var pending = 0;
var titles = new List<string>();
foreach (var el in root.EnumerateArray())
{
var done = false;
if (el.TryGetProperty("done", out var doneProp))
done = doneProp.GetBoolean();
if (done) continue;
pending++;
if (titles.Count < 3 && el.TryGetProperty("text", out var textProp))
titles.Add(textProp.GetString() ?? "");
}
return (pending, titles);
}
catch { return (0, []); }
}
// ── 업무일 계산 ──────────────────────────────────────────────────────────
private static bool IsHoliday(DateOnly d) =>
Holidays.ContainsKey(d) ||
d.DayOfWeek == DayOfWeek.Saturday ||
d.DayOfWeek == DayOfWeek.Sunday;
private static int CountWorkdays(int year, int month)
{
var days = DateTime.DaysInMonth(year, month);
var count = 0;
for (var i = 1; i <= days; i++)
if (!IsHoliday(new DateOnly(year, month, i))) count++;
return count;
}
private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
{
var count = 0;
var cur = from;
while (cur <= to)
{
if (!IsHoliday(cur)) count++;
cur = cur.AddDays(1);
}
return count;
}
}

View File

@@ -0,0 +1,260 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L16-3: 간단 할 일 목록 핸들러. "todo" 프리픽스로 사용합니다.
///
/// 예: todo → 전체 할 일 목록
/// todo 보고서 작성 → 새 항목 추가
/// todo done 1 → 1번 항목 완료 처리
/// todo del 1 → 1번 항목 삭제
/// todo clear → 완료 항목 모두 삭제
/// todo clear all → 전체 삭제
/// todo <검색어> → 키워드 필터
/// Enter → 완료 토글 또는 항목 삭제.
/// 저장: %APPDATA%\AxCopilot\todos.json
/// </summary>
public class TodoHandler : IActionHandler
{
public string? Prefix => "todo";
public PluginMetadata Metadata => new(
"Todo",
"할 일 목록 — 추가 · 완료 · 삭제 · 검색",
"1.0",
"AX");
private static readonly string DataPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "todos.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private record TodoItem(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("done")] bool Done,
[property: JsonPropertyName("at")] string CreatedAt);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var todos = LoadTodos();
if (string.IsNullOrWhiteSpace(q))
{
var pending = todos.Count(t => !t.Done);
var completed = todos.Count(t => t.Done);
items.Add(new LauncherItem(
$"할 일 {pending}개 완료 {completed}개",
"todo <내용> → 추가 / todo done <번호> → 완료 / todo del <번호> → 삭제",
null, null, Symbol: "\uE762"));
if (todos.Count == 0)
{
items.Add(new LauncherItem("할 일이 없습니다", "todo <내용> 을 입력하면 추가됩니다",
null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 미완료 먼저, 완료 항목은 하단
foreach (var t in todos.Where(t => !t.Done))
items.Add(MakeTodoItem(t));
if (completed > 0)
{
items.Add(new LauncherItem("── 완료됨 ──", $"{completed}개 / todo clear → 정리",
null, ("clear_done", ""), Symbol: "\uE762"));
foreach (var t in todos.Where(t => t.Done))
items.Add(MakeTodoItem(t));
}
items.Add(new LauncherItem("완료 항목 삭제", "todo clear — 완료 항목 정리",
null, ("clear_done", ""), Symbol: "\uE762"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// done / check / complete
if (sub is "done" or "check" or "complete" or "✓")
{
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
if (num < 0)
{
items.Add(new LauncherItem("번호를 입력하세요", "예: todo done 2", null, null, Symbol: "\uE783"));
}
else
{
var target = todos.FirstOrDefault(t => t.Id == num);
if (target == null)
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
else
items.Add(new LauncherItem(
target.Done ? $"#{num} 미완료로 되돌리기" : $"#{num} 완료 처리",
target.Text, null, ("toggle", num.ToString()), Symbol: "\uE762"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// del / delete / remove / rm
if (sub is "del" or "delete" or "remove" or "rm")
{
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
if (num < 0)
{
items.Add(new LauncherItem("번호를 입력하세요", "예: todo del 3", null, null, Symbol: "\uE783"));
}
else
{
var target = todos.FirstOrDefault(t => t.Id == num);
if (target == null)
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
else
items.Add(new LauncherItem($"#{num} 삭제",
target.Text, null, ("delete", num.ToString()), Symbol: "\uE762"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// clear — 완료 항목 삭제 / clear all → 전체 삭제
if (sub == "clear")
{
var isAll = parts.Length > 1 && parts[1].ToLowerInvariant() == "all";
if (isAll)
items.Add(new LauncherItem("전체 삭제", $"할 일 {todos.Count}개 모두 삭제 · Enter 실행",
null, ("clear_all", ""), Symbol: "\uE762"));
else
items.Add(new LauncherItem("완료 항목 삭제",
$"완료 {todos.Count(t => t.Done)}개 삭제 · Enter 실행",
null, ("clear_done", ""), Symbol: "\uE762"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 숫자만 → 완료 토글 단축
if (int.TryParse(q, out var idNum))
{
var target = todos.FirstOrDefault(t => t.Id == idNum);
if (target != null)
{
items.Add(new LauncherItem(
target.Done ? $"#{idNum} 미완료로 되돌리기" : $"#{idNum} 완료 처리",
target.Text, null, ("toggle", idNum.ToString()), Symbol: "\uE762"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 검색 또는 새 항목 추가
var filtered = todos.Where(t => t.Text.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count > 0)
{
items.Add(new LauncherItem($"'{q}' 검색 결과 {filtered.Count}개", "", null, null, Symbol: "\uE762"));
foreach (var t in filtered)
items.Add(MakeTodoItem(t));
}
// 새 항목 추가 제안
items.Add(new LauncherItem($"새 할 일 추가: {q}",
"Enter → 목록에 추가",
null, ("add", q), Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
var todos = LoadTodos();
switch (item.Data)
{
case ("add", string text):
var nextId = todos.Count > 0 ? todos.Max(t => t.Id) + 1 : 1;
todos.Add(new TodoItem(nextId, text, false,
DateTime.Now.ToString("yyyy-MM-dd HH:mm")));
SaveTodos(todos);
NotificationService.Notify("Todo", $"추가됨: {text}");
break;
case ("toggle", string idStr) when int.TryParse(idStr, out var id):
var idx = todos.FindIndex(t => t.Id == id);
if (idx >= 0)
{
todos[idx] = todos[idx] with { Done = !todos[idx].Done };
SaveTodos(todos);
var state = todos[idx].Done ? "완료" : "미완료";
NotificationService.Notify("Todo", $"#{id} {state}");
}
break;
case ("delete", string idStr) when int.TryParse(idStr, out var id):
var before = todos.Count;
todos.RemoveAll(t => t.Id == id);
if (todos.Count < before)
{
SaveTodos(todos);
NotificationService.Notify("Todo", $"#{id} 삭제됨");
}
break;
case ("clear_done", _):
var doneCount = todos.RemoveAll(t => t.Done);
SaveTodos(todos);
NotificationService.Notify("Todo", $"완료 항목 {doneCount}개 삭제됨");
break;
case ("clear_all", _):
SaveTodos(new List<TodoItem>());
NotificationService.Notify("Todo", "전체 삭제됨");
break;
}
return Task.CompletedTask;
}
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
private static List<TodoItem> LoadTodos()
{
try
{
if (!System.IO.File.Exists(DataPath)) return new List<TodoItem>();
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
return JsonSerializer.Deserialize<List<TodoItem>>(json, JsonOpts) ?? new List<TodoItem>();
}
catch { return new List<TodoItem>(); }
}
private static void SaveTodos(List<TodoItem> todos)
{
try
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
System.IO.File.WriteAllText(DataPath,
JsonSerializer.Serialize(todos, JsonOpts),
System.Text.Encoding.UTF8);
}
catch { /* 비핵심 */ }
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static LauncherItem MakeTodoItem(TodoItem t)
{
var icon = t.Done ? "\uE73E" : "\uECC5";
var prefix = t.Done ? $"[✓] #{t.Id}" : $"[ ] #{t.Id}";
var subtitle = $"{t.CreatedAt} · done {t.Id} = 완료 / del {t.Id} = 삭제";
return new LauncherItem($"{prefix} {t.Text}", subtitle,
null, ("toggle", t.Id.ToString()), Symbol: icon);
}
}

View File

@@ -0,0 +1,372 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L21-1: TOML 파서·분석기 핸들러. "toml" 프리픽스로 사용합니다.
///
/// 예: toml → 클립보드 TOML 전체 키 목록
/// toml validate → 유효성 검사
/// toml keys → 최상위 키 목록
/// toml get key → 특정 키 값 조회
/// toml get server.port → 점 표기법 중첩 키 조회
/// toml stats → 줄·키·섹션·배열 통계
/// toml flat → 점 표기법 평탄화 (모든 키·값)
/// toml sections → [section] 목록
/// Enter → 값 복사.
/// </summary>
public partial class TomlHandler : IActionHandler
{
public string? Prefix => "toml";
public PluginMetadata Metadata => new(
"TOML",
"TOML 파서·분석기 — 키 조회·유효성 검사·평탄화",
"1.0",
"AX");
// TOML 노드 (경량 표현)
private sealed class TomlTable : Dictionary<string, object?> { }
private sealed class TomlArray : List<object?> { }
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText().Trim();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("TOML 파서·분석기",
"클립보드에 TOML을 복사하세요 · toml validate / keys / get / stats / flat",
null, null, Symbol: "\uE8EC"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 기본: 유효성 확인 + 최상위 키
var (tbl, err) = ParseToml(clipboard!);
if (err != null)
{
items.Add(ErrorItem($"TOML 파싱 오류: {err}"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem("TOML 파싱 성공 ✓",
$"최상위 키 {tbl!.Count}개 · toml get / flat / stats", null, null, Symbol: "\uE8EC"));
BuildTopKeys(items, tbl!);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
var src = parts.Length > 1 && (sub != "get")
? string.Join(" ", parts[1..])
: clipboard ?? "";
// validate
if (sub is "validate" or "check" or "검사")
{
var text = clipboard ?? "";
var (_, verr) = ParseToml(text);
if (verr != null)
items.Add(ErrorItem($"유효성 오류: {verr}"));
else
{
var (vt, _) = ParseToml(text);
items.Add(new LauncherItem("✓ 유효한 TOML", $"최상위 키 {vt!.Count}개", null, null, Symbol: "\uE8EC"));
BuildTopKeys(items, vt!);
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var (table, parseErr) = ParseToml(clipboard ?? "");
if (parseErr != null && sub != "validate")
{
items.Add(ErrorItem($"TOML 파싱 오류: {parseErr}"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (sub)
{
case "keys" or "key":
items.Add(new LauncherItem("최상위 키 목록", $"{table!.Count}개", null, null, Symbol: "\uE8EC"));
BuildTopKeys(items, table!);
break;
case "sections" or "section":
{
var secs = table!.Where(kv => kv.Value is TomlTable).Select(kv => kv.Key).ToList();
items.Add(new LauncherItem($"섹션 {secs.Count}개", "", null, null, Symbol: "\uE8EC"));
foreach (var s in secs)
{
var secCount = table![s] is TomlTable st ? st.Count : 0;
items.Add(new LauncherItem($"[{s}]",
$"{secCount}개 키", null, ("copy", s), Symbol: "\uE8EC"));
}
break;
}
case "get":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: toml get server.port")); break; }
var keyPath = parts[1];
var val = GetByPath(table!, keyPath);
if (val == null)
items.Add(new LauncherItem($"'{keyPath}' 키를 찾을 수 없습니다", "", null, null, Symbol: "\uE8EC"));
else
{
var strVal = TomlValueToString(val);
items.Add(new LauncherItem($"{keyPath} = {TruncateStr(strVal, 60)}",
"Enter 복사", null, ("copy", strVal), Symbol: "\uE8EC"));
items.Add(CopyItem("값", strVal));
items.Add(CopyItem("키 경로", keyPath));
items.Add(CopyItem("타입", GetTomlType(val)));
}
break;
}
case "stats":
{
var flat = new Dictionary<string, object?>();
FlattenTable(table!, "", flat);
var lines = (clipboard ?? "").Split('\n').Length;
var arrays = flat.Values.Count(v => v is TomlArray);
var tables2 = flat.Values.Count(v => v is TomlTable);
var scalars = flat.Count - arrays - tables2;
items.Add(new LauncherItem("TOML 통계", "", null, null, Symbol: "\uE8EC"));
items.Add(CopyItem("전체 줄", lines.ToString()));
items.Add(CopyItem("최상위 키", table!.Count.ToString()));
items.Add(CopyItem("전체 키(flat)", flat.Count.ToString()));
items.Add(CopyItem("스칼라 값", scalars.ToString()));
items.Add(CopyItem("배열", arrays.ToString()));
items.Add(CopyItem("섹션(테이블)", table.Values.Count(v => v is TomlTable).ToString()));
break;
}
case "flat" or "flatten":
{
var flat = new Dictionary<string, object?>();
FlattenTable(table!, "", flat);
var all = string.Join("\n", flat.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}"));
items.Add(new LauncherItem($"평탄화 결과 {flat.Count}개",
"전체 복사 → Enter", null, ("copy", all), Symbol: "\uE8EC"));
foreach (var (k, v) in flat.Take(20))
items.Add(new LauncherItem($"{k} = {TruncateStr(TomlValueToString(v), 50)}",
GetTomlType(v), null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
if (flat.Count > 20)
items.Add(new LauncherItem($"... ({flat.Count - 20}개 더)", "전체는 Enter로 복사",
null, null, Symbol: "\uE8EC"));
break;
}
default:
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
"validate · keys · sections · get <key> · stats · flat",
null, null, Symbol: "\uE783"));
break;
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("TOML", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 경량 TOML 파서 ────────────────────────────────────────────────────
private static (TomlTable? table, string? error) ParseToml(string src)
{
var root = new TomlTable();
var current = root;
var lines = src.Split('\n');
string? parseError = null;
for (int lineNo = 0; lineNo < lines.Length; lineNo++)
{
var raw = lines[lineNo];
var line = StripComment(raw).Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
// [section] 또는 [[array-of-tables]]
if (line.StartsWith("[["))
{
var name = line.Trim('[', ']').Trim();
// 배열 섹션 처리 (간략화: 마지막 테이블만 유지)
var parts = name.Split('.');
current = root;
foreach (var p in parts)
{
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
{ child = new TomlTable(); current[p] = child; }
current = (TomlTable)current[p]!;
}
}
else if (line.StartsWith("["))
{
var name = line.Trim('[', ']').Trim();
var parts = name.Split('.');
current = root;
foreach (var p in parts)
{
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
{ child = new TomlTable(); current[p] = child; }
current = (TomlTable)current[p]!;
}
}
// key = value
else if (line.Contains('='))
{
var eqIdx = line.IndexOf('=');
var key = line[..eqIdx].Trim().Trim('"');
var val = line[(eqIdx + 1)..].Trim();
try { current[key] = ParseTomlValue(val); }
catch (Exception ex)
{ parseError ??= $"줄 {lineNo + 1}: {ex.Message}"; }
}
}
return parseError != null ? (null, parseError) : (root, null);
}
private static object? ParseTomlValue(string val)
{
if (val.StartsWith('"') || val.StartsWith('\''))
return val.Trim('"', '\'');
if (val.StartsWith('['))
{
var arr = new TomlArray();
var inner = val.Trim('[', ']');
foreach (var item in inner.Split(','))
{
var t = item.Trim();
if (!string.IsNullOrEmpty(t)) arr.Add(ParseTomlValue(t));
}
return arr;
}
if (val.StartsWith('{'))
{
var tbl = new TomlTable();
var inner = val.Trim('{', '}');
foreach (var pair in inner.Split(','))
{
var parts = pair.Split('=', 2);
if (parts.Length == 2)
tbl[parts[0].Trim().Trim('"')] = ParseTomlValue(parts[1].Trim());
}
return tbl;
}
if (val is "true") return true;
if (val is "false") return false;
if (long.TryParse(val, out var lv)) return lv;
if (double.TryParse(val, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var dv)) return dv;
return val.Trim('"', '\'');
}
private static string StripComment(string line)
{
// '#' 앞에 따옴표가 홀수개인 경우 내부 → 유지, 아니면 제거
bool inStr = false;
char quote = '"';
for (int i = 0; i < line.Length; i++)
{
var c = line[i];
if (!inStr && (c == '"' || c == '\'')) { inStr = true; quote = c; }
else if (inStr && c == quote) inStr = false;
else if (!inStr && c == '#') return line[..i];
}
return line;
}
private static object? GetByPath(TomlTable table, string path)
{
var parts = path.Split('.');
object? cur = table;
foreach (var p in parts)
{
if (cur is TomlTable t && t.TryGetValue(p, out var next)) cur = next;
else return null;
}
return cur;
}
private static void FlattenTable(TomlTable table, string prefix, Dictionary<string, object?> result)
{
foreach (var (k, v) in table)
{
var key = string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}";
if (v is TomlTable child) FlattenTable(child, key, result);
else result[key] = v;
}
}
private static void BuildTopKeys(List<LauncherItem> items, TomlTable table)
{
foreach (var (k, v) in table)
{
var type = GetTomlType(v);
var disp = v is TomlTable t ? $"{{ {t.Count}개 키 }}" :
v is TomlArray a ? $"[ {a.Count}개 항목 ]" :
TruncateStr(TomlValueToString(v), 50);
items.Add(new LauncherItem($"{k} = {disp}", type,
null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
}
}
private static string TomlValueToString(object? v) => v switch
{
null => "null",
bool b => b ? "true" : "false",
TomlTable t => "{" + string.Join(", ", t.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}")) + "}",
TomlArray a => "[" + string.Join(", ", a.Select(TomlValueToString)) + "]",
_ => v.ToString() ?? ""
};
private static string GetTomlType(object? v) => v switch
{
null => "null",
bool => "Boolean",
long => "Integer",
double => "Float",
string => "String",
TomlTable => "Table",
TomlArray => "Array",
_ => v.GetType().Name
};
private static string TruncateStr(string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8EC");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}

View File

@@ -0,0 +1,344 @@
using System.Globalization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-4: 유니코드 문자 조회 핸들러. "unicode" 프리픽스로 사용합니다.
///
/// 예: unicode A → 문자 'A'의 코드포인트·카테고리·이름 조회
/// unicode U+1F600 → 코드포인트로 문자 조회
/// unicode 0x1F600 → 16진수 코드포인트
/// unicode 128512 → 10진수 코드포인트
/// unicode 가 → 한글 문자 분석
/// unicode smile → 문자 설명으로 검색 (이모지 이름 포함)
/// Enter → 문자를 클립보드에 복사.
/// </summary>
public class UnicodeHandler : IActionHandler
{
public string? Prefix => "unicode";
public PluginMetadata Metadata => new(
"Unicode",
"유니코드 문자 조회 — 코드포인트 · 카테고리 · 블록",
"1.0",
"AX");
// 주요 유니코드 블록 범위
private static readonly (int Start, int End, string Name)[] UnicodeBlocks =
[
(0x0000, 0x007F, "Basic Latin"),
(0x0080, 0x00FF, "Latin-1 Supplement"),
(0x0100, 0x017F, "Latin Extended-A"),
(0x0370, 0x03FF, "Greek and Coptic"),
(0x0400, 0x04FF, "Cyrillic"),
(0x0600, 0x06FF, "Arabic"),
(0x0900, 0x097F, "Devanagari"),
(0x1100, 0x11FF, "Hangul Jamo"),
(0x2000, 0x206F, "General Punctuation"),
(0x2100, 0x214F, "Letterlike Symbols"),
(0x2200, 0x22FF, "Mathematical Operators"),
(0x2300, 0x23FF, "Miscellaneous Technical"),
(0x2600, 0x26FF, "Miscellaneous Symbols"),
(0x2700, 0x27BF, "Dingbats"),
(0x3000, 0x303F, "CJK Symbols and Punctuation"),
(0x3040, 0x309F, "Hiragana"),
(0x30A0, 0x30FF, "Katakana"),
(0x4E00, 0x9FFF, "CJK Unified Ideographs"),
(0xAC00, 0xD7AF, "Hangul Syllables"),
(0xE000, 0xF8FF, "Private Use Area"),
(0xF000, 0xF0FF, "Segoe MDL2 Assets (PUA)"),
(0x1F300, 0x1F5FF, "Miscellaneous Symbols and Pictographs"),
(0x1F600, 0x1F64F, "Emoticons"),
(0x1F680, 0x1F6FF, "Transport and Map Symbols"),
(0x1F900, 0x1F9FF, "Supplemental Symbols and Pictographs"),
];
// 자주 쓰는 특수 문자 예제
private static readonly (string Char, string Desc)[] QuickChars =
[
("©", "Copyright Sign (U+00A9)"),
("®", "Registered Sign (U+00AE)"),
("™", "Trade Mark Sign (U+2122)"),
("•", "Bullet (U+2022)"),
("→", "Rightwards Arrow (U+2192)"),
("←", "Leftwards Arrow (U+2190)"),
("✓", "Check Mark (U+2713)"),
("✗", "Ballot X (U+2717)"),
("★", "Black Star (U+2605)"),
("♥", "Black Heart Suit (U+2665)"),
("😀", "Grinning Face (U+1F600)"),
("한", "Korean Syllable (AC00~D7AF)"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("유니코드 문자 조회",
"예: unicode A / unicode U+1F600 / unicode 가 / unicode 0x2665",
null, null, Symbol: "\uE8D2"));
foreach (var (ch, desc) in QuickChars.Take(8))
items.Add(new LauncherItem(ch, desc, null, ("copy", ch), Symbol: "\uE8D2"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// U+XXXX 형식
if (q.StartsWith("U+", StringComparison.OrdinalIgnoreCase) ||
q.StartsWith("u+", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp))
items.AddRange(BuildCodePointItems(cp));
else
items.Add(new LauncherItem("형식 오류", "예: U+1F600", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 0x 16진수
if (q.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ||
q.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp))
items.AddRange(BuildCodePointItems(cp));
else
items.Add(new LauncherItem("형식 오류", "예: 0x1F600", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 순수 10진수 코드포인트
if (int.TryParse(q, out var decCp) && decCp >= 0)
{
items.AddRange(BuildCodePointItems(decCp));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 문자(1~2자) 직접 입력 → 분석
var codePoints = GetCodePoints(q);
if (codePoints.Count > 0 && codePoints.Count <= 6)
{
if (codePoints.Count == 1)
{
items.AddRange(BuildCodePointItems(codePoints[0]));
}
else
{
// 여러 문자 일괄 분석
items.Add(new LauncherItem($"'{q}' {codePoints.Count}개 코드포인트", "전체 분석", null, null, Symbol: "\uE8D2"));
foreach (var cp in codePoints)
items.AddRange(BuildCodePointItems(cp));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 6자 초과 → 통계만
if (codePoints.Count > 0)
{
items.Add(new LauncherItem(
$"'{(q.Length > 10 ? q[..10] + "" : q)}' {codePoints.Count}개 코드포인트",
$"범위: U+{codePoints.Min():X4} ~ U+{codePoints.Max():X4}",
null,
("copy", string.Join(" ", codePoints.Select(c => $"U+{c:X4}"))),
Symbol: "\uE8D2"));
}
else
{
items.Add(new LauncherItem("조회 실패",
$"'{q}'을(를) 인식할 수 없습니다. 예: unicode A / unicode U+1F600",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Unicode", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 코드포인트 분석 ────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildCodePointItems(int codePoint)
{
if (codePoint < 0 || codePoint > 0x10FFFF)
{
yield return new LauncherItem("범위 초과", "유효한 유니코드 범위: U+0000 ~ U+10FFFF", null, null, Symbol: "\uE783");
yield break;
}
var charStr = char.ConvertFromUtf32(codePoint);
var category = GetCategoryName(charStr);
var block = GetBlock(codePoint);
var name = GetCharName(codePoint);
var display = codePoint < 32 || (codePoint >= 127 && codePoint < 160) ? $"(제어문자 U+{codePoint:X4})" : charStr;
yield return new LauncherItem(
display,
$"U+{codePoint:X4} · {name}",
null,
("copy", charStr),
Symbol: "\uE8D2");
yield return new LauncherItem("코드포인트", $"U+{codePoint:X4}", null, ("copy", $"U+{codePoint:X4}"), Symbol: "\uE8D2");
yield return new LauncherItem("10진수", $"{codePoint}", null, ("copy", codePoint.ToString()), Symbol: "\uE8D2");
yield return new LauncherItem("HTML 엔티티", $"&#{codePoint};", null, ("copy", $"&#{codePoint};"), Symbol: "\uE8D2");
yield return new LauncherItem("HTML Hex", $"&#x{codePoint:X};", null, ("copy", $"&#x{codePoint:X};"), Symbol: "\uE8D2");
// UTF-8 바이트
var utf8 = System.Text.Encoding.UTF8.GetBytes(charStr);
var utf8Hex = string.Join(" ", utf8.Select(b => $"{b:X2}"));
yield return new LauncherItem("UTF-8", utf8Hex, null, ("copy", utf8Hex), Symbol: "\uE8D2");
// UTF-16
var utf16 = System.Text.Encoding.Unicode.GetBytes(charStr);
var utf16Hex = string.Join(" ", utf16.Select(b => $"{b:X2}"));
yield return new LauncherItem("UTF-16 LE", utf16Hex, null, ("copy", utf16Hex), Symbol: "\uE8D2");
yield return new LauncherItem("카테고리", category, null, null, Symbol: "\uE8D2");
yield return new LauncherItem("블록", block, null, null, Symbol: "\uE8D2");
// 한글 음절이면 분해
if (codePoint >= 0xAC00 && codePoint <= 0xD7A3)
{
var (initial, vowel, final) = DecomposeHangul(codePoint);
yield return new LauncherItem("초성", initial, null, ("copy", initial), Symbol: "\uE8D2");
yield return new LauncherItem("중성", vowel, null, ("copy", vowel), Symbol: "\uE8D2");
if (!string.IsNullOrEmpty(final))
yield return new LauncherItem("종성", final, null, ("copy", final), Symbol: "\uE8D2");
}
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static List<int> GetCodePoints(string s)
{
var result = new List<int>();
for (var i = 0; i < s.Length; )
{
var cp = char.ConvertToUtf32(s, i);
result.Add(cp);
i += char.IsSurrogatePair(s, i) ? 2 : 1;
}
return result;
}
private static string GetCategoryName(string charStr)
{
if (string.IsNullOrEmpty(charStr)) return "Unknown";
var cat = char.GetUnicodeCategory(charStr, 0);
return cat switch
{
UnicodeCategory.UppercaseLetter => "대문자 (Lu)",
UnicodeCategory.LowercaseLetter => "소문자 (Ll)",
UnicodeCategory.TitlecaseLetter => "타이틀케이스 (Lt)",
UnicodeCategory.ModifierLetter => "수정 문자 (Lm)",
UnicodeCategory.OtherLetter => "기타 문자 (Lo)",
UnicodeCategory.DecimalDigitNumber => "10진수 숫자 (Nd)",
UnicodeCategory.LetterNumber => "문자 숫자 (Nl)",
UnicodeCategory.OtherNumber => "기타 숫자 (No)",
UnicodeCategory.SpaceSeparator => "공백 (Zs)",
UnicodeCategory.LineSeparator => "줄 구분자 (Zl)",
UnicodeCategory.ParagraphSeparator => "단락 구분자 (Zp)",
UnicodeCategory.Control => "제어 문자 (Cc)",
UnicodeCategory.MathSymbol => "수학 기호 (Sm)",
UnicodeCategory.CurrencySymbol => "통화 기호 (Sc)",
UnicodeCategory.ModifierSymbol => "수정 기호 (Sk)",
UnicodeCategory.OtherSymbol => "기타 기호 (So)",
UnicodeCategory.OpenPunctuation => "여는 구두점 (Ps)",
UnicodeCategory.ClosePunctuation => "닫는 구두점 (Pe)",
UnicodeCategory.DashPunctuation => "대시 구두점 (Pd)",
UnicodeCategory.ConnectorPunctuation => "연결 구두점 (Pc)",
UnicodeCategory.OtherPunctuation => "기타 구두점 (Po)",
_ => cat.ToString(),
};
}
private static string GetBlock(int cp)
{
foreach (var (start, end, name) in UnicodeBlocks)
if (cp >= start && cp <= end) return $"{name} (U+{start:X4}~U+{end:X4})";
if (cp >= 0x10000) return $"Supplementary Planes (U+{cp:X4})";
return $"U+{cp:X4} 범위 불명";
}
private static string GetCharName(int cp) => cp switch
{
0x0020 => "Space",
0x0021 => "Exclamation Mark",
0x0022 => "Quotation Mark",
0x0023 => "Number Sign",
0x0024 => "Dollar Sign",
0x0025 => "Percent Sign",
0x0026 => "Ampersand",
0x0027 => "Apostrophe",
0x0028 => "Left Parenthesis",
0x0029 => "Right Parenthesis",
0x002A => "Asterisk",
0x002B => "Plus Sign",
0x002C => "Comma",
0x002D => "Hyphen-Minus",
0x002E => "Full Stop",
0x002F => "Solidus",
>= 0x0030 and <= 0x0039 => $"Digit {(char)cp}",
>= 0x0041 and <= 0x005A => $"Latin Capital Letter {(char)cp}",
>= 0x0061 and <= 0x007A => $"Latin Small Letter {(char)cp}",
0x00A9 => "Copyright Sign",
0x00AE => "Registered Sign",
0x2122 => "Trade Mark Sign",
0x2022 => "Bullet",
0x2192 => "Rightwards Arrow",
0x2190 => "Leftwards Arrow",
0x2191 => "Upwards Arrow",
0x2193 => "Downwards Arrow",
0x2713 => "Check Mark",
0x2717 => "Ballot X",
0x2605 => "Black Star",
0x2606 => "White Star",
0x2665 => "Black Heart Suit",
0x2764 => "Heavy Black Heart",
0x1F600 => "Grinning Face",
0x1F601 => "Grinning Face With Smiling Eyes",
0x1F602 => "Face With Tears of Joy",
0x1F603 => "Smiling Face With Open Mouth",
0x1F609 => "Winking Face",
0x1F60D => "Smiling Face With Heart-Eyes",
0x1F621 => "Pouting Face",
0x1F625 => "Disappointed but Relieved Face",
>= 0xAC00 and <= 0xD7A3 => "Hangul Syllable",
>= 0x1100 and <= 0x11FF => "Hangul Jamo",
>= 0x3131 and <= 0x318E => "Hangul Compatibility Jamo",
>= 0x4E00 and <= 0x9FFF => "CJK Unified Ideograph",
>= 0x3040 and <= 0x309F => "Hiragana",
>= 0x30A0 and <= 0x30FF => "Katakana",
_ => $"U+{cp:X4}",
};
private static (string Initial, string Vowel, string Final) DecomposeHangul(int cp)
{
string[] initials = ["ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ","ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"];
string[] vowels = ["ㅏ","ㅐ","ㅑ","ㅒ","ㅓ","ㅔ","ㅕ","ㅖ","ㅗ","ㅘ","ㅙ","ㅚ","ㅛ","ㅜ","ㅝ","ㅞ","ㅟ","ㅠ","ㅡ","ㅢ","ㅣ"];
string[] finals = ["", "ㄱ","ㄲ","ㄳ","ㄴ","ㄵ","ㄶ","ㄷ","ㄹ","ㄺ","ㄻ","ㄼ","ㄽ","ㄾ","ㄿ","ㅀ","ㅁ","ㅂ","ㅄ","ㅅ","ㅆ","ㅇ","ㅈ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"];
var offset = cp - 0xAC00;
var finIdx = offset % 28;
var vowIdx = (offset / 28) % 21;
var iniIdx = offset / 28 / 21;
return (initials[iniIdx], vowels[vowIdx], finals[finIdx]);
}
}

View File

@@ -0,0 +1,284 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L17-1: 단위 변환기 핸들러. "unit" 프리픽스로 사용합니다.
///
/// 예: unit 100 km m → 100km → m
/// unit 72 f c → 화씨 72°F → 섭씨
/// unit 5 kg lb → 5kg → 파운드
/// unit 1 gb mb → 1GB → MB
/// unit 60 mph kmh → 속도 변환
/// unit length → 길이 단위 목록
/// unit weight / temp / area / speed / data → 카테고리 목록
/// Enter → 결과 복사.
/// </summary>
public class UnitHandler : IActionHandler
{
public string? Prefix => "unit";
public PluginMetadata Metadata => new(
"Unit",
"단위 변환기 — 길이·무게·온도·넓이·속도·데이터",
"1.0",
"AX");
// ── 단위 정의 (기준 단위 → SI 변환 계수) ─────────────────────────────────
// 온도는 별도 처리 (비선형)
private enum UnitCategory { Length, Weight, Area, Speed, Data, Temperature, Pressure, Volume }
private record UnitDef(string[] Names, double ToBase, UnitCategory Cat, string Display);
private static readonly UnitDef[] Units =
[
// 길이 (기준: m)
new(["km","킬로미터"], 1000, UnitCategory.Length, "킬로미터 (km)"),
new(["m","미터"], 1, UnitCategory.Length, "미터 (m)"),
new(["cm","센티미터"], 0.01, UnitCategory.Length, "센티미터 (cm)"),
new(["mm","밀리미터"], 0.001, UnitCategory.Length, "밀리미터 (mm)"),
new(["mi","mile","마일"], 1609.344, UnitCategory.Length, "마일 (mi)"),
new(["yd","yard","야드"], 0.9144, UnitCategory.Length, "야드 (yd)"),
new(["ft","feet","foot","피트"], 0.3048, UnitCategory.Length, "피트 (ft)"),
new(["in","inch","인치"], 0.0254, UnitCategory.Length, "인치 (in)"),
new(["nm","해리"], 1852, UnitCategory.Length, "해리 (nm)"),
// 무게 (기준: kg)
new(["t","ton","톤"], 1000, UnitCategory.Weight, "톤 (t)"),
new(["kg","킬로그램"], 1, UnitCategory.Weight, "킬로그램 (kg)"),
new(["g","그램"], 0.001, UnitCategory.Weight, "그램 (g)"),
new(["mg","밀리그램"], 1e-6, UnitCategory.Weight, "밀리그램 (mg)"),
new(["lb","lbs","파운드"], 0.453592, UnitCategory.Weight, "파운드 (lb)"),
new(["oz","온스"], 0.0283495, UnitCategory.Weight, "온스 (oz)"),
new(["근"], 0.6, UnitCategory.Weight, "근 (600g)"),
// 넓이 (기준: m²)
new(["km2","km²"], 1e6, UnitCategory.Area, "제곱킬로미터 (km²)"),
new(["m2","m²","sqm"], 1, UnitCategory.Area, "제곱미터 (m²)"),
new(["cm2","cm²"], 0.0001, UnitCategory.Area, "제곱센티미터 (cm²)"),
new(["ha","헥타르"], 10000, UnitCategory.Area, "헥타르 (ha)"),
new(["a","아르"], 100, UnitCategory.Area, "아르 (a)"),
new(["acre","에이커"], 4046.856, UnitCategory.Area, "에이커 (acre)"),
new(["ft2","ft²","sqft"], 0.092903, UnitCategory.Area, "제곱피트 (ft²)"),
new(["평"], 3.30579, UnitCategory.Area, "평 (3.3058m²)"),
// 속도 (기준: m/s)
new(["mps","m/s"], 1, UnitCategory.Speed, "미터/초 (m/s)"),
new(["kph","kmh","km/h","kmph"], 0.277778, UnitCategory.Speed, "킬로미터/시 (km/h)"),
new(["mph","mi/h"], 0.44704, UnitCategory.Speed, "마일/시 (mph)"),
new(["knot","kn","노트"], 0.514444, UnitCategory.Speed, "노트 (kn)"),
new(["fps","ft/s"], 0.3048, UnitCategory.Speed, "피트/초 (ft/s)"),
// 데이터 (기준: byte)
new(["b","bit","비트"], 0.125, UnitCategory.Data, "비트 (bit)"),
new(["byte","바이트"], 1, UnitCategory.Data, "바이트 (byte)"),
new(["kb","킬로바이트"], 1024, UnitCategory.Data, "킬로바이트 (KB)"),
new(["mb","메가바이트"], 1048576, UnitCategory.Data, "메가바이트 (MB)"),
new(["gb","기가바이트"], 1073741824, UnitCategory.Data, "기가바이트 (GB)"),
new(["tb","테라바이트"], 1099511627776,UnitCategory.Data, "테라바이트 (TB)"),
new(["pb","페타바이트"], 1.12589990684e15, UnitCategory.Data, "페타바이트 (PB)"),
// 온도 (기준: °C, 변환은 특수 처리)
new(["c","°c","celsius","섭씨"], 1, UnitCategory.Temperature, "섭씨 (°C)"),
new(["f","°f","fahrenheit","화씨"],1, UnitCategory.Temperature, "화씨 (°F)"),
new(["k","kelvin","켈빈"], 1, UnitCategory.Temperature, "켈빈 (K)"),
// 압력 (기준: Pa)
new(["pa","파스칼"], 1, UnitCategory.Pressure, "파스칼 (Pa)"),
new(["kpa"], 1000, UnitCategory.Pressure, "킬로파스칼 (kPa)"),
new(["mpa"], 1e6, UnitCategory.Pressure, "메가파스칼 (MPa)"),
new(["atm","기압"], 101325, UnitCategory.Pressure, "기압 (atm)"),
new(["bar","바"], 100000, UnitCategory.Pressure, "바 (bar)"),
new(["psi"], 6894.757, UnitCategory.Pressure, "PSI (psi)"),
// 부피 (기준: L)
new(["l","liter","리터"], 1, UnitCategory.Volume, "리터 (L)"),
new(["ml","밀리리터"], 0.001, UnitCategory.Volume, "밀리리터 (mL)"),
new(["m3","m³","cbm"], 1000, UnitCategory.Volume, "세제곱미터 (m³)"),
new(["cm3","cm³","cc"], 0.001, UnitCategory.Volume, "세제곱센티미터 (cc)"),
new(["gallon","gal","갤런"], 3.78541, UnitCategory.Volume, "갤런 (US, gal)"),
new(["floz","fl.oz"], 0.0295735, UnitCategory.Volume, "액량온스 (fl.oz)"),
new(["cup","컵"], 0.236588, UnitCategory.Volume, "컵 (cup)"),
];
private static readonly Dictionary<string, UnitCategory> CategoryKeywords =
new(StringComparer.OrdinalIgnoreCase)
{
["length"] = UnitCategory.Length, ["길이"] = UnitCategory.Length,
["weight"] = UnitCategory.Weight, ["무게"] = UnitCategory.Weight,
["mass"] = UnitCategory.Weight,
["area"] = UnitCategory.Area, ["넓이"] = UnitCategory.Area,
["speed"] = UnitCategory.Speed, ["속도"] = UnitCategory.Speed,
["data"] = UnitCategory.Data, ["데이터"]= UnitCategory.Data,
["temp"] = UnitCategory.Temperature, ["온도"] = UnitCategory.Temperature,
["temperature"] = UnitCategory.Temperature,
["pressure"] = UnitCategory.Pressure, ["압력"] = UnitCategory.Pressure,
["volume"] = UnitCategory.Volume, ["부피"] = UnitCategory.Volume,
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("단위 변환기",
"예: unit 100 km m / unit 72 f c / unit 5 kg lb / unit 1 gb mb",
null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("── 카테고리 ──", "", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit length", "길이 단위 (km·m·cm·ft·in·mi)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit weight", "무게 단위 (kg·g·lb·oz·근)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit temp", "온도 단위 (°C·°F·K)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit area", "넓이 단위 (m²·ha·acre·평)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit speed", "속도 단위 (km/h·mph·m/s·knot)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit data", "데이터 단위 (bit·B·KB·MB·GB·TB)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit pressure", "압력 단위 (Pa·atm·bar·psi)", null, null, Symbol: "\uE8EF"));
items.Add(new LauncherItem("unit volume", "부피 단위 (L·mL·m³·gallon·cup)", null, null, Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// 카테고리 목록
if (parts.Length == 1 && CategoryKeywords.TryGetValue(parts[0], out var cat))
{
var catUnits = Units.Where(u => u.Cat == cat).ToList();
items.Add(new LauncherItem($"{cat} 단위 {catUnits.Count}개",
"예: unit 100 km m", null, null, Symbol: "\uE8EF"));
foreach (var u in catUnits)
items.Add(new LauncherItem(u.Display, string.Join(", ", u.Names), null, null, Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 변환: unit <값> <from> <to>
if (parts.Length < 2)
{
items.Add(new LauncherItem("입력 형식",
"unit <값> <단위> [대상단위] 예: unit 100 km m", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (!double.TryParse(parts[0].Replace(",", ""), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var value))
{
items.Add(new LauncherItem("숫자 형식 오류",
"첫 번째 값이 숫자여야 합니다 예: unit 100 km m", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var fromKey = parts[1].ToLowerInvariant();
var fromDef = FindUnit(fromKey);
if (fromDef == null)
{
items.Add(new LauncherItem($"'{parts[1]}' 단위를 찾을 수 없습니다",
"unit length / weight / temp / area / speed / data 로 단위 목록 확인",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 대상 단위 지정
if (parts.Length >= 3)
{
var toKey = parts[2].ToLowerInvariant();
var toDef = FindUnit(toKey);
if (toDef == null)
{
items.Add(new LauncherItem($"'{parts[2]}' 단위를 찾을 수 없습니다",
"", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (fromDef.Cat != toDef.Cat)
{
items.Add(new LauncherItem("카테고리 불일치",
$"{fromDef.Cat} ≠ {toDef.Cat} — 같은 종류끼리만 변환 가능",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var result = Convert(value, fromDef, toDef);
var label = $"{FormatNum(value)} {fromDef.Names[0].ToUpper()} = {FormatNum(result)} {toDef.Names[0].ToUpper()}";
items.Add(new LauncherItem(label, "Enter → 복사", null, ("copy", label), Symbol: "\uE8EF"));
items.Add(new LauncherItem($"{FormatNum(result)} {toDef.Names[0].ToUpper()}", toDef.Display,
null, ("copy", FormatNum(result)), Symbol: "\uE8EF"));
}
else
{
// 같은 카테고리 모든 단위로 변환
var sameCat = Units.Where(u => u.Cat == fromDef.Cat && u != fromDef).ToList();
items.Add(new LauncherItem($"{FormatNum(value)} {fromDef.Names[0].ToUpper()} 변환 결과",
fromDef.Display, null, null, Symbol: "\uE8EF"));
foreach (var toDef in sameCat)
{
var result = Convert(value, fromDef, toDef);
var label = $"{FormatNum(result)} {toDef.Names[0].ToUpper()}";
items.Add(new LauncherItem(label, toDef.Display, null, ("copy", FormatNum(result)), Symbol: "\uE8EF"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Unit", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 변환 로직 ─────────────────────────────────────────────────────────────
private static double Convert(double value, UnitDef from, UnitDef to)
{
if (from.Cat == UnitCategory.Temperature)
return ConvertTemp(value, from.Names[0].ToLowerInvariant(), to.Names[0].ToLowerInvariant());
// 선형 변환: value × from.ToBase / to.ToBase
return value * from.ToBase / to.ToBase;
}
private static double ConvertTemp(double value, string from, string to)
{
// 먼저 °C로
var celsius = from switch
{
"c" or "°c" => value,
"f" or "°f" => (value - 32) * 5 / 9,
"k" => value - 273.15,
_ => value,
};
// °C에서 목표로
return to switch
{
"c" or "°c" => celsius,
"f" or "°f" => celsius * 9 / 5 + 32,
"k" => celsius + 273.15,
_ => celsius,
};
}
private static UnitDef? FindUnit(string key) =>
Units.FirstOrDefault(u => u.Names.Any(n => n.Equals(key, StringComparison.OrdinalIgnoreCase)));
private static string FormatNum(double v)
{
if (double.IsNaN(v) || double.IsInfinity(v)) return v.ToString();
if (Math.Abs(v) >= 1e12 || (Math.Abs(v) < 1e-4 && v != 0))
return v.ToString("E3", System.Globalization.CultureInfo.InvariantCulture);
if (v == Math.Floor(v) && Math.Abs(v) < 1e9)
return $"{v:N0}";
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,300 @@
using System.Security.Cryptography;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L10-2: UUID/GUID 생성기 핸들러. "uuid" 프리픽스로 사용합니다.
///
/// 예: uuid → UUID v4 1개 생성
/// uuid 5 → UUID v4 5개 생성
/// uuid upper → 대문자 UUID 생성
/// uuid v4 → UUID v4 (랜덤)
/// uuid seq → 순차 UUID (시간 기반, 정렬 가능)
/// uuid short → 짧은 고유 ID (8자리 hex)
/// uuid nil → Nil UUID (00000000-…)
/// uuid parse <uuid> → UUID 분석 (버전, 타임스탬프 등)
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class UuidHandler : IActionHandler
{
public string? Prefix => "uuid";
public PluginMetadata Metadata => new(
"UUID",
"UUID/GUID 생성기 — v4 · 순차 · 짧은 ID · 분석",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 기본: v4 1개 생성
var uuid = Guid.NewGuid().ToString();
items.Add(new LauncherItem(
uuid,
"UUID v4 (랜덤) · Enter 복사",
null,
("copy", uuid),
Symbol: "\uF0E2"));
items.Add(new LauncherItem("uuid 5", "5개 생성", null, null, Symbol: "\uF0E2"));
items.Add(new LauncherItem("uuid upper", "대문자 UUID", null, null, Symbol: "\uF0E2"));
items.Add(new LauncherItem("uuid seq", "순차 UUID (정렬가능)", null, null, Symbol: "\uF0E2"));
items.Add(new LauncherItem("uuid short", "짧은 ID (8자리)", null, null, Symbol: "\uF0E2"));
items.Add(new LauncherItem("uuid parse …", "UUID 분석", null, null, Symbol: "\uF0E2"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// "uuid parse <value>"
if (sub == "parse" && parts.Length >= 2)
{
items.AddRange(ParseUuid(string.Join(" ", parts.Skip(1))));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid nil"
if (sub == "nil")
{
var nil = "00000000-0000-0000-0000-000000000000";
items.Add(new LauncherItem(nil, "Nil UUID", null, ("copy", nil), Symbol: "\uF0E2"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid seq"
if (sub == "seq")
{
items.AddRange(GenerateSequential(5));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid short"
if (sub == "short")
{
items.AddRange(GenerateShort(5));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid upper"
if (sub == "upper")
{
var upper = Guid.NewGuid().ToString().ToUpperInvariant();
items.Add(new LauncherItem(upper, "대문자 UUID v4 · Enter 복사", null, ("copy", upper), Symbol: "\uF0E2"));
for (var i = 0; i < 4; i++)
{
var u = Guid.NewGuid().ToString().ToUpperInvariant();
items.Add(new LauncherItem(u, "대문자 UUID v4", null, ("copy", u), Symbol: "\uF0E2"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid v4"
if (sub == "v4")
{
items.AddRange(GenerateV4(5));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "uuid <숫자>" — N개 생성
if (int.TryParse(sub, out var count))
{
count = Math.Clamp(count, 1, 20);
items.AddRange(GenerateV4(count));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// UUID 자체를 입력한 경우 → parse
if (Guid.TryParse(q, out _))
{
items.AddRange(ParseUuid(q));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem("알 수 없는 명령",
"예: uuid / uuid 5 / uuid upper / uuid seq / uuid short / uuid parse",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("UUID", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 생성 헬퍼 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> GenerateV4(int count)
{
var all = new List<string>();
for (var i = 0; i < count; i++)
{
var uuid = Guid.NewGuid().ToString();
all.Add(uuid);
}
if (count > 1)
{
yield return new LauncherItem(
$"UUID v4 {count}개",
"전체 복사: Enter",
null,
("copy", string.Join("\n", all)),
Symbol: "\uF0E2");
}
foreach (var uuid in all)
yield return new LauncherItem(uuid, "UUID v4 · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2");
}
/// <summary>
/// 시간 기반 순차 UUID (UUIDv7 스타일: 밀리초 타임스탬프 + 랜덤 비트).
/// 정렬 가능하고 시간 정보 포함.
/// </summary>
private static IEnumerable<LauncherItem> GenerateSequential(int count)
{
var all = new List<string>();
for (var i = 0; i < count; i++)
{
if (i > 0) System.Threading.Thread.Sleep(1); // 밀리초 차이 보장
var uuid = NewSequentialGuid();
all.Add(uuid);
}
yield return new LauncherItem(
$"순차 UUID {count}개",
"시간 기반 정렬 가능 · 전체 복사: Enter",
null,
("copy", string.Join("\n", all)),
Symbol: "\uF0E2");
foreach (var uuid in all)
yield return new LauncherItem(uuid, "순차 UUID · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2");
}
private static IEnumerable<LauncherItem> GenerateShort(int count)
{
var all = new List<string>();
for (var i = 0; i < count; i++)
all.Add(NewShortId());
yield return new LauncherItem(
$"짧은 ID {count}개",
"8자리 hex · 전체 복사: Enter",
null,
("copy", string.Join("\n", all)),
Symbol: "\uF0E2");
foreach (var id in all)
yield return new LauncherItem(id, "짧은 ID (8자리) · Enter 복사", null, ("copy", id), Symbol: "\uF0E2");
}
private static IEnumerable<LauncherItem> ParseUuid(string raw)
{
raw = raw.Trim();
if (!Guid.TryParse(raw, out var guid))
{
yield return new LauncherItem("UUID 파싱 실패", $"'{raw}'은 유효한 UUID가 아닙니다", null, null, Symbol: "\uE783");
yield break;
}
var bytes = guid.ToByteArray();
var version = (bytes[7] >> 4) & 0x0F;
var variant = (bytes[8] >> 6) & 0x03;
yield return new LauncherItem(
guid.ToString(),
$"버전 {version} · 변형 {variant}",
null,
("copy", guid.ToString()),
Symbol: "\uF0E2");
yield return new LauncherItem("소문자", guid.ToString(), null, ("copy", guid.ToString()), Symbol: "\uF0E2");
yield return new LauncherItem("대문자", guid.ToString().ToUpper(), null, ("copy", guid.ToString().ToUpper()), Symbol: "\uF0E2");
yield return new LauncherItem("중괄호", $"{{{guid}}}", null, ("copy", $"{{{guid}}}"), Symbol: "\uF0E2");
yield return new LauncherItem("대시 없음", guid.ToString("N"), null, ("copy", guid.ToString("N")), Symbol: "\uF0E2");
yield return new LauncherItem("버전", $"UUID v{version}", null, null, Symbol: "\uF0E2");
yield return new LauncherItem("변형", variant == 2 ? "RFC 4122" : variant == 3 ? "Microsoft" : $"변형 {variant}", null, null, Symbol: "\uF0E2");
// 시간 기반 버전 (v1)이면 타임스탬프 복원 시도
if (version == 1)
{
var ts = ExtractV1Timestamp(bytes);
if (ts.HasValue)
yield return new LauncherItem("타임스탬프", ts.Value.ToString("yyyy-MM-dd HH:mm:ss.fff UTC"), null, null, Symbol: "\uF0E2");
}
}
// ── UUID 생성 구현 ────────────────────────────────────────────────────────
/// <summary>UUIDv7 스타일 순차 GUID: 상위 48비트 = Unix ms 타임스탬프, 하위 = 랜덤</summary>
private static string NewSequentialGuid()
{
var ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var rand = RandomNumberGenerator.GetBytes(10);
var bytes = new byte[16];
// 상위 6바이트 = 타임스탬프 (big-endian ms)
bytes[0] = (byte)(ms >> 40);
bytes[1] = (byte)(ms >> 32);
bytes[2] = (byte)(ms >> 24);
bytes[3] = (byte)(ms >> 16);
bytes[4] = (byte)(ms >> 8);
bytes[5] = (byte)(ms);
// 버전 비트 (v7 = 0111)
bytes[6] = (byte)((rand[0] & 0x0F) | 0x70);
bytes[7] = rand[1];
// 변형 비트 (RFC 4122 = 10xx)
bytes[8] = (byte)((rand[2] & 0x3F) | 0x80);
bytes[9] = rand[3];
// 나머지 랜덤
Array.Copy(rand, 4, bytes, 10, 6);
return new Guid(bytes).ToString();
}
private static string NewShortId()
{
var bytes = RandomNumberGenerator.GetBytes(4);
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
}
private static DateTime? ExtractV1Timestamp(byte[] bytes)
{
try
{
// UUID v1: time_low(4) + time_mid(2) + time_hi_version(2)
var timeLow = (long)((uint)((bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0]));
var timeMid = (long)((ushort)((bytes[5] << 8) | bytes[4]));
var timeHigh = (long)((ushort)((bytes[7] << 8) | bytes[6]) & 0x0FFF);
var ticks = (timeHigh << 48) | (timeMid << 32) | timeLow;
// UUID epoch = Oct 15, 1582
var uuidEpoch = new DateTime(1582, 10, 15, 0, 0, 0, DateTimeKind.Utc);
return uuidEpoch.AddTicks(ticks);
}
catch { return null; }
}
}

View File

@@ -0,0 +1,211 @@
using System.Runtime.InteropServices;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L27-2: 시스템 볼륨 제어 핸들러. "vol" 프리픽스로 사용합니다.
///
/// 예: vol → 현재 볼륨 표시
/// vol 50 → 볼륨 50% 설정
/// vol up / down → ±10% 조절
/// vol mute → 음소거 토글
/// Enter → 해당 명령 실행.
/// Windows Core Audio API (IAudioEndpointVolume) COM 인터페이스 사용.
/// </summary>
public class VolHandler : IActionHandler
{
public string? Prefix => "vol";
public PluginMetadata Metadata => new(
"볼륨 제어",
"시스템 볼륨 조절 — 설정·증감·음소거",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 현재 볼륨 읽기
float curLevel;
bool curMuted;
try
{
using var audio = AudioEndpoint.GetDefault();
curLevel = audio.GetVolume();
curMuted = audio.GetMute();
}
catch
{
items.Add(new LauncherItem(
"오디오 장치를 찾을 수 없습니다",
"기본 재생 장치가 연결되어 있는지 확인하세요",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
int pct = (int)Math.Round(curLevel * 100);
var bar = VolumeBar(pct);
var muteLabel = curMuted ? " 🔇 음소거" : "";
var symbol = curMuted ? Symbols.VolumeMute
: pct == 0 ? Symbols.VolumeMute
: pct < 50 ? Symbols.VolumeDown
: Symbols.VolumeUp;
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"현재 볼륨: {pct}%{muteLabel}",
$"{bar} · vol 50 / vol up / vol down / vol mute",
null, null, Symbol: symbol));
items.Add(new LauncherItem("vol up", "볼륨 +10%", null, ("set", Math.Min(pct + 10, 100)), Symbol: Symbols.VolumeUp));
items.Add(new LauncherItem("vol down", "볼륨 10%", null, ("set", Math.Max(pct - 10, 0)), Symbol: Symbols.VolumeDown));
items.Add(new LauncherItem("vol mute", curMuted ? "음소거 해제" : "음소거", null, ("mute", !curMuted), Symbol: Symbols.VolumeMute));
items.Add(new LauncherItem("vol 0", "볼륨 0% (무음)", null, ("set", 0), Symbol: Symbols.VolumeMute));
items.Add(new LauncherItem("vol 50", "볼륨 50%", null, ("set", 50), Symbol: Symbols.VolumeDown));
items.Add(new LauncherItem("vol 100", "볼륨 100%", null, ("set", 100), Symbol: Symbols.VolumeUp));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 명령 파싱
if (q is "up" or "올려" or "+")
{
var target = Math.Min(pct + 10, 100);
items.Add(new LauncherItem($"볼륨 +10% → {target}%", $"{VolumeBar(target)}", null, ("set", target), Symbol: Symbols.VolumeUp));
}
else if (q is "down" or "내려" or "-")
{
var target = Math.Max(pct - 10, 0);
items.Add(new LauncherItem($"볼륨 10% → {target}%", $"{VolumeBar(target)}", null, ("set", target), Symbol: Symbols.VolumeDown));
}
else if (q is "mute" or "음소거" or "m")
{
items.Add(new LauncherItem(
curMuted ? "음소거 해제" : "음소거 설정",
$"현재: {pct}%{muteLabel}",
null, ("mute", !curMuted), Symbol: Symbols.VolumeMute));
}
else if (int.TryParse(q, out int val) && val is >= 0 and <= 100)
{
items.Add(new LauncherItem(
$"볼륨 {val}% 설정",
$"{VolumeBar(val)} (현재 {pct}%)",
null, ("set", val), Symbol: val > pct ? Symbols.VolumeUp : Symbols.VolumeDown));
}
else
{
items.Add(new LauncherItem(
$"'{query}' — 알 수 없는 명령",
"사용법: vol 50 / vol up / vol down / vol mute",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
try
{
using var audio = AudioEndpoint.GetDefault();
if (item.Data is ("set", int level))
{
audio.SetVolume(level / 100f);
if (audio.GetMute()) audio.SetMute(false);
NotificationService.Notify("vol", $"볼륨 {level}%");
}
else if (item.Data is ("mute", bool mute))
{
audio.SetMute(mute);
NotificationService.Notify("vol", mute ? "음소거" : "음소거 해제");
}
}
catch (Exception ex)
{
NotificationService.Notify("vol", $"오류: {ex.Message}");
}
return Task.CompletedTask;
}
private static string VolumeBar(int pct)
{
int filled = pct / 5; // 0~20
return "[" + new string('█', filled) + new string('░', 20 - filled) + "]";
}
// ─── Core Audio API COM 래퍼 ─────────────────────────────────────────────
private sealed class AudioEndpoint : IDisposable
{
private readonly IAudioEndpointVolume _vol;
private AudioEndpoint(IAudioEndpointVolume vol) => _vol = vol;
public static AudioEndpoint GetDefault()
{
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorClass();
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
var iid = typeof(IAudioEndpointVolume).GUID;
device.Activate(ref iid, 1 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
return new AudioEndpoint((IAudioEndpointVolume)obj);
}
public float GetVolume() { _vol.GetMasterVolumeLevelScalar(out float l); return l; }
public bool GetMute() { _vol.GetMute(out bool m); return m; }
public void SetVolume(float level) { var g = Guid.Empty; _vol.SetMasterVolumeLevelScalar(Math.Clamp(level, 0f, 1f), ref g); }
public void SetMute(bool mute) { var g = Guid.Empty; _vol.SetMute(mute, ref g); }
public void Dispose()
{
if (_vol is IDisposable d) d.Dispose();
if (_vol != null) Marshal.ReleaseComObject(_vol);
}
}
// ─── COM 인터페이스 정의 (Windows Core Audio API) ──────────────────────
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
private class MMDeviceEnumeratorClass { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDeviceEnumerator
{
int EnumAudioEndpoints(int dataFlow, uint stateMask, out IntPtr devices);
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDevice
{
int Activate(ref Guid iid, uint clsCtx, IntPtr activationParams,
[MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
}
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IAudioEndpointVolume
{
int RegisterControlChangeNotify(IntPtr pNotify);
int UnregisterControlChangeNotify(IntPtr pNotify);
int GetChannelCount(out uint pnChannelCount);
int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext);
int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext);
int GetMasterVolumeLevel(out float pfLevelDB);
int GetMasterVolumeLevelScalar(out float pfLevel);
int SetChannelVolumeLevel(uint nChannel, float fLevelDB, ref Guid pguidEventContext);
int SetChannelVolumeLevelScalar(uint nChannel, float fLevel, ref Guid pguidEventContext);
int GetChannelVolumeLevel(uint nChannel, out float pfLevelDB);
int GetChannelVolumeLevelScalar(uint nChannel, out float pfLevel);
int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext);
int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
}
}

View File

@@ -0,0 +1,260 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-1: Wake-on-LAN 핸들러. "wol" 프리픽스로 사용합니다.
///
/// 예: wol → 저장된 호스트 목록
/// wol AA:BB:CC:DD:EE:FF → 매직 패킷 전송
/// wol AA-BB-CC-DD-EE-FF → 대시 구분자도 지원
/// wol AABBCCDDEEFF → 구분자 없는 형식
/// wol save PC-1 AA:BB:CC:DD:EE:FF → 호스트 저장
/// wol delete PC-1 → 저장 항목 삭제
/// Enter → 매직 패킷 전송.
/// </summary>
public class WolHandler : IActionHandler
{
public string? Prefix => "wol";
public PluginMetadata Metadata => new(
"WoL",
"Wake-on-LAN — 매직 패킷 전송 · 호스트 관리",
"1.0",
"AX");
private static readonly string StorePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "wol_hosts.json");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var hosts = LoadHosts();
if (string.IsNullOrWhiteSpace(q))
{
if (hosts.Count == 0)
{
items.Add(new LauncherItem("Wake-on-LAN",
"예: wol AA:BB:CC:DD:EE:FF / wol save 이름 AA:BB:CC:...",
null, null, Symbol: "\uE823"));
}
else
{
items.Add(new LauncherItem($"저장된 호스트 {hosts.Count}개",
"Enter → 매직 패킷 전송", null, null, Symbol: "\uE823"));
foreach (var h in hosts)
items.Add(MakeHostItem(h));
}
items.Add(new LauncherItem("wol save 이름 MAC", "호스트 저장", null, null, Symbol: "\uE823"));
items.Add(new LauncherItem("wol AA:BB:CC:…", "직접 전송", null, null, Symbol: "\uE823"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
case "save":
case "add":
{
if (parts.Length < 3)
{
items.Add(new LauncherItem("형식 오류", "예: wol save PC-이름 AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE783"));
break;
}
var name = parts[1];
var mac = parts[2];
if (!TryParseMac(mac, out var macBytes))
{
items.Add(new LauncherItem("MAC 형식 오류", $"'{mac}'은 유효한 MAC 주소가 아닙니다", null, null, Symbol: "\uE783"));
break;
}
items.Add(new LauncherItem(
$"저장: {name} ({mac})",
"Enter → 저장",
null, ("save", $"{name}|{mac}"), Symbol: "\uE823"));
break;
}
case "delete":
case "del":
case "remove":
{
var target = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : "";
var found = hosts.FirstOrDefault(h =>
h.Name.Equals(target, StringComparison.OrdinalIgnoreCase));
if (found == null)
items.Add(new LauncherItem("없는 항목", $"'{target}'을 찾을 수 없습니다", null, null, Symbol: "\uE783"));
else
items.Add(new LauncherItem($"삭제: {found.Name}", found.Mac, null, ("delete", found.Name), Symbol: "\uE74D"));
break;
}
default:
{
// MAC 주소 직접 입력
if (TryParseMac(q, out _))
{
items.Add(new LauncherItem(
$"매직 패킷 전송: {q}",
"Enter → 브로드캐스트 전송 (255.255.255.255:9)",
null, ("send", q), Symbol: "\uE823"));
}
// 저장된 호스트 이름 검색
else
{
var filtered = hosts.Where(h =>
h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == 0)
items.Add(new LauncherItem("결과 없음",
$"'{q}' 항목 없음. MAC 형식: AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE946"));
else
foreach (var h in filtered)
items.Add(MakeHostItem(h));
}
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("send", string mac):
SendMagicPacket(mac);
break;
case ("wake", string mac):
SendMagicPacket(mac);
break;
case ("save", string data):
{
var idx = data.IndexOf('|');
var name = data[..idx];
var mac = data[(idx + 1)..];
var hosts = LoadHosts();
hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
hosts.Add(new WolHost { Name = name, Mac = mac });
SaveHosts(hosts);
NotificationService.Notify("WoL", $"'{name}' ({mac}) 저장됨");
break;
}
case ("delete", string name):
{
var hosts = LoadHosts();
hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
SaveHosts(hosts);
NotificationService.Notify("WoL", $"'{name}' 삭제됨");
break;
}
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("WoL", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
break;
}
return Task.CompletedTask;
}
// ── 매직 패킷 전송 ────────────────────────────────────────────────────────
private static void SendMagicPacket(string mac)
{
if (!TryParseMac(mac, out var macBytes))
{
NotificationService.Notify("WoL", $"MAC 형식 오류: {mac}");
return;
}
// 매직 패킷: 0xFF × 6 + MAC × 16
var packet = new byte[102];
for (var i = 0; i < 6; i++)
packet[i] = 0xFF;
for (var i = 1; i <= 16; i++)
Array.Copy(macBytes, 0, packet, i * 6, 6);
try
{
using var udp = new UdpClient();
udp.EnableBroadcast = true;
udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 9));
// 포트 7도 함께 전송 (일부 장치)
udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 7));
NotificationService.Notify("WoL", $"매직 패킷 전송됨 → {mac}");
}
catch (Exception ex)
{
NotificationService.Notify("WoL", $"전송 실패: {ex.Message}");
}
}
// ── 파싱·저장 헬퍼 ────────────────────────────────────────────────────────
private static bool TryParseMac(string s, out byte[] bytes)
{
bytes = [];
s = s.Trim().Replace(":", "").Replace("-", "").Replace(".", "");
if (s.Length != 12) return false;
try
{
bytes = Enumerable.Range(0, 6)
.Select(i => Convert.ToByte(s.Substring(i * 2, 2), 16))
.ToArray();
return true;
}
catch { return false; }
}
private static LauncherItem MakeHostItem(WolHost h) =>
new(h.Name, h.Mac, null, ("wake", h.Mac), Symbol: "\uE823");
// ── 영속 스토리지 ─────────────────────────────────────────────────────────
private class WolHost
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("mac")] public string Mac { get; set; } = "";
}
private static List<WolHost> LoadHosts()
{
try
{
if (!System.IO.File.Exists(StorePath)) return new();
var json = System.IO.File.ReadAllText(StorePath);
return JsonSerializer.Deserialize<List<WolHost>>(json) ?? new();
}
catch { return new(); }
}
private static void SaveHosts(List<WolHost> hosts)
{
try
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!);
System.IO.File.WriteAllText(StorePath,
JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,241 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L23-3: 근무 시간·급여 계산. "work" 프리픽스로 사용합니다.
///
/// 예: work 09:00 18:30 → 근무시간·초과근무 (점심 1시간 자동 제외)
/// work 09:00 18:30 -30 → 점심 30분 제외
/// work 09:00 18:30 -0 → 점심 제외 없음
/// work 09:00 18:30 pay 15000 → 급여 함께 계산
/// work pay 15000 → 이전 계산 재활용 급여 산출
/// work week 45.5 → 주간 근무시간 입력 → 초과 계산
/// Enter → 결과 복사
/// </summary>
public class WorkTimeHandler : IActionHandler
{
public string? Prefix => "work";
public PluginMetadata Metadata => new(
"근무시간 계산",
"출퇴근 시간 입력 → 근무시간·초과근무·급여 계산",
"1.0",
"AX");
// 마지막 계산 캐시 (pay 재활용용)
private static double _lastWorkedHours;
// ── 파서 ──────────────────────────────────────────────────────────────────
private static bool TryParseTime(string s, out TimeSpan result)
{
result = default;
s = s.Trim();
// HH:mm or H:mm
if (s.Contains(':'))
{
var parts = s.Split(':');
if (parts.Length == 2 &&
int.TryParse(parts[0], out var h) &&
int.TryParse(parts[1], out var m) &&
h >= 0 && h <= 47 && m >= 0 && m < 60)
{
result = new TimeSpan(h, m, 0);
return true;
}
return false;
}
// HHmm (4자리)
if (s.Length == 4 &&
int.TryParse(s[..2], out var hh) &&
int.TryParse(s[2..], out var mm) &&
hh >= 0 && hh <= 23 && mm >= 0 && mm < 60)
{
result = new TimeSpan(hh, mm, 0);
return true;
}
return false;
}
private static bool TryParseWorkTime(string q,
out TimeSpan start, out TimeSpan end,
out double lunchMinutes, out double payWage)
{
start = default;
end = default;
lunchMinutes = 60;
payWage = 0;
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2) return false;
if (!TryParseTime(tokens[0], out start)) return false;
if (!TryParseTime(tokens[1], out end)) return false;
for (var i = 2; i < tokens.Length; i++)
{
var t = tokens[i];
// -N → 점심 제외 분
if (t.StartsWith('-') && double.TryParse(t[1..],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var lm))
{
lunchMinutes = lm;
}
// pay N
else if (t.Equals("pay", StringComparison.OrdinalIgnoreCase) && i + 1 < tokens.Length)
{
if (double.TryParse(tokens[i + 1],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var pw))
{
payWage = pw;
i++;
}
}
}
return true;
}
private static (double stdPay, double otPay, double total) CalcPay(double wage, double workedHours)
{
var stdHours = Math.Min(workedHours, 8.0);
var otHours = Math.Max(0, workedHours - 8.0);
var stdPay = stdHours * wage;
var otPay = otHours * wage * 1.5;
return (stdPay, otPay, stdPay + otPay);
}
// ── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("근무시간 계산기",
"work 09:00 18:30 → 근무시간 / work 09:00 18:30 -30 → 점심 30분 / work 09:00 18:30 pay 15000 → 급여",
null, null, Symbol: "\uE916"));
items.Add(new LauncherItem("work 09:00 18:30", "점심 1시간 자동 제외", null, null, Symbol: "\uE916"));
items.Add(new LauncherItem("work 09:00 18:30 -30", "점심 30분 제외", null, null, Symbol: "\uE916"));
items.Add(new LauncherItem("work pay 15000", "이전 계산 재활용 급여 산출", null, null, Symbol: "\uE916"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = tokens[0].ToLowerInvariant();
// pay N → 이전 계산 재활용
if (sub == "pay")
{
if (tokens.Length >= 2 && double.TryParse(tokens[1],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var wage))
{
if (_lastWorkedHours <= 0)
{
items.Add(new LauncherItem("이전 계산 없음", "먼저 시간을 계산하세요: work HH:mm HH:mm",
null, null, Symbol: "\uE783"));
}
else
{
var (stdPay, otPay, total) = CalcPay(wage, _lastWorkedHours);
var result = $"근무 {_lastWorkedHours:F1}시간 | 시급 {wage:#,0}원 | 기본 {stdPay:#,0}원 | 초과 {otPay:#,0}원 | 합계 {total:#,0}원";
items.Add(new LauncherItem(
$"급여: {total:#,0}원",
$"기본 {stdPay:#,0}원 + 초과(1.5배) {otPay:#,0}원 · Enter: 복사",
null, ("copy", result), Symbol: "\uE916"));
}
}
else
{
items.Add(new LauncherItem("시급을 입력하세요", "예: work pay 15000", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// week N → 주간 근무시간
if (sub == "week")
{
if (tokens.Length >= 2 && double.TryParse(tokens[1],
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var weekHours))
{
var std = 40.0;
var ot = Math.Max(0, weekHours - std);
var label = $"주간 근무 {weekHours:F1}시간 초과근무 {ot:F1}시간 (기준 {std}h)";
items.Add(new LauncherItem(label, "Enter: 복사",
null, ("copy", label), Symbol: "\uE916"));
}
else
{
items.Add(new LauncherItem("시간을 입력하세요", "예: work week 45.5", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 시각 파싱 시도
if (!TryParseWorkTime(q, out var start, out var end, out var lunch, out var pay))
{
items.Add(new LauncherItem("시간 형식 오류",
"예: work 09:00 18:30 또는 work 09:00 18:30 -30 pay 15000",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 야간 처리
if (end <= start) end = end.Add(TimeSpan.FromHours(24));
var totalSpan = end - start - TimeSpan.FromMinutes(lunch);
var workedHours = totalSpan.TotalHours;
if (workedHours < 0) workedHours = 0;
_lastWorkedHours = workedHours;
var wh = (int)workedHours;
var wm = (int)Math.Round((workedHours - wh) * 60);
var otHours = Math.Max(0, workedHours - 8.0);
var summaryLine = $"근무 {workedHours:F1}시간 ({wh}시간 {wm}분) 초과 {otHours:F1}시간";
items.Add(new LauncherItem(
$"근무시간: {workedHours:F1}시간 ({wh}시간 {wm}분)",
$"초과근무: {otHours:F1}시간 · Enter: 복사",
null, ("copy", summaryLine), Symbol: "\uE916"));
items.Add(new LauncherItem(
$"초과근무: {otHours:F1}시간",
$"기준 8시간 초과분 / 점심 제외: {lunch}분",
null, ("copy", $"초과근무: {otHours:F1}시간"), Symbol: "\uE916"));
if (pay > 0)
{
var (stdP, otP, totalP) = CalcPay(pay, workedHours);
var payLine = $"급여: {totalP:#,0}원 (기본 {stdP:#,0}원 + 초과 {otP:#,0}원) | 시급 {pay:#,0}원";
items.Add(new LauncherItem(
$"급여: {totalP:#,0}원",
$"기본 {stdP:#,0}원 + 초과(1.5배) {otP:#,0}원 · Enter: 복사",
null, ("copy", payLine), Symbol: "\uE916"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("근무시간", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

Some files were not shown because too many files have changed in this diff Show More