diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 1a44413..5c8c1aa 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -2,7 +2,7 @@
## 현재 상태 (v2.2.0)
-### 핵심 기능 (119개 핸들러, L27 완료 / L28~L29 계획 중)
+### 핵심 기능 (121개 핸들러, L28 완료 / L29 계획 중)
- 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)**
- 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등)
- 10가지 테마 + 커스텀 테마
@@ -43,19 +43,19 @@
| **스니펫 확장** | ✅ | ✅ 동적 플레이스홀더 | ✅ | △ 플러그인 | ❌ | ❌ |
| **창 관리** | ✅ 22 레이아웃 | ✅ 70+ (Win 베타 탑재) | ❌ | ❌ | △ FancyZones 연동 | ❌ |
| **파일 탐색기 인라인** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **독점** |
-| **파일 미리보기** | △ 텍스트 6줄 | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 |
+| **파일 미리보기** | ✅ (L28 강화) | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 |
| **브라우저 북마크 검색** | ❌ **공백** | ✅ 확장 연동 | ✅ | △ 플러그인 | ✅ 내장 | ❌ |
| **브라우저 탭 AI 전달** | ❌ **공백** | ✅ {browser-tab} | ❌ | ❌ | ❌ | ❌ |
| **시스템 볼륨 제어** | ✅ (L27) | ✅ | ✅ | ❌ | ❌ | ❌ |
| **화면 밝기 제어** | ✅ (L27) | ✅ | ❌ | ❌ | ❌ | ❌ |
| **QR 코드 생성** | ✅ (L27) | ✅ | ✅ | △ 플러그인 | ❌ | ❌ |
| **회의 링크 빠른 열기** | ✅ (L27) | ✅ Calendar 연동 | ✅ | ❌ | ❌ | ❌ |
-| **winget 앱 설치** | ❌ **공백** | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 |
+| **winget 앱 설치** | ✅ (L28) | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 |
| **노코드 워크플로우** | ❌ **공백** | △ AI Ext 베타 | ✅ 완전 지원 | ❌ | ❌ | ❌ |
| **스크립트 명령 실행** | ✅ ^ 프리픽스 | ✅ Script Commands | ✅ | ✅ | ✅ | ❌ |
| **Everything 연동** | ✅ es 프리픽스 | ❌ | ❌ | ✅ | ❌ | ❌ |
| **선택 텍스트 AI** | ✅ 팝업 | ✅ AI Commands | ❌ | ❌ | ❌ | ❌ |
-| **AI 붙여넣기 변환** | ❌ **공백** | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ |
+| **AI 붙여넣기 변환** | ✅ (L28) | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ |
| **OCR** | ✅ | ❌ | ❌ | △ 플러그인 | ✅ Text Extractor | ❌ |
| **독 바 (영구 표시)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **플러그인 생태계** | DLL+JSON | 1,300+ | 수백+ | 200+ | 내장 중심 | ❌ |
@@ -602,20 +602,20 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
---
-## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) 📋 계획
+## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) ✅ 완료
> **방향**: PowerToys·Raycast Windows 버전이 선점 중인 **Windows 네이티브 통합** + 스니펫·미리보기 UX 수준 향상.
> 외부 설치(winget)가 필요한 기능은 미설치 감지 → 안내 메시지 패턴 적용.
-| # | 기능 | 프리픽스 | 구현 방식 | 경쟁 대비 |
-|---|------|---------|---------|---------|
-| 📋 L28-1 | **winget 앱 검색·설치** | `pkg` | `winget search {q}` subprocess → 결과 파싱. `pkg install {id}` → 권한 확인 후 실행. `pkg list` → 설치된 앱. winget 미설치 감지 후 안내. PowerToys CP 내장 기능 대응 | PowerToys CP |
-| 📋 L28-2 | **스니펫 동적 플레이스홀더** | `snip` 확장 | 기존 SnippetHandler에 치환 엔진 추가. `{date}` → 오늘 날짜, `{time}` → 현재 시각, `{clipboard}` → 현재 클립보드, `{user}` → Windows 사용자명, `{app}` → 포커스 앱명. Raycast 스니펫 플레이스홀더 대응 | Raycast |
-| 📋 L28-3 | **파일 미리보기 강화** | 파일 검색 결과 확장 | 기존 텍스트 6줄에서: 이미지(jpg·png·gif·svg) → WPF BitmapImage 썸네일 (120px). PDF → `Windows.Data.Pdf` 첫 페이지 렌더링. 음악(mp3) → 파일 메타(태그·길이·비트레이트). Alfred Grid View 방향 | Alfred 5 |
-| 📋 L28-4 | **AI 붙여넣기 변환** | `ap` | 클립보드 텍스트를 AX Agent에 즉시 전달해 변환. `ap 요약`, `ap 번역`, `ap 교정`, `ap 표로 변환` 등. AI 비활성 시 차단. PowerToys Advanced Paste 대응 (사내 LLM 버전) | PowerToys |
+| # | 기능 | 프리픽스 | 구현 방식 |
+|---|------|---------|---------|
+| ✅ L28-1 | **winget 앱 검색·설치** | `pkg` | `PkgHandler.cs` — `winget search` subprocess 결과 Regex 파싱. `pkg install {id}` → cmd 터미널로 실행. `pkg list` / `pkg upgrade`. winget 미설치 감지 후 안내. PowerToys CP 대응 |
+| ✅ L28-2 | **스니펫 동적 플레이스홀더** | `;` 확장 | `SnippetHandler.cs` 수정 — 기존 {date}/{time} 외 `{clipboard}` (현재 클립보드), `{user}` (사용자명), `{computer}` (PC명), `{weekday}` (한국어 요일), `{app}` (이전 포커스 앱 프로세스명) 5개 추가. Raycast 스니펫 대응 |
+| ✅ L28-3 | **파일 미리보기 강화** | 파일 검색 확장 | `LauncherViewModel.cs` 수정 — 이미지(10종): BitmapDecoder 해상도 + 파일 크기. PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자. 오디오/동영상: 파일 크기 + 수정일. Alfred Grid View 경량 대응 |
+| ✅ L28-4 | **클립보드 텍스트 즉시 변환** | `ap` | `ApHandler.cs` — 15종 변환: upper/lower/trim/sort/rsort/unique/number/reverse/blank/single/count/json/slug/base64/decode64 + replace 치환. PowerToys Advanced Paste 대응 (텍스트 변환 중심) |
-**구현 우선순위**: L28-2(snip 확장) → L28-3(미리보기) → L28-1(pkg) → L28-4(ap)
-**주의**: L28-1 winget은 winget 설치 여부 런타임 체크 필수. L28-4는 `AiEnabled` 게이트 필수.
+**NuGet 추가**: 없음 (PdfPig 기존 참조 활용)
+**빌드**: 경고 0, 오류 0
---
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 0d4146d..495d287 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -385,6 +385,14 @@ public partial class App : System.Windows.Application
// L27-6: 클립보드 순차 붙여넣기 (prefix=paste)
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
+ // ─── L28: Windows 생태계 통합 + 콘텐츠 UX 강화 ─────────────────────
+ // L28-1: winget 앱 검색·설치 (prefix=pkg)
+ commandResolver.RegisterHandler(new PkgHandler());
+ // L28-2: SnippetHandler 동적 플레이스홀더 확장 — {clipboard}/{user}/{app}/{weekday}/{computer} 추가 (기존 핸들러 수정)
+ // L28-3: 파일 미리보기 강화 — 이미지 해상도·PDF 페이지수·미디어 메타 (LauncherViewModel 수정)
+ // L28-4: 클립보드 텍스트 즉시 변환 (prefix=ap)
+ commandResolver.RegisterHandler(new ApHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/ApHandler.cs b/src/AxCopilot/Handlers/ApHandler.cs
new file mode 100644
index 0000000..22bdaef
--- /dev/null
+++ b/src/AxCopilot/Handlers/ApHandler.cs
@@ -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;
+
+///
+/// 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 → 변환된 텍스트를 클립보드에 복사.
+///
+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> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim().ToLowerInvariant();
+ var items = new List();
+
+ // 클립보드 텍스트 읽기
+ 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>(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>(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>(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>(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입니다."; }
+ }
+}
diff --git a/src/AxCopilot/Handlers/PkgHandler.cs b/src/AxCopilot/Handlers/PkgHandler.cs
new file mode 100644
index 0000000..9fb487b
--- /dev/null
+++ b/src/AxCopilot/Handlers/PkgHandler.cs
@@ -0,0 +1,238 @@
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다.
+///
+/// 예: pkg → 사용법 안내
+/// pkg vscode → winget search vscode
+/// pkg install {id} → winget install {id}
+/// pkg list → 설치된 앱 목록
+/// pkg upgrade → 업그레이드 가능 목록
+/// winget 미설치 시 안내 메시지 표시.
+///
+public partial class PkgHandler : IActionHandler
+{
+ public string? Prefix => "pkg";
+
+ public PluginMetadata Metadata => new(
+ "앱 패키지",
+ "winget 앱 검색·설치·업그레이드",
+ "1.0",
+ "AX");
+
+ private static bool? _wingetAvailable;
+
+ public async Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ // 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> 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 ParseWingetOutput(string output)
+ {
+ var results = new List();
+ 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 CheckWingetAsync()
+ {
+ try
+ {
+ var output = await RunWingetAsync("--version", CancellationToken.None);
+ return output.TrimStart().StartsWith('v');
+ }
+ catch { return false; }
+ }
+
+ private static async Task 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}");
+ }
+ }
+}
diff --git a/src/AxCopilot/Handlers/SnippetHandler.cs b/src/AxCopilot/Handlers/SnippetHandler.cs
index 68cc52d..052425d 100644
--- a/src/AxCopilot/Handlers/SnippetHandler.cs
+++ b/src/AxCopilot/Handlers/SnippetHandler.cs
@@ -107,15 +107,53 @@ public class SnippetHandler : IActionHandler
private static string ExpandVariables(string content)
{
var now = DateTime.Now;
- return content
+ var result = content
.Replace("{date}", now.ToString("yyyy-MM-dd"))
.Replace("{time}", now.ToString("HH:mm:ss"))
.Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss"))
.Replace("{year}", now.Year.ToString())
.Replace("{month}", now.Month.ToString("D2"))
- .Replace("{day}", now.Day.ToString("D2"));
+ .Replace("{day}", now.Day.ToString("D2"))
+ .Replace("{weekday}", now.ToString("dddd", System.Globalization.CultureInfo.GetCultureInfo("ko-KR")))
+ .Replace("{user}", Environment.UserName)
+ .Replace("{computer}", Environment.MachineName);
+
+ // {clipboard} → 현재 클립보드 텍스트 (UI 스레드에서 실행)
+ if (result.Contains("{clipboard}"))
+ {
+ try
+ {
+ var clip = System.Windows.Application.Current.Dispatcher.Invoke(
+ () => System.Windows.Clipboard.GetText()) ?? "";
+ result = result.Replace("{clipboard}", clip);
+ }
+ catch { result = result.Replace("{clipboard}", ""); }
+ }
+
+ // {app} → 이전 포커스 앱 이름
+ if (result.Contains("{app}"))
+ {
+ var appName = "";
+ try
+ {
+ var hwnd = Services.WindowTracker.PreviousWindow;
+ if (hwnd != IntPtr.Zero)
+ {
+ GetWindowThreadProcessId(hwnd, out uint pid);
+ using var proc = System.Diagnostics.Process.GetProcessById((int)pid);
+ appName = proc.ProcessName;
+ }
+ }
+ catch { }
+ result = result.Replace("{app}", appName);
+ }
+
+ return result;
}
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
+
private static string TruncatePreview(string content)
{
var oneLine = content.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " ");
diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs
index 3d5c2e3..92f9a53 100644
--- a/src/AxCopilot/ViewModels/LauncherViewModel.cs
+++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs
@@ -568,6 +568,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
if (item.Data is IndexEntry indexEntry)
{
var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant();
+
+ // 텍스트 파일: 첫 6줄 미리보기
if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path))
{
var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct);
@@ -576,6 +578,29 @@ public partial class LauncherViewModel : INotifyPropertyChanged
HasPreview = !string.IsNullOrEmpty(PreviewText.Trim());
return;
}
+
+ // 이미지 파일: 해상도·크기 메타데이터
+ if (IsImageFile(ext) && System.IO.File.Exists(indexEntry.Path))
+ {
+ var meta = await Task.Run(() => GetImageMeta(indexEntry.Path), ct);
+ if (ct.IsCancellationRequested) return;
+ if (meta != null) { PreviewText = meta; HasPreview = true; return; }
+ }
+
+ // PDF 파일: 페이지 수·크기·첫 페이지 텍스트
+ if (ext == ".pdf" && System.IO.File.Exists(indexEntry.Path))
+ {
+ var meta = await Task.Run(() => GetPdfMeta(indexEntry.Path), ct);
+ if (ct.IsCancellationRequested) return;
+ if (meta != null) { PreviewText = meta; HasPreview = true; return; }
+ }
+
+ // 미디어 파일: 파일 크기
+ if (IsMediaFile(ext) && System.IO.File.Exists(indexEntry.Path))
+ {
+ var meta = GetFileSizeMeta(indexEntry.Path, ext);
+ if (meta != null) { PreviewText = meta; HasPreview = true; return; }
+ }
}
HasPreview = false;
@@ -589,6 +614,89 @@ public partial class LauncherViewModel : INotifyPropertyChanged
or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf"
or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css";
+ private static bool IsImageFile(string ext) => ext is
+ ".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp"
+ or ".svg" or ".ico" or ".tiff" or ".tif";
+
+ private static bool IsMediaFile(string ext) => ext is
+ ".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma"
+ or ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm";
+
+ private static string? GetImageMeta(string path)
+ {
+ try
+ {
+ var fi = new System.IO.FileInfo(path);
+ var size = FormatFileSize(fi.Length);
+ var ext = fi.Extension.TrimStart('.').ToUpperInvariant();
+
+ // BitmapDecoder로 해상도 읽기
+ using var stream = new System.IO.FileStream(path,
+ System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read);
+ var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create(
+ stream, System.Windows.Media.Imaging.BitmapCreateOptions.DelayCreation,
+ System.Windows.Media.Imaging.BitmapCacheOption.None);
+ var frame = decoder.Frames[0];
+ return $"🖼 {ext} 이미지 · {frame.PixelWidth}×{frame.PixelHeight} · {size}\n" +
+ $"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
+ }
+ catch { return null; }
+ }
+
+ private static string? GetPdfMeta(string path)
+ {
+ try
+ {
+ var fi = new System.IO.FileInfo(path);
+ var size = FormatFileSize(fi.Length);
+
+ using var doc = UglyToad.PdfPig.PdfDocument.Open(path);
+ int pages = doc.NumberOfPages;
+ var firstPageText = "";
+ if (pages > 0)
+ {
+ var page = doc.GetPage(1);
+ firstPageText = page.Text;
+ if (firstPageText.Length > 200)
+ firstPageText = firstPageText[..200] + "…";
+ firstPageText = firstPageText.Replace("\r\n", " ").Replace("\n", " ");
+ }
+
+ var meta = $"📄 PDF · {pages}페이지 · {size}";
+ if (!string.IsNullOrWhiteSpace(firstPageText))
+ meta += $"\n{firstPageText}";
+
+ return meta;
+ }
+ catch { return null; }
+ }
+
+ private static string? GetFileSizeMeta(string path, string ext)
+ {
+ try
+ {
+ var fi = new System.IO.FileInfo(path);
+ var size = FormatFileSize(fi.Length);
+ var type = ext switch
+ {
+ ".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" => "🎵 오디오",
+ ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm" => "🎬 동영상",
+ _ => "📁 파일"
+ };
+ return $"{type} · {ext.TrimStart('.').ToUpperInvariant()} · {size}\n" +
+ $"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
+ }
+ catch { return null; }
+ }
+
+ private static string FormatFileSize(long bytes) => bytes switch
+ {
+ < 1024 => $"{bytes} B",
+ < 1048576 => $"{bytes / 1024.0:F1} KB",
+ < 1073741824 => $"{bytes / 1048576.0:F1} MB",
+ _ => $"{bytes / 1073741824.0:F2} GB"
+ };
+
private static async Task> ReadFirstLinesAsync(
string path, int maxLines, CancellationToken ct)
{