Compare commits

...

225 Commits

Author SHA1 Message Date
4c8b550242 코워크·코드 장시간 실행 종료 직전 NullReference 실패를 방지하고 예외 추적을 보강한다
Some checks are pending
Release Gate / gate (push) Waiting to run
- ChatWindow 실행 경로에서 스트리밍 취소 토큰을 지역 변수로 고정해 마지막 라이브 프리뷰 단계에서 _streamCts null 참조가 발생하지 않도록 수정

- 최종 타이핑 프리뷰 컨테이너 준비 실패와 취소 예외를 방어적으로 처리해 장시간 작업이 마지막 UI 렌더 때문에 오류로 뒤집히지 않도록 정리

- 에이전트 실행 예외 전체를 앱 로그에 남기고 README 및 DEVELOPMENT 문서 이력을 갱신
2026-04-07 09:26:28 +09:00
f34878cbd5 하단 토큰 집계와 압축 표시 정확도 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
- 유휴 전환 후 하단 상태바 전체 토큰 집계가 사라지지 않도록 대화 기준 합산 복원 경로 추가
- 컨텍스트 사용량 팝업에 마지막 실제 압축 before/after 및 누적 절감량 표시
- total_stats 이벤트가 진행 피드에 흡수되지 않고 전용 통계 카드로 다시 노출되게 수정
- 관련 README 및 개발 문서 이력 갱신

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-07 09:21:23 +09:00
b45ed524e1 코워크 탭 전환 시 처리 상태 오염과 자동 취소 수정\n\n- 탭 전환과 대화 재개 시 실행 중 작업을 즉시 취소하던 흐름 제거\n- 실행 소유 탭에서만 정지/일시정지 버튼과 라이브 진행 힌트가 보이도록 정리\n- README와 DEVELOPMENT 문서에 2026-04-07 03:13 (KST) 기준 변경 이력 반영\n- dotnet build 검증 완료 (경고 0, 오류 0)
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-07 09:07:13 +09:00
6ca067c4a6 코워크·코드 하단 메모리 표기 제거 및 footer 정리\n\n- Cowork/Code 하단 작업 바의 메모리 상태 칩을 항상 숨기도록 조정\n- 메모리 상태 버튼 비노출 시 tooltip과 팝업 진입도 함께 차단\n- README와 DEVELOPMENT 문서에 2026-04-07 03:03 (KST) 기준 변경 이력 반영\n- dotnet build 검증 완료 (경고 0, 오류 0)
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-07 09:02:18 +09:00
fbaaf19391 AX Agent 내부 설정의 워크플로우 시각화와 자동 프리뷰를 복구한다\n\n- 개발자 탭의 워크플로우 시각화가 숨은 DevMode 의존 때문에 실제 분석기 창을 띄우지 않던 문제를 수정한다\n- 워크플로우 시각화 토글을 켜면 즉시 창을 열고 끄면 바로 숨기도록 동작을 연결한다\n- 일반 설정에만 남아 있던 문서 미리보기 자동 표시 옵션을 AX Agent 내부 설정 공통 탭에도 복원한다\n- 내부 설정의 자동 프리뷰 콤보를 Llm.AutoPreview와 동기화하고 변경 즉시 저장되도록 연결한다\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-07 08:58:00 +09:00
a686d822e7 AX Agent 진행 시간 표기와 글로우 설정 위치를 정리한다\n\n- Cowork/Code 진행 카드에서 스트리밍 시작 시각이 준비되기 전 계산되며 수천만 시간으로 튀는 문제를 수정한다\n- 진행 카드 라이브 대기 색상을 테마 AccentColor 기반으로 조정해 주황색 고정 느낌을 제거한다\n- 채팅 입력창 글로우를 런처와 같은 리듬의 무지개 글로우로 완화하고 외곽선 두께와 블러를 정리한다\n- 일반 설정의 런처/채팅 글로우 토글을 제거하고 AX Agent 내부 설정 공통 탭으로 이동한다\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-07 08:52:37 +09:00
f8baea24f5 코워크와 코드 최종 응답 라이브 타이핑 프리뷰 적용
Some checks failed
Release Gate / gate (push) Has been cancelled
- Cowork/Code 에이전트 루프 완료 후 최종 답변이 한 번에 붙지 않도록 라이브 타이핑 프리뷰를 추가함\n- 처리 중에는 기존 진행 로그를 유지하고, 최종 응답 구간만 스트리밍 컨테이너를 거쳐 자연스럽게 이어지도록 정리함\n- README와 DEVELOPMENT 문서에 2026-04-07 02:31 (KST) 기준 변경 이력 반영\n- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-07 08:21:05 +09:00
23b2352637 채팅 탭 SSE 스트리밍 응답 경로 복구 및 문서 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
- Chat 탭 직접 대화 경로가 최종 응답만 한 번에 표시하던 문제를 수정하고 LlmService 스트리밍 경로를 실제 UI에 연결함\n- AxAgentExecutionEngine에서 비에이전트 채팅이 스트리밍 전송을 사용할 수 있도록 실행 모드를 조정함\n- ChatWindow에서 기존 스트리밍 컨테이너와 타이핑 타이머를 실제 전송 루프에 연결해 타자 치듯 점진적으로 응답이 보이게 함\n- README와 DEVELOPMENT 문서에 2026-04-07 02:23 (KST) 기준 변경 이력 반영\n- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-07 08:03:37 +09:00
8617f66496 내부 설정 Gemini API 키 입력 문제 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정 공통 탭의 서비스 API 키 입력 필드를 PasswordBox에서 TextBox로 변경

- Gemini/Claude 키 입력 시 오버레이 동기화 중에도 입력이 끊기지 않도록 보완

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-07 07:55:36 +09:00
f44b8b7dea Cowork 진행 표시와 깨진 한글 문자열 복구
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-07 07:53:08 +09:00
4e6d5d0597 AX Agent footer 한글 깨짐 복구 및 안내 문구 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- Cowork와 Code 입력창 워터마크, 프리셋 안내, 메모리 상태 팝업의 깨진 한글 문자열 복구
- 메모리 적용 근거와 상태 문구를 읽기 쉬운 한국어로 재정리
- Release 빌드 경고/오류 0 재검증
2026-04-07 07:24:46 +09:00
a35c47ed32 메모리 적용 근거 표시와 감사/명령 UX 마무리
- Cowork와 Code 진행 표시 줄에 메모리 규칙 및 학습 메모리 적용 근거를 함께 노출
- include 감사 로그 최근 3일 필터와 보관 시점 판정을 정리해 메모리 감사 상태를 더 정확히 표시
- /memory list 및 search 출력 형식을 우선순위·레이어·설명·paths·tags 중심으로 재구성하고 Release 빌드 경고/오류 0 검증
2026-04-07 06:40:18 +09:00
fe843fb314 메모리 상태 팝업과 include 감사 요약 UX 고도화
Some checks failed
Release Gate / gate (push) Has been cancelled
- Cowork/Code 하단 메모리 칩에서 적용 규칙과 최근 include 감사 이력을 팝업으로 확인 가능하게 개선
- 설정 메모리 개요에 최근 include 감사 요약을 추가해 메모리 계층과 감사 상태를 함께 점검 가능하도록 정리
- AX Agent 메모리 구조 고도화 마지막 UX 보강 반영 및 Release 빌드 경고/오류 0 검증
2026-04-07 00:55:53 +09:00
594bb6ffe6 메모리 include 감사 로그와 AX Agent 메모리 상태 UX 강화
- AgentMemoryService에 @include 성공/차단 감사 로그(MemoryInclude) 기록 추가
- Cowork/Code 하단 폴더 바에 메모리 규칙/학습 메모리 상태 요약 표시 추가
- 설정의 외부 메모리 include 안내 문구를 감사 로그 기준으로 정리
- dotnet build 검증 완료 (경고 0 / 오류 0)
2026-04-07 00:46:56 +09:00
aef5f51c89 AX Agent 메모리 적용 상태 요약 UI 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 설정의 에이전트 메모리 섹션에 현재 적용 중인 메모리 계층 요약 row 추가
- 계층형 규칙 수와 학습 메모리 수를 한눈에 보여주고 활성 규칙의 우선순위, 설명, 태그를 바로 읽을 수 있도록 정리
- 새로고침 버튼을 추가해 현재 작업 폴더 기준 메모리 적용 상태를 즉시 다시 계산 가능하게 구현
- 메모리 파일 저장과 학습 메모리 초기화 이후 요약이 자동으로 다시 반영되도록 연결
- README 및 DEVELOPMENT 문서에 2026-04-07 01:00 (KST) 기준 작업 이력 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-07 00:39:26 +09:00
0bfec6fb78 AX Agent 계층형 메모리 규칙 메타 확장 및 표시 보강
Some checks failed
Release Gate / gate (push) Has been cancelled
- AgentMemoryService frontmatter에 enabled와 tags 메타를 추가해 규칙 파일을 비활성 상태로 보관하거나 태그 단위로 묶어 관리할 수 있도록 확장
- enabled:false 규칙은 로드 단계에서 제외하도록 처리해 실험용 메모리 규칙을 안전하게 보관 가능하게 정리
- MemoryTool list/search 출력에 tags 메타를 함께 노출해 규칙 설명·적용 경로·우선순위와 함께 묶음 성격까지 바로 읽을 수 있도록 개선
- 설정의 메모리 편집 다이얼로그 frontmatter 예시를 description/enabled/tags/paths 기준으로 갱신해 현재 지원 메타를 UI에서도 바로 참고 가능하게 정리
- README 및 DEVELOPMENT 문서에 2026-04-07 00:52 (KST) 기준 작업 이력 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-07 00:34:32 +09:00
4e1dcf082c AX Agent 메모리 편집 UI 추가 및 계층형 메모리 관리 흐름 보강
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 설정의 에이전트 메모리 섹션에 관리형/사용자/프로젝트/로컬 메모리 편집 버튼 추가
- 계층형 메모리 파일을 현재 테마 기반 다이얼로그에서 직접 열고 저장/삭제할 수 있는 편집 UI 구현
- description 및 paths frontmatter 예시를 편집기 안내에 포함해 claw-code 수준의 메모리 규칙 작성 흐름 보강
- 저장 후 메모리 계층을 즉시 다시 로드하도록 연결해 /memory 명령과 설정 UI가 같은 상태를 보도록 정리
- README 및 DEVELOPMENT 문서에 2026-04-07 00:45 (KST) 기준 작업 이력 반영

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-07 00:28:56 +09:00
917e61af20 메모리 계층 우선순위와 중복 병합 정책 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
계층형 메모리 문서 로드 후 동일한 규칙 내용은 더 가까운 계층만 남기고 중복을 제거하도록 NormalizeInstructionDocuments 로직을 추가했습니다.

최종 메모리 문서는 managed, user, project, local 순서로 다시 정렬되고 우선순위 번호를 부여하며, MemoryTool의 list와 search 결과에서 그 우선순위를 함께 보여주도록 했습니다.

README와 DEVELOPMENT 문서에 2026-04-07 00:39 (KST) 기준 이력을 반영했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-07 00:17:48 +09:00
7093c77849 메모리 규칙 설명 메타와 슬래시 조회 기능 확장
Some checks failed
Release Gate / gate (push) Has been cancelled
계층형 메모리 frontmatter에 description 메타를 추가해 rule 파일의 의도와 적용 범위를 /memory 결과에서 함께 읽을 수 있도록 정리했습니다.

MemoryTool에 show_scope 액션을 추가해 managed, user, project, local 메모리 파일의 실제 내용을 슬래시 명령으로 직접 확인할 수 있게 했습니다.

README와 DEVELOPMENT 문서에 2026-04-07 00:31 (KST) 기준 변경 이력을 반영했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-07 00:11:51 +09:00
2e0362a88f 메모리 규칙 경로 범위 적용을 위한 paths 프런트매터 지원 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
계층형 메모리 문서에 YAML 유사 paths 프런트매터를 추가해 현재 작업 폴더 경로에 따라 규칙 적용 여부를 제어할 수 있도록 했습니다.

AgentMemoryService에서 프런트매터를 파싱하고 프로젝트 루트 기준 상대 경로에 대해 *, **, ? glob 매칭을 수행하도록 구현했습니다.

README와 DEVELOPMENT 문서에 메모리 규칙 범위 제어 기능과 동작 방식을 2026-04-07 00:22 (KST) 기준으로 반영했고, Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-07 00:07:32 +09:00
18551a0aea AX Agent 메모리 구조 4차 강화: 외부 include 보안 정책과 설정 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
- 메모리 내용 관리는 /memory 도구로 유지하고, 외부 include 허용 여부만 설정에서 제어하도록 구조를 분리함

- AllowExternalMemoryIncludes 설정과 UI 토글을 추가해 홈 경로/절대 경로/프로젝트 밖 상대 include를 기본 차단하고 필요 시에만 허용하도록 정리함

- AgentMemoryService가 include 해석 시 프로젝트 경계와 설정값을 함께 검사해 claw-code와 유사한 안전 정책을 따르도록 보강함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-07 00:02:20 +09:00
ae765fb543 AX Agent 메모리 구조 3차 강화: @include 지원과 프로젝트 루트 판단 개선
- 계층형 메모리 문서에 @include 확장을 추가해 상대 경로, 홈 경로, 절대 경로 텍스트 파일을 최대 5단계까지 재귀적으로 펼치도록 구현함

- 코드 블록 내부 include 무시, 순환 참조 차단, 비텍스트 파일 제외 규칙을 적용해 안전한 최소 규칙으로 정리함

- 프로젝트 루트 판단을 .git, .sln, *.csproj, package.json, pyproject.toml, go.mod, Cargo.toml 마커 기반으로 강화해 project/local 메모리 탐색과 저장 경로를 더 정확히 맞춤

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:56:19 +09:00
80682552f4 AX Agent 메모리 구조 2차 강화: 계층형 메모리 관리 도구 확장
Some checks failed
Release Gate / gate (push) Has been cancelled
- memory 도구에 save_scope, delete_scope 액션을 추가해 managed/user/project/local 메모리 파일을 직접 저장 및 삭제할 수 있게 확장함

- search, list 액션이 학습 메모리뿐 아니라 계층형 메모리 문서도 함께 보여주도록 개선함

- AgentMemoryService에 계층형 메모리 파일 쓰기/삭제 경로와 append/remove 로직을 추가해 메모리 계층을 실제로 관리 가능한 상태로 전환함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:46:23 +09:00
13cd1e54ed AX Agent 메모리 구조 1차 강화: 계층형 메모리 문서 로딩과 프롬프트 주입 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
- AgentMemoryService에 관리형/사용자/프로젝트/로컬 메모리 문서 탐색을 추가해 AXMEMORY.md, AXMEMORY.local.md, .ax/rules/*.md 계층을 로드하도록 확장함

- ChatWindow 시스템 프롬프트 메모리 섹션을 계층형 메모리와 기존 학습 메모리를 함께 조립하는 구조로 재편함

- 작업 폴더 메모리 로드 전에 Count를 먼저 검사하던 경로를 제거해 다른 폴더 메모리 누락 가능성을 줄임

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:40:35 +09:00
c75790f8c2 런처 유휴 CPU와 AX Agent 백그라운드 갱신 부담을 줄임
Some checks failed
Release Gate / gate (push) Has been cancelled
- FuzzyEngine에 인덱스 버전 기준 쿼리 캐시를 추가해 색인 완료 후 반복 검색 반응성을 개선함

- 캐시된 인덱스가 없을 때는 앱 시작 시 watcher를 먼저 켜지 않도록 조정해 런처 유휴 CPU 부담을 완화함

- AX Agent는 최소화/백그라운드 상태에서 task summary, 입력 보조 UI, agent UI flush를 지연했다가 활성화 시 한 번에 반영하도록 정리함

- README와 DEVELOPMENT 문서에 2026-04-06 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-06 23:34:03 +09:00
dc2574bb17 AX Agent 진행 표시를 Claude Code 스타일에 가깝게 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- execution history를 접은 상태에서도 대기/압축/중요 진행 이벤트가 transcript에 계속 노출되도록 필터를 조정함

- 진행 줄 메타를 경과 시간 · 누적 토큰 형식으로 통일하고 일반 진행 이벤트를 평평한 line 스타일로 정리함

- 장기 대기/컨텍스트 압축 상태만 강조 배경과 펄스 마커를 유지해 살아 있는 작업이 더 잘 보이도록 개선함

- README와 DEVELOPMENT 문서에 2026-04-06 23:26 (KST) 기준 이력을 반영함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:29:08 +09:00
45dfa70951 전체 코드 오류와 성능 병목을 점검하고 핵심 핫패스를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- 런처 색인에서 임시 파일, 숨김/시스템 경로, Office 임시 파일을 감시와 색인 대상에서 제외해 불필요한 재색인과 디스크 I/O를 줄인다

- AX Agent 표현 수준 저장값이 매번 rich로 덮어쓰이던 버그를 수정해 balanced/simple/rich 설정이 실제로 유지되게 한다

- 최소화/숨김 상태의 AX Agent 창은 transcript 재렌더를 지연했다가 다시 보일 때 한 번만 처리하고, 런처 인덱스 상태 타이머도 재사용하도록 바꿔 백그라운드 오버헤드를 줄인다

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-06 23:19:02 +09:00
30e8218ba4 AX Agent 라이브 진행 메시지 애니메이션 추가 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-06 22:52:35 +09:00
306245524d AX Agent 라이브 진행 메시지에 경과 시간·토큰 표시 추가\n\n- Cowork/Code 장기 대기와 컨텍스트 압축 상황을 transcript에서 더 명확히 보이도록 라이브 진행 힌트 이벤트를 확장\n- 라이브 진행 줄 우측에 경과 시간과 누적 토큰 수를 표시해 사용자가 멈춤과 처리 중 상태를 구분할 수 있게 조정\n- process feed 요약줄 스타일을 보강해 claw-code 레퍼런스처럼 대기/압축 상태가 더 눈에 잘 들어오도록 개선\n- README 및 DEVELOPMENT 문서에 2026-04-06 22:42 (KST) 기준 변경 이력 반영\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-06 22:44:24 +09:00
4992dca74f AX Agent 중간 처리 메시지를 Claude Code 스타일에 가깝게 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 진행 중 이벤트(Planning, StepStart, StepDone, Thinking, ToolCall, SkillCall)를 작은 상태 칩 대신 요약줄+본문 설명 구조로 재구성했습니다.

- claw-code의 AssistantToolUseMessage/HookProgressMessage 흐름을 참고해 중간 처리 과정이 hover 없이도 transcript 안에서 읽히도록 조정했습니다.

- 권한 요청 및 결과 카드는 기존 richer card 구조를 유지하고, process feed 이벤트만 별도 경량 표현으로 분리했습니다.

- README와 DEVELOPMENT 문서에 2026-04-06 22:31 (KST) 기준 변경 이력을 반영했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 22:25:30 +09:00
f48e598cc1 AX Agent 무료티어 대기와 진행 표시 UX를 실제 동작 기준으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- Gemini 무료 티어 대기를 Gemini 서비스에서만 적용하도록 좁혀 vLLM/Ollama/Claude 작업이 불필요하게 멈추지 않게 수정
- 내부 설정과 빠른 설정의 Fast 표기를 Gemini 무료 티어 대기로 바꾸고 설명 문구도 실제 기능 기준으로 정리
- 단계 시작과 도구 호출 이벤트를 기본 transcript에 더 크게 노출하고 카드 배경/테두리/폰트 크기를 조정해 장시간 작업 중 상태를 읽기 쉽게 개선
- Cowork 장시간 무응답처럼 보이던 상황을 줄이기 위해 StepStart와 ToolCall이 더 이상 hover성 보조 정보처럼 숨지 않도록 수정

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\
- 경고 0 / 오류 0
2026-04-06 22:18:29 +09:00
36c04ccc07 AX Agent 내부 설정 토글 저장 동작 복구
Some checks failed
Release Gate / gate (push) Has been cancelled
내부 설정 오버레이의 코드/공통 기능 토글에 Checked/Unchecked 이벤트를 다시 연결해 변경 즉시 저장 루틴을 타도록 수정했다.

Code 결과 검토, 코드 리뷰, 병렬 도구, Worktree/Team/Cron 도구 등 눌러도 원래 상태로 돌아가던 문제를 해결했고 Release 빌드에서 경고 0, 오류 0을 확인했다.
2026-04-06 22:06:55 +09:00
339dc6c06b AX Agent 테마 팔레트와 채팅 박스 스타일 재정비
Some checks failed
Release Gate / gate (push) Has been cancelled
Claude/Codex/Slate/Nord/Ember 테마 팔레트를 다시 분리해 각 테마가 서로 다른 표면 위계와 인상을 갖도록 조정했다.

입력창 포커스 테두리를 AccentColor 고정이 아닌 테마별 InputFocusBorderColor로 변경하고, composer 및 메시지 버블 라운딩을 더 부드럽게 다듬었다.

README와 DEVELOPMENT 문서를 2026-04-06 21:54 (KST) 기준으로 갱신했고 Release 빌드에서 경고 0, 오류 0을 확인했다.
2026-04-06 22:02:23 +09:00
1636b9c26f Claude 테마 표면 위계 조정 및 본문/사이드바 색상 수정\n\n- Claude 라이트/시스템 테마에서 본문 배경은 흰색으로, 좌측 패널과 카드 표면은 더 짙은 웜 베이지 톤으로 재조정\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-06 21:48:20 +09:00
d9309b45fa AX Agent 코드 탭 저장소 요약줄과 리뷰 진입 UX 개선\n\n- 코드 탭 입력부 위 저장소 요약줄에 로컬/워크트리, upstream 배지를 추가해 repo context를 더 명확히 표시\n- 변경 상태에 따라 액션 문구를 동적으로 바꾸고 /review slash 명령으로 바로 이어지는 리뷰 배지 추가\n- 코드 탭 입력 워터마크와 안내 문구를 저장소 배너 흐름에 맞게 정리\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-06 21:37:07 +09:00
a19f69b2ff AX Agent 코드 메시지 강조와 Git 요약 배너 개선
- Cowork/Code 메시지 마크다운 렌더에 camelCase, PascalCase, snake_case 등 코드 심볼 강조를 추가함

- 코드 탭 입력부 위에 저장소/브랜치/변경 수치 요약 배너를 추가해 claude-code 스타일의 repo context를 빠르게 확인할 수 있게 함

- README와 DEVELOPMENT 문서를 2026-04-06 20:18 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-06 20:29:33 +09:00
43ee9154a8 토큰 카드와 전송 버튼 외곽선 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 입력 영역의 토큰 사용량 원형 카드 크기와 내부 링 두께를 재조정
- 토큰 카드 임계점 마커와 퍼센트 텍스트를 축소해 외곽선이 덜 답답하게 보이도록 보정
- 전송 버튼의 불필요한 외곽 테두리를 제거하고 화살표 정렬을 중앙 기준으로 정리
- 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-06 20:07:14 +09:00
bbb5c526b0 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-06 20:04:34 +09:00
43dd6b5d71 테마 팔레트 레퍼런스 정렬 및 Claude 테마명 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 내부 설정의 Claw 테마명을 Claude로 변경
- 기존 claw 저장값을 claude로 정규화하고 레거시 값도 계속 호환되도록 처리
- Claude 다크/라이트/시스템 테마를 실제 화면 톤에 맞춘 웜 뉴트럴 팔레트로 재조정
- Codex 다크 테마를 중성 차콜 기반 색상으로 정리
- Nord 라이트/다크/시스템 테마를 공식 Nord 팔레트 기준으로 재정렬
- Slate 라이트/다크/시스템 테마를 표준 슬레이트 계열 색상으로 정리
- 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 20:00:52 +09:00
c389c6ff3f AX Agent 라이트 테마 계층과 색 대비를 레퍼런스 기준으로 재조정
Some checks failed
Release Gate / gate (push) Has been cancelled
- AgentCodexLight와 AgentCodexSystem의 배경, 선택 배경, 호버, 경계선, 보조 텍스트 톤을 더 따뜻한 베이지 계열로 재설정
- AgentClawLight도 같은 기준으로 표면 계층을 보정해 사이드바와 본문, 선택 상태 위계가 더 자연스럽게 느껴지도록 정리
- 라이트 테마에서 본문은 더 깨끗한 흰색으로 유지하고 외곽/사이드 표면은 낮은 채도의 따뜻한 톤으로 분리해 레퍼런스와 비슷한 정보 구조를 복원
- 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 19:48:08 +09:00
1f52bc1cc3 설정창에서 런처 색인 진행률과 남은 시간을 직접 표시하도록 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- IndexService에 LauncherIndexProgressInfo와 진행 이벤트를 추가해 전체 색인 중 진행률, 상태 문구, 예상 남은 시간을 계산하도록 변경
- 설정창 일반 탭의 인덱싱 속도 아래에 프로그레스바와 상태/상세 문구 패널을 추가하고 실시간으로 바인딩되도록 연결
- 전체 색인 완료 후 최근 색인 완료 요약과 검색 가능 항목 수를 같은 위치에 유지해 런처 하단 완료 문구 의존도를 낮춤
- 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 19:43:18 +09:00
cc12177252 테마 글로우 설정의 실시간 반영 누락 수정
- 런처가 SettingsChanged 이벤트를 직접 받아 테두리, 아이콘 애니메이션, 무지개 글로우, 선택 글로우를 즉시 다시 적용하도록 보강함

- AX Agent는 저장된 설정을 다시 읽을 때 스트리밍 중 입력창 글로우 설정도 즉시 반영하도록 RefreshFromSavedSettings 흐름을 정리함

- README와 DEVELOPMENT 문서를 2026-04-06 19:02 (KST) 기준으로 갱신하고 Release 빌드에서 경고 0 / 오류 0을 확인함
2026-04-06 19:29:09 +09:00
4df5d5d874 런처 색인 과부하와 AX Agent 리사이즈 재렌더 부담 완화
Some checks failed
Release Gate / gate (push) Has been cancelled
- 런처 색인에서 .git, node_modules, bin, obj, dist, packages, venv, __pycache__, target 경로를 스캔/감시 제외해 유휴 CPU와 재색인 부담을 줄임

- 런처 재귀 파일 스캔을 제외 폴더 인지형 순회로 바꾸고 무지개 글로우 타이머 주기를 낮춰 visible 상태 UI 갱신 비용을 완화함

- AX Agent는 SizeChanged 때마다 전체 RenderMessages를 즉시 호출하지 않고 디바운스 후 한 번만 반영하도록 바꿔 창 조작 시 무거운 느낌을 줄임

- README와 DEVELOPMENT 문서를 2026-04-06 18:46 (KST) 기준으로 갱신하고 Release 빌드에서 경고 0 / 오류 0을 확인함
2026-04-06 19:07:09 +09:00
e4e3e49419 글로벌 키보드 훅 타이핑 버벅임 완화
Some checks failed
Release Gate / gate (push) Has been cancelled
다른 앱 타이핑 시에도 AX Copilot가 과하게 개입하던 글로벌 키보드 훅의 핫패스를 줄였다.

InputListener는 모든 키마다 파일 대화상자 억제 창 검사를 하지 않고 실제 핫키·캡처·키필터 후보 키에서만 검사하도록 최적화했다.

SnippetExpander는 추적 중이 아닐 때 ';' 시작 키 외에는 즉시 반환하게 바꿔 일반 타이핑 중 반복적인 modifier 상태 확인과 버퍼 처리를 제거했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:34 (KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 18:09:21 +09:00
5bf323d4bf 런처 색인 영속 캐시와 증분 갱신 구조 적용
Some checks failed
Release Gate / gate (push) Has been cancelled
런처 색인 구조를 전체 재색인 중심에서 영속 캐시와 watcher 증분 반영 방식으로 재구성했다.

IndexService에 launcher-index.json 기반 캐시 로드/저장, 파일 생성·삭제·이름변경 증분 갱신, 디렉터리 고위험 변경 시 전체 재색인 폴백을 추가했다.

App 시작 시 캐시 로드와 파일 감시를 바로 시작하고, 무거운 전체 스캔은 실제 검색 시 한 번만 보강 실행하도록 정리했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:24 (KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 18:06:03 +09:00
94dc325df4 메시지 좋아요 싫어요 토글 동작 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
채팅 메시지 피드백 버튼 로직을 shared feedback 상태 기반으로 다시 구성해 좋아요와 싫어요가 모두 즉시 반응하고 상호배타적으로 동작하도록 정리했다.

좋아요는 활성 색상과 chip 배경으로 상태가 확실히 보이게 바꾸고, 싫어요를 다시 누르면 null 상태로 정상 해제되도록 수정했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:09 (KST) 기준으로 갱신했고 dotnet build 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 17:56:53 +09:00
606ecbe6cd IBM vLLM 배포형 채팅 요청 스키마 분기와 문서 반영
IBM/CP4D 인증을 사용하는 vLLM 등록 모델에서 배포형 /ml/v1/deployments/.../text/chat 계열 엔드포인트를 감지하도록 정리했다.

일반 OpenAI 호환 body 대신 messages+parameters 형태의 IBM deployment chat body를 사용하고 /v1/chat/completions를 강제로 붙이지 않도록 수정했다.

IBM 배포형 응답은 results.generated_text, output_text, choices.message.content를 함께 파싱하도록 보강했고 도구 호출 경로는 안전하게 일반 응답 폴백을 유도하도록 정리했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:02 (KST) 기준으로 갱신했고 dotnet build 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 17:49:48 +09:00
e439fd144e 런처 즉시 표시 유지하고 색인 시작 시점 지연
LauncherWindow 사전 생성은 복원해 런처 호출 반응성을 유지했습니다.

대신 인덱스 전체 스캔과 감시는 런처를 여는 순간이 아니라 실제 검색이 시작될 때만 한 번 지연 시작하도록 변경했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 17:52(KST) 기준으로 갱신했고 Release 빌드에서 경고 0 오류 0을 확인했습니다.
2026-04-06 17:43:03 +09:00
7889189e41 런처와 트레이 지연 생성으로 유휴 CPU 추가 절감
Some checks failed
Release Gate / gate (push) Has been cancelled
앱 시작 시 LauncherWindow 전체를 미리 생성하지 않고 실제로 런처를 열 때만 만들도록 변경했습니다.

트레이 메뉴 PrepareForDisplay 사전 렌더도 제거해 사용하지 않는 팝업 레이아웃 계산이 부팅 직후 돌지 않게 정리했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 17:43(KST) 기준으로 갱신했고 Release 빌드에서 경고 0 오류 0을 확인했습니다.
2026-04-06 17:40:39 +09:00
353c5ce471 앱 시작 시 무거운 초기화 지연으로 유휴 성능 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
앱 시작 직후 AX Agent 창을 미리 생성하던 경로를 제거하고 실제로 AX Agent를 열 때만 ChatWindow를 만들도록 정리했습니다.

런처 검색 인덱스 전체 스캔과 파일 감시 시작도 부팅 시 즉시 실행하지 않고, 런처를 실제로 표시할 때 한 번만 지연 시작하도록 변경했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 17:35(KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 오류 0을 확인했습니다.
2026-04-06 17:20:37 +09:00
ccb39f0fe0 선택 텍스트 AI 명령 기본값과 토글 동작을 보수적으로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
선택 텍스트 명령 사용의 기본값을 비활성으로 바꾸고, 활성 AI 명령 목록도 기본은 빈 상태로 조정했다. 새 설치나 초기화 이후에는 사용자가 명시적으로 켜기 전까지 선택 텍스트 AI 팝업이 자동으로 개입하지 않도록 정리했다.

설정창의 텍스트 AI 명령 패널에서 최소 1개 유지 로직을 제거해 다시 쓰기를 포함한 모든 명령을 실제로 끌 수 있게 수정했다. 안내 문구도 현재 동작 기준으로 갱신해서, 모든 AI 명령을 꺼두면 선택 텍스트 팝업에는 AX Commander 열기만 남는다는 점을 명확히 반영했다.

README와 DEVELOPMENT 문서를 2026-04-06 17:24 (KST) 기준으로 갱신했고, 표준 검증 빌드 결과 경고 0개와 오류 0개를 확인했다.
2026-04-06 17:15:58 +09:00
9877d347b1 유휴 CPU 사용을 줄이도록 전역 훅과 스케줄 타이머 조건부 활성화로 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
앱 시작 시 파일 대화상자 통합이 꺼져 있어도 시스템 전역 WinEvent 훅을 항상 걸던 구조를 수정해, 설정이 켜져 있을 때만 FileDialogWatcher가 시작되도록 바꿨다. 설정 저장 시 watcher와 스케줄러 상태를 즉시 다시 계산하도록 App 초기화 경로도 함께 보강했다.

SchedulerService는 활성 일정이 하나도 없으면 타이머를 만들지 않고, 실행 중 일정이 모두 비활성화되면 스스로 정지하도록 Refresh 기반 구조로 정리했다. 이 변경으로 런처와 AX Agent 창이 닫힌 유휴 상태에서도 발생하던 불필요한 30초 주기 깨우기와 전역 이벤트 콜백을 줄였다.

README와 DEVELOPMENT 문서에 2026-04-06 17:18 (KST) 기준 이력을 반영했고, 표준 검증 빌드(dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\) 결과 경고 0, 오류 0을 확인했다.
2026-04-06 17:12:00 +09:00
c2e96c0286 런처 하단 빠른 실행 칩 표시 옵션과 중앙 정렬 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- 설정 기능 탭에 런처 하단 빠른 실행 칩 표시 토글을 추가하고 기본값을 비활성으로 분리함

- 런처 하단 빠른 실행 칩은 옵션이 켜진 경우에만 로드되도록 바꾸고, 블록 전체와 칩 내부를 중앙 정렬 기준으로 재배치함

- README와 DEVELOPMENT 문서 이력을 2026-04-06 17:09 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 17:02:07 +09:00
17d7b515ce CP4D 인증 방식을 등록 단계에서 분리
Some checks failed
Release Gate / gate (push) Has been cancelled
모델 등록 UI에 CP4D 사용자 이름+비밀번호 방식과 사용자 이름+API 키 방식을 별도 인증 유형으로 추가하고 선택에 따라 입력 라벨이 달라지도록 정리했습니다.

LlmService, SettingsViewModel, AppSettings를 갱신해 cp4d_password와 cp4d_api_key 저장값을 공식 지원하고 기존 cp4d 값은 비밀번호 방식으로 계속 호환되게 유지했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 17:01 (KST) 기준으로 갱신했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 16:55:10 +09:00
fd3af15e54 CP4D 토큰 요청의 api_key 본문 방식 지원
Some checks failed
Release Gate / gate (push) Has been cancelled
IBM 계열 vLLM 연결 점검 결과 일부 CP4D 인증 엔드포인트가 username+password 대신 username+api_key JSON 본문을 요구하는 것을 확인했습니다.

Cp4dTokenService가 먼저 username/password를 시도하고 실패 시 username/api_key로 자동 재시도하도록 보강했으며 README와 DEVELOPMENT 문서를 2026-04-06 16:55 (KST) 기준으로 갱신했습니다.

Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 16:51:26 +09:00
3961dc8ca2 모델 관리 섹션 구분선 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 내부 설정 공통 탭에서 서비스와 모델, 등록 모델 관리 사이에 있던 하단 구분선을 제거해 같은 흐름의 설정이 자연스럽게 이어 보이도록 정리했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 16:49 (KST) 기준으로 갱신했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 16:44:08 +09:00
98bc4ff24f 운영 모드 구분선 위치 조정
AX Agent 내부 설정 공통 탭의 운영 모드 섹션 구분선을 아래쪽에서 위쪽으로 옮겨 섹션 시작선처럼 보이도록 정리했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 16:44 (KST) 기준으로 갱신했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 16:41:54 +09:00
a5b511c38b 내부 설정 스크롤 체감과 저장 공간 버튼 스타일 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 내부 설정 오른쪽 본문 ScrollViewer에 deferred scrolling, vertical panning, bitmap cache를 적용해 스크롤 시 버벅임을 줄였습니다.

저장 공간 섹션의 새로고침, 대화 삭제, 저장 공간 줄이기 버튼을 OverlayActionBtn 커스텀 스타일로 교체해 일반 버튼 느낌을 제거했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 16:39 (KST) 기준으로 갱신했고 Release 빌드 경고 0 오류 0을 확인했습니다.
2026-04-06 16:39:55 +09:00
e8cd68cce7 컴포저 첨부·전송 버튼 스타일 단순화
Some checks failed
Release Gate / gate (push) Has been cancelled
채팅·코워크·코드 입력창의 첨부 버튼을 과도하게 커진 상태에서 원복 수준으로 줄이고, 전송 버튼도 같은 축의 컴팩트한 크기로 다시 맞췄습니다.

ChatWindow.xaml에 ComposerIconBtn과 ComposerSendBtn 스타일을 추가해 두 버튼 모두 상하좌우 중앙 정렬된 심플한 시각 언어를 사용하도록 재구성했습니다.

README와 DEVELOPMENT 문서를 갱신했고, dotnet build 검증에서 경고 0 오류 0을 확인했습니다.
2026-04-06 16:34:25 +09:00
1ad75b5896 배포판 보호 수준 강화 및 single-file 호환 보정
Some checks failed
Release Gate / gate (push) Has been cancelled
배포 스크립트와 앱 Release 설정에 single-file, ReadyToRun, 압축 번들, 최적화 옵션을 추가해 릴리즈 배포 출력의 보호 수준을 한 단계 높였습니다.

WebSearchHandler와 SettingsWindow는 single-file 환경에서 Assembly.Location 경고가 발생하지 않도록 AppContext.BaseDirectory 및 AssemblyInformationalVersionAttribute 기반으로 수정했습니다.

README와 DEVELOPMENT 문서를 갱신했고, dotnet build 검증에서 경고 0 오류 0을 다시 확인했습니다.
2026-04-06 16:09:00 +09:00
8da0a069b7 코워크 문서 생성 기본 포맷 편향 완화
Some checks failed
Release Gate / gate (push) Has been cancelled
문서 생성 도구가 인자 없이 호출될 때 html/professional로 고정되던 기본값을 제거했습니다.

DocumentPlannerTool과 DocumentAssemblerTool이 설정값(DefaultOutputFormat, DefaultMood), 문서 유형, 요청 키워드를 함께 보고 docx/html/markdown 및 corporate/dashboard/minimal/creative/professional 무드를 자동 선택하도록 조정했습니다.

README와 DEVELOPMENT 문서에 변경 배경과 검증 결과를 반영했고, dotnet build 기준 경고 0 오류 0을 확인했습니다.
2026-04-06 15:55:12 +09:00
2ae56b2510 격려문구 알림 팝업 자동 종료 안정성 개선
Some checks failed
Release Gate / gate (push) Has been cancelled
- ReminderPopupWindow의 자동 닫힘 경로를 점검하고 UI 타이머와 실제 종료 타이머를 분리함
- 남은 시간 계산을 절대 시각 기준으로 바꾸고 Task.Delay 기반 종료 fail-safe를 추가해 팝업이 남는 경우를 줄임
- 창 종료 시 CancellationToken과 타이머를 함께 정리해 후속 종료 처리도 안전하게 맞춤
- 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 15:41:27 +09:00
71fd5f0bb7 빈 상태 프리셋 화면 상하 중앙 정렬 보정
- ChatWindow의 EmptyState 레이아웃을 제목/설명/프리셋 목록이 하나의 세로 묶음으로 동작하도록 재구성함
- 화면 높이가 커질 때 프리셋 카드만 중앙에 오고 설명 블록은 위에 남던 문제를 해결함
- 채팅/코워크 빈 상태 화면이 전체적으로 상하 중앙에 자연스럽게 정렬되도록 보정함
- 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 15:37:20 +09:00
b3b301b9b6 AX Agent 공통 설정 섹션 순서 재정리 및 운영 모드 하단 배치
Some checks failed
Release Gate / gate (push) Has been cancelled
- 내부 설정 공통 탭에서 서비스와 모델 바로 아래에 등록 모델 관리를 배치해 모델 관리 흐름을 연속되게 정리함
- 운영 모드를 대화 관리와 저장 공간 영역 아래로 이동하고 섹션 구분선을 다시 맞춰 가독성을 개선함
- 공통 탭의 섹션 그룹이 실제 사용 순서와 더 가깝게 보이도록 XAML 레이아웃을 정리함
- 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 15:34:37 +09:00
d45698d397 AX Agent 창 드래그 성능 개선 및 레이아웃 재계산 지연 처리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에 WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE 감지를 추가해 창 이동·리사이즈 루프를 별도로 관리함
- 이동 중에는 루트 visual을 BitmapCache로 전환하고 무거운 transcript/layout 재계산을 지연시켜 드래그 버벅임을 줄임
- SizeChanged에서 주제 스크롤/반응형 레이아웃/메시지 재렌더를 즉시 수행하지 않고 종료 시 한 번만 반영하도록 정리함
- 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 15:28:29 +09:00
421a2c97f9 AX Agent 권한 버튼 테두리 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
코워크·코드 하단 우측의 권한 요청 버튼이 footer 작업 바와 더 자연스럽게 이어지도록 외곽 테두리를 제거했습니다.

README와 docs/DEVELOPMENT.md에 2026-04-06 15:31 (KST) 기준 변경 이력을 반영했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 15:21:54 +09:00
9d13456695 AX Agent 하단 안내 문구 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
채팅·코워크·코드 하단 안내 문구를 현재 구현된 기능 기준으로 다시 정리했습니다.

입력 워터마크는 탭 종류와 작업 폴더 선택 여부를 보고 실제 가능한 작업을 안내하도록 공통 helper로 통일했고, 선택된 프리셋 안내는 placeholder 대신 설명 중심으로 정리해 역할을 분리했습니다.

README와 docs/DEVELOPMENT.md에 2026-04-06 15:26 (KST) 기준 변경 이력을 반영했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 15:19:44 +09:00
d283cf26ba AX Agent 권한 버튼 앞 구분선 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
코워크·코드 하단 폴더 바에서 권한 선택 버튼 왼쪽에 보이던 세로 구분선을 제거해 footer 작업 바가 더 단순하게 이어지도록 정리했습니다.

README와 docs/DEVELOPMENT.md에 2026-04-06 15:18 (KST) 기준 변경 이력을 반영했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 15:15:01 +09:00
5401fcf7bb AX Agent 입력창 액션 버튼 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
채팅·코워크·코드 공통 composer 우측 상단의 프리셋 선택 버튼을 제거해 입력 영역을 단순화했습니다.

파일 첨부 버튼을 약 1.5배 키우고 전송 버튼도 같은 정사각 기준으로 맞춘 뒤, 두 버튼 아이콘을 모두 상하좌우 중앙 정렬로 보정했습니다.

README와 docs/DEVELOPMENT.md에 2026-04-06 15: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 15:11:34 +09:00
7c5396e239 AX Agent 사용자 메시지 메타 겹침 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
사용자 메시지 하단 메타 행에서 시간 표시와 복사/편집 액션이 같은 위치에 겹치던 레이아웃을 수정했습니다.

ChatWindow.MessageBubblePresentation에서 사용자 메타 바를 전용 컬럼 구조로 분리해 시간과 액션 버튼이 항상 분리되어 표시되도록 정리했습니다.

README와 docs/DEVELOPMENT.md에 수정 이력을 2026-04-06 15:04 (KST) 기준으로 반영했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 15:07:18 +09:00
817fc94f41 IBM 연동형 vLLM 인증 실패 원인 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
IBM Cloud 계열 vLLM 연결에서 등록 모델 인증 방식이 Bearer와 CP4D만 지원하던 문제를 점검하고, IBM IAM 토큰 교환 경로를 추가했습니다.

- RegisteredModel/AuthType에 ibm_iam 경로를 반영했습니다.

- IbmIamTokenService를 추가해 API 키를 IAM access token으로 교환한 뒤 Bearer 헤더로 적용하도록 했습니다.

- 모델 등록 다이얼로그, 설정 ViewModel, AX Agent 오버레이 모델 목록에도 IBM IAM 표시를 추가했습니다.

- README.md와 docs/DEVELOPMENT.md에 2026-04-06 14:06 (KST) 기준 이력을 반영했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 15:02:42 +09:00
3feb1f0be4 권한·도구 결과 카드 액션 문구 고도화
파일 기반 transcript 카드의 액션 버튼 라벨을 상태와 종류에 맞게 세분화했습니다.

변경 확인, 작성 내용 보기, 부분 결과 보기, 오류 파일 보기, 승인 전 미리보기 등 맥락별 문구를 적용했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 14:29:05 +09:00
c4d050f2bf 권한·도구 결과 callout 상태 제목 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
transcript callout이 상태별로 다른 제목과 강조선을 가지도록 정리했습니다.

권한 요청은 확인 포인트와 적용 내용, 도구 결과는 승인 필요, 오류 확인, 부분 완료 점검, 다음 권장 작업으로 구분되게 바꿨습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:54:31 +09:00
a46b4bf9c0 권한·도구 결과 카드 프리뷰 연결 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
파일 경로가 있는 권한 요청과 도구 결과 transcript 카드에 프리뷰 열기 액션을 추가했습니다.

미리보기 권장 상태나 파일 기반 결과에서 사용자가 카드에서 바로 preview panel로 이동할 수 있게 연결했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:51:37 +09:00
ec0ed7fb1c 권한·도구 결과 카드 callout 구조 도입
Some checks failed
Release Gate / gate (push) Has been cancelled
권한 요청과 도구 결과 transcript 안내를 단순 보조 문구에서 callout 카드 구조로 정리했습니다.

권한 요청은 확인 포인트, 도구 결과는 다음 권장 작업으로 보여주도록 바꿔 카드 성격 차이를 더 분명하게 만들었습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:43:19 +09:00
571d4bfaca 권한·도구 결과 카테고리 표시 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
transcript 카드에 작업 종류 chip을 추가해 권한 요청과 도구 결과의 성격을 더 빠르게 구분할 수 있게 했습니다.

명령 실행, 파일 수정, 웹 요청, Git, 문서, 스킬, MCP 등의 카테고리를 상태 chip과 함께 표시하도록 정리했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:26:20 +09:00
fdf95aa6ec 권한·도구 결과 카드 시각 구분 강화
권한 요청과 도구 결과 transcript chip을 상태별 색상으로 분리했습니다.

미리보기 권장, 주의 필요, 검토 권장, 승인 후 계속, 후속 점검을 색과 라벨로 더 즉시 구분되게 정리했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:14:07 +09:00
7fd4d1ae5a 권한·도구 결과 transcript 안내 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
권한 요청과 도구 결과 metadata를 실제 transcript 카드에 반영했습니다.

ActionHint, Severity, RequiresPreview, FollowUpHint, NeedsAttention, StatusKind를 이용해 보조 문구와 주의 chip을 표시하도록 정리했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 13:03:18 +09:00
e747032501 도구·권한·스킬 표현 정교화 1차 반영
Some checks failed
Release Gate / gate (push) Has been cancelled
- 권한 요청 카탈로그를 bash/powershell/web_fetch/mcp/skill/file_edit/file_write/git/document/filesystem 수준으로 세분화했습니다.

- 도구 결과 카탈로그에 approval_required, partial, follow-up hint, attention 메타를 추가해 후속 renderer 고도화 기반을 마련했습니다.

- 스킬 갤러리에 모델, 추론 강도, 실행 컨텍스트, 에이전트, 모델 호출 비활성화, 추천 상황을 표시하도록 확장했습니다.

- README, DEVELOPMENT, parity plan, regression prompts 문서를 2026-04-06 11:52 (KST) 기준으로 갱신했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 12:57:15 +09:00
f11b8b74b7 AX Agent footer 작업 바 chip 스타일 통일 및 후속 polish
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에 FooterChipBtn 스타일을 추가해 하단 작업 바 버튼 언어를 공통화
- 권한과 Git 브랜치 버튼이 같은 라운드/테두리/패딩 규칙을 사용하도록 정리
- README와 DEVELOPMENT 문서에 2026-04-06 11:34 (KST) 기준 footer polish 반영
- dotnet build 검증 경고 0, 오류 0 확인
2026-04-06 12:18:10 +09:00
b4d69f5db3 AX Agent popup 시각 언어 통일 및 선택 surface 후속 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- 공통 popup factory가 surface visual helper를 사용하도록 정리
- worktree 선택과 권한 모드 row의 border/hover/selected 규칙을 같은 visual language로 통일
- README와 DEVELOPMENT 문서에 2026-04-06 11:27 (KST) 기준 popup polish 누적 범위 반영
- dotnet build 검증 경고 0, 오류 0 확인
2026-04-06 12:12:44 +09:00
3c3faab528 AX Agent surface visual language 공통화 및 preview/file browser 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에 공통 popup container/menu item/separator/file tree header helper를 추가
- Preview와 FileBrowser presentation이 같은 surface 스타일을 사용하도록 정리
- README와 DEVELOPMENT 문서에 2026-04-06 11:20 (KST) 기준 visual polish 1차 반영
- dotnet build 검증 경고 0, 오류 0 확인
2026-04-06 12:09:18 +09:00
9aa99cdfe6 AX Agent 대화 목록 필터 프레젠테이션 분리 및 사이드바 구조 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에서 대화 목록 필터/정렬 interaction과 선호 저장 로직을 ConversationFilterPresentation partial로 분리
- 실행 중 보기, 최근/활동 정렬, 관련 버튼 UI 갱신을 메인 창 orchestration 코드 밖으로 이동
- README와 DEVELOPMENT 문서에 2026-04-06 11:11 (KST) 기준 구조 개선 누적 완료 범위 반영
- dotnet build 검증 경고 0, 오류 0 확인
2026-04-06 11:37:44 +09:00
3ac8a7155f AX Agent 사이드바 상호작용 프레젠테이션 분리 및 구조 개선 후속 정리
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow에서 검색/새 대화 사이드바 interaction을 SidebarInteractionPresentation partial로 분리
- 검색 열기/닫기 애니메이션과 hover 상태 변경을 메인 창 orchestration 코드 밖으로 이동
- README와 DEVELOPMENT 문서에 2026-04-06 11:03 (KST) 기준 구조 개선 완료 범위와 남은 후속 작업 수준 반영
- dotnet build 검증 경고 0, 오류 0 확인
2026-04-06 11:28:26 +09:00
3e44f1fc4d AX Agent 대화 목록 관리 프레젠테이션 분리 및 구조 개선 마감\n\n- ChatWindow에서 제목 인라인 편집과 대화 관리 팝업 렌더를 ConversationManagementPresentation partial로 분리\n- 고정, 이름 변경, 카테고리 변경, 삭제 등 대화 목록 관리 상호작용을 메인 창 orchestration 코드 밖으로 이동\n- README와 DEVELOPMENT 문서에 구조 개선 완료 범위와 남은 작업 수준을 2026-04-06 10:56 (KST) 기준으로 반영\n- dotnet build 검증 경고 0, 오류 0 확인
Some checks failed
Release Gate / gate (push) Has been cancelled
2026-04-06 10:58:13 +09:00
fa431f1666 AX Agent timeline 프레젠테이션 helper를 한 파일로 모아 렌더 책임을 더 줄인다
Some checks failed
Release Gate / gate (push) Has been cancelled
CreateTimelineLoadMoreCard, ToAgentEvent, IsCompactionMetaMessage, CreateCompactionMetaCard를 ChatWindow.TimelinePresentation.cs로 이동해 RenderMessages 주변의 timeline 관련 helper를 하나의 presentation surface로 정리했다.

README와 DEVELOPMENT 문서에 2026-04-06 10:44 (KST) 기준 이력을 반영했고, dotnet build 검증 결과 경고 0 / 오류 0을 확인했다.
2026-04-06 10:42:01 +09:00
fda5c6bace AX Agent timeline 조립 helper를 분리해 메시지 렌더 구조를 단순화한다
Some checks failed
Release Gate / gate (push) Has been cancelled
RenderMessages()가 직접 처리하던 visible 메시지/이벤트 필터링과 timestamp/order 기반 timeline action 조립을 ChatWindow.TimelinePresentation.cs로 이동해 메인 렌더 루프가 orchestration 중심으로 더 단순해지도록 정리했다.

README와 DEVELOPMENT 문서에 2026-04-06 10:36 (KST) 기준 이력을 반영했고, dotnet build 검증 결과 경고 0 / 오류 0을 확인했다.
2026-04-06 10:33:57 +09:00
b3b5f8a79d AX Agent transcript 메시지 버블 렌더를 분리해 메인 창 구조를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
사용자/assistant 메시지 버블, 분기 컨텍스트 카드, 액션 바, 응답 메타 조립을 ChatWindow.MessageBubblePresentation.cs로 이동해 ChatWindow.xaml.cs가 transcript orchestration과 런타임 흐름에 더 집중하도록 정리했다.

README와 DEVELOPMENT 문서에 2026-04-06 10:27 (KST) 기준 이력을 반영했고, dotnet build 검증 결과 경고 0 / 오류 0을 확인했다.
2026-04-06 10:29:54 +09:00
8faa26b134 AX Agent 대화 목록 렌더를 별도 프레젠테이션 파일로 분리하고 문서 이력을 갱신한다
Some checks failed
Release Gate / gate (push) Has been cancelled
좌측 대화 목록의 메타 필터링, spotlight 계산, 그룹 렌더, 더 보기 페이지네이션, 목록 카드 조립을 ChatWindow.ConversationListPresentation.cs로 이동해 ChatWindow.xaml.cs가 transcript/runtime orchestration에 더 집중하도록 정리했다.

README와 DEVELOPMENT 문서에 2026-04-06 10:18 (KST) 기준 변경 이력을 반영했고, dotnet build 검증 결과 경고 0 / 오류 0을 확인했다.
2026-04-06 10:23:25 +09:00
1b4566d192 AX Agent 프리셋 렌더와 주제 선택 흐름을 분리해 메인 창 책임을 줄인다
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.TopicPresetPresentation을 추가해 프리셋 카드 생성, 커스텀 프리셋 메뉴, 주제 선택 적용 흐름을 별도 프레젠테이션 계층으로 이동한다

- ChatWindow.xaml.cs에서는 프리셋 UI 조립 코드를 제거하고 대화 orchestration 중심 구조를 더 강화한다

- claw-code parity plan과 개발 문서에 구조 개선 진행 상황을 반영해 큰 구조 개선 항목이 사실상 마감 단계임을 기록한다

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 10:15:22 +09:00
d0d66c1d52 AX Agent footer와 Git 브랜치 프레젠테이션 구조를 분리하고 회귀 점검 루틴을 고정한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.FooterPresentation에서 Git 브랜치 팝업 렌더와 요약 helper를 분리해 폴더 바 상태/프리셋 안내 동기화 책임만 남긴다

- ChatWindow.GitBranchPresentation을 추가해 브랜치 팝업 조립, 요약 pill, 최근 브랜치/전환 액션 렌더를 별도 프레젠테이션 계층으로 옮긴다

- AX_AGENT_REGRESSION_PROMPTS 문서를 재작성해 실패 분류와 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-06 10:06:25 +09:00
9464dd0234 의견 요청과 계획 승인 렌더를 분리해 메시지 타입 구조를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.InlineInteractions를 UserAskPresentation과 PlanApprovalPresentation으로 분리해 사용자 질문 카드와 계획 승인 흐름의 책임을 나눔

- 메시지 타입 renderer 분리 계획의 다음 단계로 ChatWindow.xaml.cs와 mixed inline interaction partial의 결합도를 낮춤

- README, DEVELOPMENT, claw-code parity plan 문서를 2026-04-06 09:44 (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:53:07 +09:00
d5c1266d3e 상태선/권한 카탈로그 구조 정리와 계획 모드 표현 잔재 제거
Some checks failed
Release Gate / gate (push) Has been cancelled
- OperationalStatusPresentationCatalog를 추가해 compact strip과 quick strip의 색상/노출 계산을 AppStateService 밖으로 분리함

- PermissionRequestPresentationCatalog와 ToolResultPresentationCatalog에 Kind/Description 메타를 추가해 transcript fallback 설명을 타입 기반으로 정리함

- PermissionModePresentationCatalog와 ChatWindow.PermissionPresentation에서 제거된 계획 모드 표현 분기를 걷어 권한 UI를 실제 지원 모드만 다루도록 단순화함

- README, DEVELOPMENT, claw-code parity plan 문서를 2026-04-06 09:36 (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:44:53 +09:00
3924bac9f9 claw-code 대조 기반 AX Agent 품질 향상 3트랙 계획을 수립한다
- 사용자 체감 UI/UX, LLM·작업 처리, 유지보수·추가기능 구조의 3트랙으로 남은 품질 향상 과제를 재정리했다.

- docs/claw-code-parity-plan.md에 참조 파일, AX 적용 위치, 완료 조건, 품질 기준, 권장 실행 순서를 고정했다.

- README와 DEVELOPMENT 문서에 2026-04-06 09:27 (KST) 기준 계획 변경 이력을 반영했다.

- 이번 변경은 문서화 작업으로 코드 변경이 없어 별도 빌드는 생략했다.
2026-04-06 09:30:13 +09:00
9f5a9d315c AX Agent 선택 팝업 렌더를 분리해 footer 선택 UX 구조를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.SelectionPopupPresentation.cs를 추가해 워크트리 선택 팝업과 공통 선택 row 렌더를 메인 창 코드 밖으로 이동했다.

- ChatWindow.xaml.cs에서 작업 위치 선택 팝업 조립 책임을 제거해 대화 상태와 세션 orchestration 중심 구조를 더 선명하게 만들었다.

- README와 DEVELOPMENT 문서에 2026-04-06 09:14 (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:19:10 +09:00
4c1513a5da AX Agent 공통 팝업과 시각 상호작용 렌더를 분리해 메인 창 구조를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled
- 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
223 changed files with 85169 additions and 10407 deletions

1029
README.md

File diff suppressed because it is too large Load Diff

235
build.bat
View File

@@ -1,90 +1,199 @@
@echo off @echo off
setlocal EnableExtensions
chcp 65001 >nul chcp 65001 >nul
set "ROOT=%~dp0"
pushd "%ROOT%" >nul
echo. echo.
echo ======================================== echo ========================================
echo AX Copilot - Build Script echo AX Copilot - Build Script
echo ======================================== echo ========================================
echo. echo.
set APP=src\AxCopilot\AxCopilot.csproj set "APP=%ROOT%src\AxCopilot\AxCopilot.csproj"
set ENCRYPTOR=src\AxKeyEncryptor\AxKeyEncryptor.csproj set "ENCRYPTOR=%ROOT%src\AxKeyEncryptor\AxKeyEncryptor.csproj"
set OFFLINE=src\AxCopilot.Installer\AxCopilot.Installer.csproj set "INSTALLER=%ROOT%src\AxCopilot.Installer\AxCopilot.Installer.csproj"
set OUT=dist set "INSTALLER_DIR=%ROOT%src\AxCopilot.Installer"
set "OUT=%ROOT%dist"
set "APP_OUT=%OUT%\AxCopilot"
set "ENCRYPTOR_OUT=%OUT%\AxKeyEncryptor"
set "PAYLOAD_ZIP=%INSTALLER_DIR%\payload.zip"
set "INSTALLER_EXE=%INSTALLER_DIR%\bin\Release\net48\AxCopilot_Setup.exe"
set "RUNTIME=win-x64"
set "OBFUSCATOR_EXE=%ROOT%tools\obfuscator\obfuscator.exe"
set "OBFUSCATOR_CONFIG=%ROOT%tools\obfuscator\AxCopilot.obfuscation.xml"
:: Kill running app call :stop_process "AxCopilot" "AX Copilot"
tasklist /FI "IMAGENAME eq AxCopilot.exe" 2>nul | find /i "AxCopilot.exe" >nul if errorlevel 1 goto :fail_running
if %ERRORLEVEL%==0 ( call :stop_process "AxCommander" "legacy AxCommander"
echo [0] Stopping AxCopilot... if errorlevel 1 goto :fail_running
taskkill /IM AxCopilot.exe /F >nul 2>nul
timeout /t 2 /nobreak >nul
)
:: Kill legacy process
tasklist /FI "IMAGENAME eq AxCommander.exe" 2>nul | find /i "AxCommander.exe" >nul
if %ERRORLEVEL%==0 (
echo [0] Stopping legacy AxCommander...
taskkill /IM AxCommander.exe /F >nul 2>nul
timeout /t 2 /nobreak >nul
)
if exist "%OUT%" rd /s /q "%OUT%" 2>nul if exist "%OUT%" rd /s /q "%OUT%" 2>nul
mkdir "%OUT%" mkdir "%OUT%" || goto :fail_dist
mkdir "%OUT%\AxCopilot" mkdir "%APP_OUT%" || goto :fail_dist
mkdir "%ENCRYPTOR_OUT%" || goto :fail_dist
:: ======================================== if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
:: 1. Main app (self-contained, folder)
:: ======================================== echo [1/5] Building main app (self-contained %RUNTIME%)...
echo [1/4] Building main app (self-contained)... dotnet publish "%APP%" ^
dotnet publish "%APP%" -c Release -o "%OUT%\AxCopilot" --self-contained true --nologo -v quiet -c Release ^
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] Main app build & pause & exit /b 1 ) -r %RUNTIME% ^
echo OK - dist\AxCopilot\ --self-contained true ^
-o "%APP_OUT%" ^
--nologo ^
-v minimal ^
-p:DebugType=None ^
-p:DebugSymbols=false ^
-p:CopyOutputSymbolsToPublishDirectory=false ^
-p:EnableSourceLink=false ^
-p:PublishSingleFile=true ^
-p:EnableCompressionInSingleFile=true ^
-p:IncludeNativeLibrariesForSelfExtract=true ^
-p:PublishReadyToRun=true
if errorlevel 1 goto :fail_app
echo OK - %APP_OUT%
echo. echo.
:: ======================================== echo [2/5] Checking obfuscation / anti-decompile status...
:: 2. AxKeyEncryptor (developer tool) if exist "%OBFUSCATOR_EXE%" (
:: ======================================== if exist "%OBFUSCATOR_CONFIG%" (
echo [2/4] Building AxKeyEncryptor (WinForms)... echo Optional obfuscator found.
mkdir "%OUT%\AxKeyEncryptor" 2>nul echo Running: "%OBFUSCATOR_EXE%"
dotnet publish "%ENCRYPTOR%" -c Release -o "%OUT%\AxKeyEncryptor" --self-contained false --nologo -v quiet "%OBFUSCATOR_EXE%" "%OBFUSCATOR_CONFIG%" "%APP_OUT%"
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] AxKeyEncryptor build & pause & exit /b 1 ) if errorlevel 1 goto :fail_obfuscation
del /q "%OUT%\AxKeyEncryptor\*.pdb" 2>nul echo OK - obfuscation step completed
echo OK - dist\AxKeyEncryptor\ ) else (
echo WARNING - no external obfuscator configured.
echo Current protection is limited to symbol/source metadata removal only.
)
) else (
echo WARNING - no external obfuscator configured.
echo Current protection is limited to symbol/source metadata removal only.
)
echo. echo.
:: ======================================== echo [3/5] Building AxKeyEncryptor...
:: 3. Create payload ZIP for installer dotnet publish "%ENCRYPTOR%" ^
:: ======================================== -c Release ^
echo [3/4] Creating installer payload ZIP... -o "%ENCRYPTOR_OUT%" ^
powershell -NoProfile -Command "Compress-Archive -Path '%OUT%\AxCopilot\*' -DestinationPath 'src\AxCopilot.Installer\payload.zip' -Force" --self-contained false ^
echo OK - payload.zip --nologo ^
-v minimal ^
-p:DebugType=None ^
-p:DebugSymbols=false
if errorlevel 1 goto :fail_encryptor
echo OK - %ENCRYPTOR_OUT%
echo. echo.
:: ======================================== echo [4/5] Creating installer payload ZIP...
:: 4. Build installer (.NET Framework 4.8) powershell -NoProfile -Command "Compress-Archive -Path '%APP_OUT%\*' -DestinationPath '%PAYLOAD_ZIP%' -Force"
:: ======================================== if errorlevel 1 goto :fail_payload
echo [4/4] Building installer (.NET Framework 4.8)... if not exist "%PAYLOAD_ZIP%" goto :fail_payload
dotnet build "%OFFLINE%" -c Release --nologo -v quiet echo OK - %PAYLOAD_ZIP%
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] Installer build & pause & exit /b 1 ) echo.
copy /Y "src\AxCopilot.Installer\bin\Release\net48\AxCopilot_Setup.exe" "%OUT%\" >nul
echo [5/5] Building installer (.NET Framework 4.8)...
dotnet build "%INSTALLER%" -c Release --nologo -v minimal
if errorlevel 1 goto :fail_installer
if not exist "%INSTALLER_EXE%" goto :fail_installer_copy
copy /Y "%INSTALLER_EXE%" "%OUT%\" >nul
if errorlevel 1 goto :fail_installer_copy
for %%F in ("%OUT%\AxCopilot_Setup.exe") do echo OK - AxCopilot_Setup.exe (%%~zF bytes) for %%F in ("%OUT%\AxCopilot_Setup.exe") do echo OK - AxCopilot_Setup.exe (%%~zF bytes)
echo. echo.
:: ======================================== echo [Cleanup] Removing debug and metadata files from dist...
:: Cleanup call :clean_publish_artifacts "%OUT%"
:: ======================================== call :clean_publish_artifacts "%APP_OUT%"
:: Remove debug symbols and metadata (anti-decompile) call :clean_publish_artifacts "%ENCRYPTOR_OUT%"
del /q "%OUT%\*.pdb" 2>nul if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
del /q "%OUT%\AxCopilot\*.pdb" 2>nul echo OK - cleaned
del /q "%OUT%\AxCopilot\*.xml" 2>nul echo.
del /q "%OUT%\*.deps.json" 2>nul
del /q "%OUT%\*.runtimeconfig.json" 2>nul
del /q "src\AxCopilot.Installer\payload.zip" 2>nul
echo ======================================== echo ========================================
echo Build Complete! echo Build Complete!
echo ======================================== echo ========================================
echo. echo.
echo dist\AxCopilot\ Main app (EXE + DLL) echo %APP_OUT% Main app
echo dist\AxKeyEncryptor\ Settings Encryptor (dev tool) echo %ENCRYPTOR_OUT% Settings Encryptor
echo dist\AxCopilot_Setup.exe Installer (offline, .NET 4.8) echo %OUT%\AxCopilot_Setup.exe Installer
echo. echo.
pause echo Note:
echo - Release/self-contained single-file publish applied
echo - ReadyToRun + compressed single-file bundle enabled
echo - PDB/XML/debug metadata removed from dist output
echo - External obfuscator is only applied when tools\obfuscator is configured
echo.
popd >nul
exit /b 0
:stop_process
set "PROC_NAME=%~1"
set "DISPLAY_NAME=%~2"
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$name='%PROC_NAME%';" ^
"$display='%DISPLAY_NAME%';" ^
"$procs = Get-Process -Name $name -ErrorAction SilentlyContinue;" ^
"if (-not $procs) { exit 0 }" ^
"Write-Host ('[0] Stopping ' + $display + '...');" ^
"$imageName = $name + '.exe';" ^
"foreach ($proc in $procs) {" ^
" try { if ($proc.MainWindowHandle -ne 0) { [void]$proc.CloseMainWindow() } } catch { }" ^
"}" ^
"Start-Sleep -Seconds 2;" ^
"& taskkill /IM $imageName /T /F > $null 2> $null;" ^
"Start-Sleep -Seconds 2;" ^
"$stillRunning = Get-Process -Name $name -ErrorAction SilentlyContinue;" ^
"if ($stillRunning) {" ^
" Write-Host ('[FAILED] Could not stop ' + $display + '. Access may be denied or the app may be running with higher privileges.') -ForegroundColor Red;" ^
" exit 1" ^
"}" ^
"exit 0"
if errorlevel 1 exit /b 1
exit /b 0
:clean_publish_artifacts
if not exist "%~1" exit /b 0
del /q "%~1\*.pdb" 2>nul
del /q "%~1\*.xml" 2>nul
del /q "%~1\*.deps.json" 2>nul
del /q "%~1\*.runtimeconfig.json" 2>nul
exit /b 0
:fail_dist
echo [FAILED] dist ??€????밴쉐 ??쎈솭
goto :end_fail
:fail_app
echo [FAILED] main app publish ??쎈솭
goto :end_fail
:fail_obfuscation
echo [FAILED] obfuscation ??€???쎈솭
goto :end_fail
:fail_encryptor
echo [FAILED] AxKeyEncryptor publish ??쎈솭
goto :end_fail
:fail_payload
echo [FAILED] payload.zip ??밴쉐 ??쎈솭
goto :end_fail
:fail_installer
echo [FAILED] installer build ??쎈솭
goto :end_fail
:fail_installer_copy
echo [FAILED] installer exe 癰귣벊沅???쎈솭
goto :end_fail
:fail_running
echo [FAILED] running AX Copilot process could not be stopped cleanly
goto :end_fail
:end_fail
if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
popd >nul
exit /b 1

View File

@@ -0,0 +1,128 @@
# AX Agent Regression Prompts
업데이트: 2026-04-06 09:58 (KST)
`claw-code`와 AX Agent를 같은 기준으로 비교하기 위한 공통 회귀 프롬프트 세트입니다.
## 사용 규칙
- 런타임 동작, transcript 렌더, 권한/계획/질문 UX, queue/compact/reopen 흐름에 영향을 주는 변경 뒤에는 이 문서를 기준으로 최소 1회 점검합니다.
- 모든 항목을 매번 수동 실행할 필요는 없지만, 관련 축이 바뀌었으면 해당 묶음은 반드시 확인합니다.
- 결과는 “문장이 똑같은가”가 아니라 “실행 경로와 사용자 체감 결과가 같은가”를 봅니다.
## 실패 분류
- `blank-reply`: 토큰은 소비됐는데 본문이 비어 있거나 assistant 카드가 비어 있음
- `duplicate-banner`: 같은 실행 이벤트가 transcript에 중복 표시됨
- `bad-approval-flow`: 권한/계획/질문 요청이 inline으로 안 닫히고 popup 의존이 커짐
- `queue-drift`: 후속 요청, retry, regenerate가 다른 실행 경로를 타거나 순서가 어긋남
- `restore-drift`: reopen 후 상태선, queue, 최신 메시지 상태가 달라짐
- `status-noise`: Cowork/Code 기본 상태선이 과하게 흔들리거나 debug 정보가 과노출됨
## Chat
1. 기본 응답
- 프롬프트: `회의 일정 조정 메일을 정중한 한국어로 써줘`
- 확인:
- `blank-reply`
- `restore-drift`
2. 장문 설명
- 프롬프트: `RAG와 fine-tuning 차이를 실무 관점으로 7가지로 설명해줘`
- 확인:
- 장문 렌더 안정성
- compact 이후 다음 턴 문맥 유지
- `blank-reply`
## Cowork
3. 문서형 작업
- 프롬프트: `신규 ERP 도입 제안서 초안을 작성해줘. 목적, 범위, 기대효과, 추진일정 포함`
- 확인:
- 작업 유형 반영
- 계획 이후 실제 문서형 결과 흐름
- 기본 로그 과노출 없음
- `bad-approval-flow`
4. 데이터형 작업
- 프롬프트: `매출 CSV를 분석해서 월별 추세와 이상치를 요약해줘`
- 확인:
- 데이터 분석 도구 선택
- 결과 요약 일관성
- runtime 노이즈 최소화
- `status-noise`
## Code
5. 버그 수정
- 프롬프트: `현재 프로젝트에서 설정 저장 버그 원인 찾고 수정해줘`
- 확인:
- 읽기/검색/수정 흐름 일관성
- diff/저장/재오픈 시 transcript 보존
- `restore-drift`
6. 빌드/테스트
- 프롬프트: `빌드 오류를 재현하고 수정한 뒤 다시 빌드해줘`
- 확인:
- build/test 루프
- 실패 후 재시도
- 완료 메시지 일관성
- `queue-drift`
## Cross-tab
7. 후속 요청
- 프롬프트 순서:
- `이 창 레이아웃 문제 원인 찾아줘`
- `끝나면 README도 같이 갱신해줘`
- 확인:
- queue chaining
- 입력창 직접 변경 없이 다음 턴 실행
- `queue-drift`
8. compact 이후 연속성
- 프롬프트: `지금까지 논의한 내용을 5줄로 이어서 정리하고 다음 작업 제안해줘`
- 확인:
- token-only completion 없음
- compact 후 문맥 유지
- `queue-drift`
9. 권한 승인
- 프롬프트: `이 파일을 수정해서 저장해줘`
- 확인:
- 권한 요청 transcript 표시
- 승인/거부 결과 일관성
- `bad-approval-flow`
10. slash / skill
- 프롬프트: `/bug-hunt src 폴더 잠재 버그 찾아줘`
- 확인:
- slash 진입과 일반 send 경로 동일성
- skill 실행 이유/결과 표기
- `queue-drift`
## 개발 루틴 고정
- transcript, permission, tool-result, queue, compact, reopen에 영향을 주는 변경은 커밋 전 아래를 기준으로 셀프 체크합니다.
- Chat 변경: 1, 2, 8
- Cowork 변경: 3, 4, 7, 8
- Code 변경: 5, 6, 7, 9, 10
- 체크 후 문서 이력에는 “어떤 묶음을 확인했는지”를 간단히 남깁니다.
## Tool / Permission Follow-up
11. 권한 거부 후 재시도
- 프롬프트 순서:
- `src 폴더에서 설정 파일을 수정해줘`
- 첫 권한 요청은 거부
- 같은 작업을 다시 요청
- 확인:
- `reject``approval_required`가 같은 결과 카드처럼 보이지 않음
- 재시도 시 권한 메시지와 도구 결과가 중복되지 않음
- `bad-approval-flow`
12. 부분 성공 / 후속 안내
- 프롬프트: `여러 문서 파일을 한 번에 읽고 요약해줘`
- 확인:
- 일부 실패가 있으면 `partial` 계열 안내가 보이는지
- 후속 안내 문구가 단순 실패와 다르게 보이는지
- `status-noise`

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,108 @@
## Scope ## Scope
- Align AX Copilot with claw-code quality for loop reliability, permission/hook behavior, and session durability. - 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.
- Updated: 2026-04-06 09:36 (KST)
- Progressed the maintainability track by moving runtime strip styling into `OperationalStatusPresentationCatalog.cs`, expanding permission/tool-result transcript catalogs with typed descriptions, and removing the stale plan-mode presentation branch from permission UI surfaces. The next structural focus remains footer/status/composer presentation slimming and regression ritual enforcement.
- Updated: 2026-04-06 09:44 (KST)
- Continued the maintainability track by splitting mixed inline interaction rendering into `ChatWindow.UserAskPresentation.cs` and `ChatWindow.PlanApprovalPresentation.cs`. This reduces message-type coupling inside the main window and keeps the next focus on footer/composer presentation and regression-routine formalization.
- Updated: 2026-04-06 09:58 (KST)
- Continued the maintainability track by splitting Git branch popup and footer-adjacent summary helpers into `ChatWindow.GitBranchPresentation.cs`, leaving `ChatWindow.FooterPresentation.cs` focused on folder bar state and preset-guide sync only.
- Formalized the regression ritual in `docs/AX_AGENT_REGRESSION_PROMPTS.md` by adding failure classes (`blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise`) and required prompt bundles per change area.
- Updated: 2026-04-06 10:07 (KST)
- Continued the maintainability track by moving topic preset rendering, custom preset context menus, and topic-selection application flow into `ChatWindow.TopicPresetPresentation.cs`. This reduces mixed preset UI logic inside `ChatWindow.xaml.cs` and keeps the main window closer to orchestration-only responsibility.
- Updated: 2026-04-06 11:52 (KST)
- Continued the tool/permission/skill sophistication track by expanding AX presentation catalogs toward `claw-code` specificity. `PermissionRequestPresentationCatalog.cs` now models action-level permission kinds plus severity/action hints, `ToolResultPresentationCatalog.cs` now models `approval_required`/`partial` result states plus follow-up guidance, and the AX skill gallery now exposes runtime-policy metadata that was previously hidden.
## Preserved History (Summary) ## Preserved History (Summary)
- Core loop guards and post-tool verification gates are already partially implemented. - Core loop guards and post-tool verification gates are already partially implemented.
- Plan Mode, parallel tool execution, and unknown-tool recovery are in place. - Plan Mode, parallel tool execution, and unknown-tool recovery are in place.
- Session restore hardening is ongoing. - 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 ## Execution Tracks
1. Hook contract parity 1. Hook contract parity
- Structured hook output support (`updatedInput`, `updatedPermissions`, `additionalContext`). - Structured hook output support (`updatedInput`, `updatedPermissions`, `additionalContext`).
@@ -28,3 +125,399 @@
- Internal parity scenarios pass target threshold. - Internal parity scenarios pass target threshold.
- Resume/replay failures: zero. - Resume/replay failures: zero.
- `dotnet build` warnings/errors: 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
## Focused Quality Tracks
- Updated: 2026-04-06 09:27 (KST)
- The remaining improvement work should now be managed in three parallel tracks so UX polish, runtime quality, and maintainability do not get mixed together.
### Track 1. User-Facing UI/UX Quality
- Reference:
- `src/components/Messages.tsx`
- `src/components/MessageRow.tsx`
- `src/components/StatusLine.tsx`
- `src/components/PromptInput/PromptInput.tsx`
- `src/components/PromptInput/PromptInputFooter.tsx`
- `src/components/SessionPreview.tsx`
- AX apply target:
- `src/AxCopilot/Views/ChatWindow.xaml`
- `src/AxCopilot/Views/ChatWindow.MessageInteractions.cs`
- `src/AxCopilot/Views/ChatWindow.StatusPresentation.cs`
- `src/AxCopilot/Views/ChatWindow.FooterPresentation.cs`
- `src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs`
- Focus:
- keep transcript text dominant and metadata secondary
- make footer controls read as a task bar, not a settings strip
- unify preview surfaces, chooser popups, and approval cards under one visual language
- reduce visual noise from queue/status/diagnostic surfaces unless the state is actionable
- Completion criteria:
- message rows, footer, preview, and inline approval/question cards feel visually coherent
- chooser popups share the same spacing, hover behavior, and summary-row structure
- footer/status elements appear only when they convey useful state
- Quality criteria:
- a user can understand “what is happening now” from the transcript and footer without opening extra panels
- the interface remains readable under narrow widths without text clipping or layout jitter
### Track 2. LLM / Task Handling Quality
- Reference:
- `src/bootstrap/state.ts`
- `src/bridge/initReplBridge.ts`
- `src/bridge/sessionRunner.ts`
- `src/components/messages/AssistantToolUseMessage.tsx`
- `src/components/messages/PlanApprovalMessage.tsx`
- `src/components/permissions/*`
- AX apply target:
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`
- `src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs`
- `src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs`
- `src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs`
- `src/AxCopilot/Views/ChatWindow.InlineInteractions.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs`
- Focus:
- keep all entry routes (`send`, `retry`, `regenerate`, `queue`, `slash`) on the same prepared execution path
- distinguish `success / error / reject / cancel / needs-approval` tool results more clearly
- keep plan approval and user-question flows transcript-native by default
- minimize mismatches between execution state and what the user sees in the timeline
- Completion criteria:
- plan/permission/tool-result/question events all have consistent transcript-native lifecycles
- reopen/retry/queue/compact flows preserve the same visible runtime state
- tool failures and permission rejections are clearly distinguishable in transcript rendering
- Quality criteria:
- the same prompt under the same tab/settings uses the same execution route
- users can tell whether the agent succeeded, failed, was blocked, or is waiting for approval without reading raw diagnostics
### Track 3. Maintainability / Extensibility Structure
- Reference:
- `src/components/*` split by role in `claw-code`
- `src/components/messages/*`
- `src/components/permissions/*`
- `src/components/PromptInput/*`
- AX apply target:
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- `src/AxCopilot/Views/ChatWindow.*.cs`
- `src/AxCopilot/Services/AppStateService.cs`
- `src/AxCopilot/Services/Agent/*.cs`
- Focus:
- continue shrinking `ChatWindow.xaml.cs` toward orchestration-only responsibility
- keep renderer, popup, footer, message-interaction, status, and preview logic in dedicated partials
- centralize presentation rules in catalogs/models instead of scattered UI string/visibility branches
- prepare the codebase for new permission types, new tool classes, and new transcript card types without re-bloating the main window file
- Completion criteria:
- `ChatWindow.xaml.cs` owns orchestration and runtime coordination more than direct UI element construction
- new message/permission/tool card types can be added via presentation catalogs or dedicated partials
- runtime summary and footer/status visibility derive from presentation models rather than ad-hoc branching
- Quality criteria:
- adding a new tool-result or approval type should mostly affect one catalog/renderer area
- future UI polish work should land in dedicated presentation files rather than expanding the main window file again
## Recommended Execution Order
- Updated: 2026-04-06 09:27 (KST)
1. Finish Track 2 consistency first whenever a UX issue is caused by runtime truth mismatch.
2. Apply Track 1 visual cleanup only after the state/message lifecycle is stable for that surface.
3. Fold each stable surface into Track 3 structure immediately so later changes do not reintroduce `ChatWindow.xaml.cs` sprawl.
4. Keep validating against `docs/AX_AGENT_REGRESSION_PROMPTS.md` after each change set, especially for `plan / permission / queue / compact / reopen`.
## Current Snapshot
- Updated: 2026-04-05 19:42 (KST)
- Estimated parity:
- 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,10 +19,12 @@ public partial class App : System.Windows.Application
private SettingsService? _settings; private SettingsService? _settings;
private SettingsWindow? _settingsWindow; private SettingsWindow? _settingsWindow;
private PluginHost? _pluginHost; private PluginHost? _pluginHost;
private SchedulerService? _schedulerService;
private ClipboardHistoryService? _clipboardHistory; private ClipboardHistoryService? _clipboardHistory;
private DockBarWindow? _dockBar; private DockBarWindow? _dockBar;
private FileDialogWatcher? _fileDialogWatcher; private FileDialogWatcher? _fileDialogWatcher;
private volatile IndexService? _indexService; private volatile IndexService? _indexService;
private int _indexWarmupStarted;
public IndexService? IndexService => _indexService; public IndexService? IndexService => _indexService;
public SettingsService? SettingsService => _settings; public SettingsService? SettingsService => _settings;
public ClipboardHistoryService? ClipboardHistoryService => _clipboardHistory; public ClipboardHistoryService? ClipboardHistoryService => _clipboardHistory;
@@ -113,6 +115,9 @@ public partial class App : System.Windows.Application
_indexService = new IndexService(settings); _indexService = new IndexService(settings);
var indexService = _indexService; var indexService = _indexService;
indexService.LoadCachedIndex();
if (indexService.HasCachedIndexLoaded)
indexService.StartWatchers();
var fuzzyEngine = new FuzzyEngine(indexService); var fuzzyEngine = new FuzzyEngine(indexService);
var commandResolver = new CommandResolver(fuzzyEngine, settings); var commandResolver = new CommandResolver(fuzzyEngine, settings);
var contextManager = new ContextManager(settings); var contextManager = new ContextManager(settings);
@@ -172,6 +177,102 @@ public partial class App : System.Windows.Application
commandResolver.RegisterHandler(new EverythingHandler()); commandResolver.RegisterHandler(new EverythingHandler());
commandResolver.RegisterHandler(new HelpHandler(settings)); commandResolver.RegisterHandler(new HelpHandler(settings));
commandResolver.RegisterHandler(new ChatHandler(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.Refresh();
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); var pluginHost = new PluginHost(settings, commandResolver);
@@ -190,14 +291,9 @@ public partial class App : System.Windows.Application
() => _clipboardHistory?.Initialize(), () => _clipboardHistory?.Initialize(),
System.Windows.Threading.DispatcherPriority.ApplicationIdle); System.Windows.Threading.DispatcherPriority.ApplicationIdle);
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ────────── // ─── 런처 인덱스 캐시/감시 초기화 ─────────────────────────────────────
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림 // 앱 시작 시엔 저장된 캐시를 즉시 로드하고, 파일 감시는 증분 반영 위주로 유지합니다.
Dispatcher.BeginInvoke( // 무거운 전체 재색인은 실제 검색이 시작될 때 한 번만 보강 실행합니다.
() => PrewarmChatWindow(),
System.Windows.Threading.DispatcherPriority.SystemIdle);
// ─── 인덱스 빌드 (백그라운드) + 완료 후 FileSystemWatcher 시작 ────────
_ = indexService.BuildAsync().ContinueWith(_ => indexService.StartWatchers());
// ─── 글로벌 훅 + 스니펫 확장기 ─────────────────────────────────────── // ─── 글로벌 훅 + 스니펫 확장기 ───────────────────────────────────────
_inputListener = new InputListener(); _inputListener = new InputListener();
@@ -230,12 +326,21 @@ public partial class App : System.Windows.Application
if (_launcher == null || _launcher.IsVisible) return; if (_launcher == null || _launcher.IsVisible) return;
if (_settings?.Settings.Launcher.EnableFileDialogIntegration != true) return; if (_settings?.Settings.Launcher.EnableFileDialogIntegration != true) return;
WindowTracker.Capture(); WindowTracker.Capture();
_launcher.Show(); ShowLauncherWindow();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
() => _launcher.SetInputText("cd ")); () => _launcher.SetInputText("cd "));
}); });
}; };
_fileDialogWatcher.Start(); UpdateFileDialogWatcherState();
settings.SettingsChanged += (_, _) =>
{
Dispatcher.BeginInvoke(() =>
{
UpdateFileDialogWatcherState();
_schedulerService?.Refresh();
});
};
// 독 바 자동 표시 // 독 바 자동 표시
if (settings.Settings.Launcher.DockBarAutoShow) if (settings.Settings.Launcher.DockBarAutoShow)
@@ -269,7 +374,7 @@ public partial class App : System.Windows.Application
{ {
if (_launcher == null) return; if (_launcher == null) return;
UsageStatisticsService.RecordLauncherOpen(); UsageStatisticsService.RecordLauncherOpen();
_launcher.Show(); ShowLauncherWindow();
}); });
return; return;
} }
@@ -317,7 +422,7 @@ public partial class App : System.Windows.Application
{ {
case TextActionPopup.ActionResult.OpenLauncher: case TextActionPopup.ActionResult.OpenLauncher:
UsageStatisticsService.RecordLauncherOpen(); UsageStatisticsService.RecordLauncherOpen();
_launcher.Show(); ShowLauncherWindow();
break; break;
case TextActionPopup.ActionResult.None: case TextActionPopup.ActionResult.None:
break; // Esc 또는 포커스 잃음 break; // Esc 또는 포커스 잃음
@@ -332,7 +437,7 @@ public partial class App : System.Windows.Application
else else
{ {
UsageStatisticsService.RecordLauncherOpen(); UsageStatisticsService.RecordLauncherOpen();
_launcher.Show(); ShowLauncherWindow();
} }
}); });
} }
@@ -408,7 +513,7 @@ public partial class App : System.Windows.Application
}; };
// ! 프리픽스로 AX Agent에 전달 // ! 프리픽스로 AX Agent에 전달
_launcher?.Show(); ShowLauncherWindow();
_launcher?.SetInputText($"! {prompt}"); _launcher?.SetInputText($"! {prompt}");
} }
@@ -475,7 +580,7 @@ public partial class App : System.Windows.Application
_trayMenu _trayMenu
.AddHeader(versionText) .AddHeader(versionText)
.AddItem("\uE7C5", "AX Commander 호출하기", () => .AddItem("\uE7C5", "AX Commander 호출하기", () =>
Dispatcher.Invoke(() => _launcher?.Show())) Dispatcher.Invoke(ShowLauncherWindow))
.AddItem("\uE8BD", "AX Agent 대화하기", () => .AddItem("\uE8BD", "AX Agent 대화하기", () =>
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem) Dispatcher.Invoke(OpenAiChat), out var aiTrayItem)
.AddItem("\uE8A7", "독 바 표시", () => .AddItem("\uE8A7", "독 바 표시", () =>
@@ -534,7 +639,7 @@ public partial class App : System.Windows.Application
if (settings.Settings.AiEnabled) if (settings.Settings.AiEnabled)
OpenAiChat(); OpenAiChat();
else else
_launcher?.Show(); ShowLauncherWindow();
}); });
} }
else if (e.Button == System.Windows.Forms.MouseButtons.Right) else if (e.Button == System.Windows.Forms.MouseButtons.Right)
@@ -569,14 +674,29 @@ public partial class App : System.Windows.Application
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary> /// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
private Views.ChatWindow? _chatWindow; private Views.ChatWindow? _chatWindow;
/// <summary> public void EnsureIndexWarmupStarted()
/// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출).
/// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다.
/// </summary>
internal void PrewarmChatWindow()
{ {
if (_chatWindow != null || _settings == null) return; if (_indexService == null) return;
_chatWindow = new Views.ChatWindow(_settings); if (Interlocked.Exchange(ref _indexWarmupStarted, 1) == 1) return;
_ = Task.Run(async () =>
{
try
{
await _indexService.BuildAsync().ConfigureAwait(false);
_indexService.StartWatchers();
}
catch (Exception ex)
{
LogService.Warn($"런처 인덱스 초기화 실패: {ex.Message}");
}
});
}
private void ShowLauncherWindow()
{
if (_launcher == null) return;
_launcher.Show();
} }
private void OpenAiChat() private void OpenAiChat()
@@ -604,7 +724,7 @@ public partial class App : System.Windows.Application
_dockBar.OnQuickSearch = query => _dockBar.OnQuickSearch = query =>
{ {
if (_launcher == null) return; if (_launcher == null) return;
_launcher.Show(); ShowLauncherWindow();
_launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시 _launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시
if (!string.IsNullOrEmpty(query)) if (!string.IsNullOrEmpty(query))
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
@@ -619,7 +739,7 @@ public partial class App : System.Windows.Application
_dockBar.OnOpenAgent = () => _dockBar.OnOpenAgent = () =>
{ {
if (_launcher == null) return; if (_launcher == null) return;
_launcher.Show(); ShowLauncherWindow();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
() => _launcher.SetInputText("!")); () => _launcher.SetInputText("!"));
}; };
@@ -847,7 +967,9 @@ public partial class App : System.Windows.Application
_chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기 _chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기
_inputListener?.Dispose(); _inputListener?.Dispose();
_clipboardHistory?.Dispose(); _clipboardHistory?.Dispose();
_fileDialogWatcher?.Dispose();
_indexService?.Dispose(); _indexService?.Dispose();
_schedulerService?.Dispose();
_sessionTracking?.Dispose(); _sessionTracking?.Dispose();
_worktimeReminder?.Dispose(); _worktimeReminder?.Dispose();
_trayIcon?.Dispose(); _trayIcon?.Dispose();
@@ -856,4 +978,18 @@ public partial class App : System.Windows.Application
LogService.Info("=== AX Copilot 종료 ==="); LogService.Info("=== AX Copilot 종료 ===");
base.OnExit(e); base.OnExit(e);
} }
private void UpdateFileDialogWatcherState()
{
if (_fileDialogWatcher == null || _settings == null)
return;
if (_settings.Settings.Launcher.EnableFileDialogIntegration)
{
_fileDialogWatcher.Start();
return;
}
_fileDialogWatcher.Stop();
}
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
@@ -40,10 +40,16 @@
<!-- Release 빌드 시 추가 난독화 설정 --> <!-- Release 빌드 시 추가 난독화 설정 -->
<PropertyGroup Condition="'$(Configuration)'=='Release'"> <PropertyGroup Condition="'$(Configuration)'=='Release'">
<Optimize>true</Optimize>
<!-- 사용하지 않는 멤버 제거 (IL trimming) --> <!-- 사용하지 않는 멤버 제거 (IL trimming) -->
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<!-- PDB 제거 --> <!-- PDB 제거 -->
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory> <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<!-- 배포판 보호 수준 강화 -->
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup> </PropertyGroup>
<!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의 <!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의
@@ -66,6 +72,7 @@
<PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" /> <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.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" /> <PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />

View File

@@ -9,10 +9,16 @@ namespace AxCopilot.Core;
public class FuzzyEngine public class FuzzyEngine
{ {
private readonly IndexService _index; private readonly IndexService _index;
private readonly object _cacheLock = new();
private readonly Dictionary<string, List<FuzzyResult>> _queryCache = new(StringComparer.Ordinal);
private readonly Queue<string> _queryCacheOrder = new();
private const int QueryCacheLimit = 64;
private int _indexGeneration;
public FuzzyEngine(IndexService index) public FuzzyEngine(IndexService index)
{ {
_index = index; _index = index;
_index.IndexRebuilt += (_, _) => InvalidateQueryCache();
} }
/// <summary> /// <summary>
@@ -25,6 +31,13 @@ public class FuzzyEngine
return Enumerable.Empty<FuzzyResult>(); return Enumerable.Empty<FuzzyResult>();
var normalized = query.Trim().ToLowerInvariant(); var normalized = query.Trim().ToLowerInvariant();
var cacheKey = $"{_indexGeneration}:{maxResults}:{normalized}";
lock (_cacheLock)
{
if (_queryCache.TryGetValue(cacheKey, out var cached))
return cached;
}
var entries = _index.Entries; var entries = _index.Entries;
// 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음 // 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음
@@ -36,20 +49,56 @@ public class FuzzyEngine
} }
// 300개 초과 시 PLINQ 병렬 처리 // 300개 초과 시 PLINQ 병렬 처리
List<FuzzyResult> results;
if (entries.Count > 300) if (entries.Count > 300)
{ {
return entries.AsParallel() results = entries.AsParallel()
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean))) .Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
.Where(r => r.Score > 0) .Where(r => r.Score > 0)
.OrderByDescending(r => r.Score) .OrderByDescending(r => r.Score)
.Take(maxResults); .Take(maxResults)
.ToList();
}
else
{
results = entries
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
.Where(r => r.Score > 0)
.OrderByDescending(r => r.Score)
.Take(maxResults)
.ToList();
} }
return entries StoreCachedResults(cacheKey, results);
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean))) return results;
.Where(r => r.Score > 0) }
.OrderByDescending(r => r.Score)
.Take(maxResults); private void InvalidateQueryCache()
{
lock (_cacheLock)
{
_indexGeneration++;
_queryCache.Clear();
_queryCacheOrder.Clear();
}
}
private void StoreCachedResults(string cacheKey, List<FuzzyResult> results)
{
lock (_cacheLock)
{
if (_queryCache.ContainsKey(cacheKey))
return;
_queryCache[cacheKey] = results;
_queryCacheOrder.Enqueue(cacheKey);
while (_queryCacheOrder.Count > QueryCacheLimit)
{
var oldKey = _queryCacheOrder.Dequeue();
_queryCache.Remove(oldKey);
}
}
} }
/// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary> /// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary>

View File

@@ -177,12 +177,17 @@ public class InputListener : IDisposable
if (wParam != WM_KEYDOWN && wParam != WM_SYSKEYDOWN) if (wParam != WM_KEYDOWN && wParam != WM_SYSKEYDOWN)
return CallNextHookEx(_hookHandle, nCode, wParam, lParam); return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
var isHotkeyMainKey = vkCode == _hotkey.VkCode;
var isCaptureMainKey = _captureHotkeyEnabled && vkCode == _captureHotkey.VkCode;
var shouldRunKeyFilter = KeyFilter != null;
var needsSuppressedWindowCheck = isHotkeyMainKey || isCaptureMainKey || shouldRunKeyFilter;
// ─── 시스템 파일 대화상자에서는 핫키·스니펫 전부 비활성 ────────────── // ─── 시스템 파일 대화상자에서는 핫키·스니펫 전부 비활성 ──────────────
if (IsSuppressedForegroundWindow()) if (needsSuppressedWindowCheck && IsSuppressedForegroundWindow())
return CallNextHookEx(_hookHandle, nCode, wParam, lParam); return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
// ─── 핫키 감지 ────────────────────────────────────────────────────── // ─── 핫키 감지 ──────────────────────────────────────────────────────
if (!SuspendHotkey && vkCode == _hotkey.VkCode) if (!SuspendHotkey && isHotkeyMainKey)
{ {
bool ctrlOk = !_hotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; bool ctrlOk = !_hotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
bool altOk = !_hotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; bool altOk = !_hotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
@@ -202,7 +207,7 @@ public class InputListener : IDisposable
} }
// ─── 글로벌 캡처 단축키 감지 ───────────────────────────────────────── // ─── 글로벌 캡처 단축키 감지 ─────────────────────────────────────────
if (!SuspendHotkey && _captureHotkeyEnabled && vkCode == _captureHotkey.VkCode) if (!SuspendHotkey && isCaptureMainKey)
{ {
bool ctrlOk = !_captureHotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; bool ctrlOk = !_captureHotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
bool altOk = !_captureHotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; bool altOk = !_captureHotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
@@ -221,7 +226,7 @@ public class InputListener : IDisposable
} }
// ─── 스니펫 키 필터 ───────────────────────────────────────────────── // ─── 스니펫 키 필터 ─────────────────────────────────────────────────
if (KeyFilter?.Invoke(vkCode) == true) if (shouldRunKeyFilter && KeyFilter?.Invoke(vkCode) == true)
return (IntPtr)1; return (IntPtr)1;
return CallNextHookEx(_hookHandle, nCode, wParam, lParam); return CallNextHookEx(_hookHandle, nCode, wParam, lParam);

View File

@@ -49,20 +49,30 @@ public class SnippetExpander
// 자동 확장 비활성화 시 즉시 통과 // 자동 확장 비활성화 시 즉시 통과
if (!_settings.Settings.Launcher.SnippetAutoExpand) return false; if (!_settings.Settings.Launcher.SnippetAutoExpand) return false;
// Ctrl/Alt 조합은 무시 (단축키와 충돌 방지) // 추적 중이 아닐 때는 ';' 시작 키만 검사해서 훅 경로 부담을 최소화합니다.
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; } if (!_tracking)
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
// ─── 트리거 시작: ';' 입력 ──────────────────────────────────────────
if (vkCode == VK_OEM_1 && (GetAsyncKeyState(VK_SHIFT) & 0x8000) == 0)
{ {
if (vkCode != VK_OEM_1)
return false;
if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0)
return false;
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0)
return false;
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0)
return false;
_tracking = true; _tracking = true;
_buffer.Clear(); _buffer.Clear();
_buffer.Append(';'); _buffer.Append(';');
return false; // ';'는 소비하지 않고 앱으로 전달 return false;
} }
if (!_tracking) return false; // Ctrl/Alt 조합은 무시 (단축키와 충돌 방지)
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
// ─── 영문자/숫자 — 버퍼에 추가 ───────────────────────────────────── // ─── 영문자/숫자 — 버퍼에 추가 ─────────────────────────────────────
if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z

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

View File

@@ -1,4 +1,3 @@
using System.Runtime.InteropServices;
using System.Windows; using System.Windows;
using AxCopilot.SDK; using AxCopilot.SDK;
using AxCopilot.Services; using AxCopilot.Services;
@@ -28,7 +27,6 @@ public class ClipboardHistoryHandler : IActionHandler
_historyService = historyService; _historyService = historyService;
} }
// 카테고리 필터 프리픽스: #url, #코드, #경로
private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase)
{ {
{ "url", "URL" }, { "코드", "코드" }, { "code", "코드" }, { "url", "URL" }, { "코드", "코드" }, { "code", "코드" },
@@ -52,7 +50,6 @@ public class ClipboardHistoryHandler : IActionHandler
var q = query.Trim().ToLowerInvariant(); var q = query.Trim().ToLowerInvariant();
// 카테고리 필터 감지 (예: #url, #핀)
string? catFilter = null; string? catFilter = null;
foreach (var (prefix, cat) in CategoryFilters) foreach (var (prefix, cat) in CategoryFilters)
{ {
@@ -66,17 +63,14 @@ public class ClipboardHistoryHandler : IActionHandler
var filtered = history.AsEnumerable(); var filtered = history.AsEnumerable();
// 카테고리 필터 적용
if (catFilter == "핀") if (catFilter == "핀")
filtered = filtered.Where(e => e.IsPinned); filtered = filtered.Where(e => e.IsPinned);
else if (catFilter != null) else if (catFilter != null)
filtered = filtered.Where(e => e.Category == catFilter); filtered = filtered.Where(e => e.Category == catFilter);
// 텍스트 검색
if (!string.IsNullOrEmpty(q)) if (!string.IsNullOrEmpty(q))
filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q)); filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q));
// 핀 항목을 상단에 배치
var sorted = filtered var sorted = filtered
.OrderByDescending(e => e.IsPinned) .OrderByDescending(e => e.IsPinned)
.ThenByDescending(e => e.CopiedAt); .ThenByDescending(e => e.CopiedAt);
@@ -113,14 +107,13 @@ public class ClipboardHistoryHandler : IActionHandler
try try
{ {
_historyService.SuppressNextCapture(); _historyService.SuppressNextCapture();
_historyService.PromoteEntry(entry); // 사용 시각 갱신 + 목록 맨 위로 _historyService.PromoteEntry(entry);
if (!entry.IsText && entry.Image != null) if (!entry.IsText && entry.Image != null)
{ {
// 원본 이미지가 있으면 원본 해상도로 클립보드 복사
var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath); var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath);
Clipboard.SetImage(originalImg ?? entry.Image); Clipboard.SetImage(originalImg ?? entry.Image);
return; // 이미지는 붙여넣기 시뮬레이션 없이 클립보드만 설정 return;
} }
if (string.IsNullOrEmpty(entry.Text)) return; if (string.IsNullOrEmpty(entry.Text)) return;
@@ -129,25 +122,7 @@ public class ClipboardHistoryHandler : IActionHandler
var prevWindow = WindowTracker.PreviousWindow; var prevWindow = WindowTracker.PreviousWindow;
if (prevWindow == IntPtr.Zero) return; if (prevWindow == IntPtr.Zero) return;
// ── 이전 창 포커스 복원 후 Ctrl+V ──────────────────────────────── await ForegroundPasteHelper.PasteClipboardAsync(prevWindow, ct, initialDelayMs: 260);
// 런처 창이 완전히 숨겨지고 이전 창이 포커스를 회복할 시간 확보
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();
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch (Exception ex) catch (Exception ex)
@@ -155,62 +130,4 @@ public class ClipboardHistoryHandler : IActionHandler
LogService.Warn($"클립보드 히스토리 붙여넣기 실패: {ex.Message}"); 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);
}
}

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