런처 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
This commit is contained in:
2026-04-05 11:51:43 +09:00
parent 0336904258
commit f7cafe0cfc
17 changed files with 2518 additions and 24 deletions

View File

@@ -0,0 +1,322 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using UglyToad.PdfPig;
namespace AxCopilot.ViewModels;
public partial class LauncherViewModel
{
private string _previewText = "";
private bool _hasPreview;
private CancellationTokenSource? _previewCts;
public ObservableCollection<QuickActionChip> QuickActionItems { get; } = new();
public bool ShowQuickActions => string.IsNullOrEmpty(_inputText) && QuickActionItems.Count > 0;
public string PreviewText
{
get => _previewText;
private set
{
_previewText = value;
OnPropertyChanged();
}
}
public bool HasPreview
{
get => _hasPreview;
private set
{
_hasPreview = value;
OnPropertyChanged();
}
}
public string? NavigateHistoryPrev()
{
var history = SearchHistoryService.GetAll();
if (history.Count == 0)
return null;
_historyIndex = Math.Min(_historyIndex + 1, history.Count - 1);
return history[_historyIndex];
}
public string? NavigateHistoryNext()
{
if (_historyIndex <= 0)
{
_historyIndex = -1;
return "";
}
_historyIndex--;
var history = SearchHistoryService.GetAll();
return _historyIndex >= 0 && _historyIndex < history.Count
? history[_historyIndex]
: "";
}
public void SetInputFromHistory(string text)
{
_isHistoryNavigation = true;
try
{
InputText = text;
}
finally
{
_isHistoryNavigation = false;
}
}
public void LoadQuickActions()
{
QuickActionItems.Clear();
var topItems = UsageRankingService.GetTopItems(16);
var added = 0;
foreach (var (path, _) in topItems)
{
if (added >= 8)
break;
var expanded = Environment.ExpandEnvironmentVariables(path);
var isFolder = Directory.Exists(expanded);
var isFile = !isFolder && File.Exists(expanded);
if (!isFolder && !isFile)
continue;
var ext = Path.GetExtension(expanded).ToLowerInvariant();
var title = Path.GetFileNameWithoutExtension(expanded);
if (string.IsNullOrEmpty(title))
title = Path.GetFileName(expanded);
var symbol = isFolder ? Symbols.Folder
: ext == ".exe" ? Symbols.App
: ext is ".lnk" or ".url" ? Symbols.App
: Symbols.File;
var color = isFolder ? Color.FromRgb(0x10, 0x7C, 0x10)
: ext == ".exe" ? Color.FromRgb(0x4B, 0x5E, 0xFC)
: ext is ".lnk" or ".url" ? Color.FromRgb(0x4B, 0x5E, 0xFC)
: Color.FromRgb(0x5B, 0x4E, 0x7E);
var bg = new SolidColorBrush(Color.FromArgb(0x26, color.R, color.G, color.B));
QuickActionItems.Add(new QuickActionChip(title, symbol, path, bg));
added++;
}
OnPropertyChanged(nameof(ShowQuickActions));
}
private async Task UpdatePreviewAsync(LauncherItem? item)
{
_previewCts?.Cancel();
_previewCts = new CancellationTokenSource();
var ct = _previewCts.Token;
if (item == null)
{
HasPreview = false;
PreviewText = string.Empty;
return;
}
try
{
await Task.Delay(80, ct);
if (item.Data is ClipboardEntry clipEntry && clipEntry.IsText)
{
var text = clipEntry.Text ?? string.Empty;
PreviewText = text.Length > 400 ? text[..400] + "…" : text;
HasPreview = !string.IsNullOrEmpty(PreviewText);
return;
}
if (item.Data is IndexEntry indexEntry)
{
var path = Environment.ExpandEnvironmentVariables(indexEntry.Path);
var ext = Path.GetExtension(path).ToLowerInvariant();
if (IsPreviewableTextFile(ext) && File.Exists(path))
{
var lines = await ReadFirstLinesAsync(path, 6, ct);
if (ct.IsCancellationRequested)
return;
PreviewText = string.Join("\n", lines);
HasPreview = !string.IsNullOrWhiteSpace(PreviewText);
return;
}
if (IsImageFile(ext) && File.Exists(path))
{
var meta = await Task.Run(() => GetImageMeta(path), ct);
if (ct.IsCancellationRequested)
return;
if (!string.IsNullOrEmpty(meta))
{
PreviewText = meta;
HasPreview = true;
return;
}
}
if (ext == ".pdf" && File.Exists(path))
{
var meta = await Task.Run(() => GetPdfMeta(path), ct);
if (ct.IsCancellationRequested)
return;
if (!string.IsNullOrEmpty(meta))
{
PreviewText = meta;
HasPreview = true;
return;
}
}
if (IsMediaFile(ext) && File.Exists(path))
{
var meta = GetFileSizeMeta(path, ext);
if (!string.IsNullOrEmpty(meta))
{
PreviewText = meta;
HasPreview = true;
return;
}
}
}
PreviewText = string.Empty;
HasPreview = false;
}
catch (OperationCanceledException)
{
}
catch
{
PreviewText = string.Empty;
HasPreview = false;
}
}
private static bool IsPreviewableTextFile(string ext) => ext is
".txt" or ".md" or ".log" or ".csv" or ".json" or ".xml"
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 FileInfo(path);
var size = FormatFileSize(fi.Length);
var ext = fi.Extension.TrimStart('.').ToUpperInvariant();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var decoder = BitmapDecoder.Create(
stream,
BitmapCreateOptions.DelayCreation,
BitmapCacheOption.None);
var frame = decoder.Frames[0];
return $"이미지 {ext} · {frame.PixelWidth}x{frame.PixelHeight} · {size}\n수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
}
catch
{
return null;
}
}
private static string? GetPdfMeta(string path)
{
try
{
var fi = new FileInfo(path);
var size = FormatFileSize(fi.Length);
using var doc = PdfDocument.Open(path);
var pages = doc.NumberOfPages;
var firstPageText = string.Empty;
if (pages > 0)
{
firstPageText = doc.GetPage(1).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 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<IEnumerable<string>> ReadFirstLinesAsync(string path, int maxLines, CancellationToken ct)
{
var lines = new List<string>();
using var reader = new StreamReader(path, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
for (var i = 0; i < maxLines && !reader.EndOfStream; i++)
{
ct.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(ct);
if (line != null)
lines.Add(line);
}
return lines;
}
}

View File

@@ -0,0 +1,114 @@
using AxCopilot.Handlers;
using AxCopilot.Services;
using System.IO;
namespace AxCopilot.ViewModels;
public partial class LauncherViewModel
{
public string Widget_PerfText
{
get
{
var perf = PerformanceMonitorService.Instance;
return $"CPU {perf.CpuPercent:F0}% RAM {perf.RamPercent:F0}% {perf.DiskCText}";
}
}
public string Widget_PomoText
{
get
{
var pomo = PomodoroService.Instance;
var remain = pomo.Remaining;
var clock = $"{(int)remain.TotalMinutes:D2}:{remain.Seconds:D2}";
return pomo.Mode switch
{
PomodoroMode.Focus => $"집중 {clock}",
PomodoroMode.Break => $"휴식 {clock}",
_ => $"대기 {clock}",
};
}
}
public bool Widget_PomoRunning => PomodoroService.Instance.IsRunning;
private int _widgetNoteCount;
public string Widget_NoteText => _widgetNoteCount > 0 ? $"메모 {_widgetNoteCount}건" : "메모 없음";
public bool Widget_OllamaOnline => ServerStatusService.Instance.OllamaOnline;
public bool Widget_LlmOnline => ServerStatusService.Instance.LlmOnline;
public bool Widget_McpOnline => ServerStatusService.Instance.McpOnline;
public string Widget_McpName => ServerStatusService.Instance.McpName;
private string _widgetWeatherText = "--";
public string Widget_WeatherText
{
get => _widgetWeatherText;
internal set { _widgetWeatherText = value; OnPropertyChanged(); }
}
public string Widget_CalText =>
DateTime.Now.ToString("M/d (ddd)", System.Globalization.CultureInfo.GetCultureInfo("ko-KR"));
private string _widgetBatteryText = "--";
private string _widgetBatteryIcon = "\uE83F";
private bool _widgetBatteryVisible;
public string Widget_BatteryText
{
get => _widgetBatteryText;
internal set { _widgetBatteryText = value; OnPropertyChanged(); }
}
public string Widget_BatteryIcon
{
get => _widgetBatteryIcon;
internal set { _widgetBatteryIcon = value; OnPropertyChanged(); }
}
public bool Widget_BatteryVisible
{
get => _widgetBatteryVisible;
internal set { _widgetBatteryVisible = value; OnPropertyChanged(); }
}
private int _widgetRefreshTick;
public void UpdateWidgets()
{
_widgetRefreshTick++;
if (_widgetRefreshTick % 5 == 0)
_widgetNoteCount = GetNoteCount();
OnPropertyChanged(nameof(Widget_PerfText));
OnPropertyChanged(nameof(Widget_PomoText));
OnPropertyChanged(nameof(Widget_PomoRunning));
OnPropertyChanged(nameof(Widget_NoteText));
OnPropertyChanged(nameof(Widget_OllamaOnline));
OnPropertyChanged(nameof(Widget_LlmOnline));
OnPropertyChanged(nameof(Widget_McpOnline));
OnPropertyChanged(nameof(Widget_McpName));
OnPropertyChanged(nameof(Widget_CalText));
}
private static int GetNoteCount()
{
try
{
var notesFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot",
"notes.txt");
if (!File.Exists(notesFile))
return 0;
return File.ReadLines(notesFile, System.Text.Encoding.UTF8)
.Count(line => !string.IsNullOrWhiteSpace(line));
}
catch
{
return 0;
}
}
}

View File

@@ -13,7 +13,7 @@ using AxCopilot.Themes;
namespace AxCopilot.ViewModels;
public class LauncherViewModel : INotifyPropertyChanged
public partial class LauncherViewModel : INotifyPropertyChanged
{
private readonly CommandResolver _resolver;
private readonly SettingsService _settings;
@@ -21,6 +21,8 @@ public class LauncherViewModel : INotifyPropertyChanged
private string _inputText = "";
private LauncherItem? _selectedItem;
private bool _isLoading;
private bool _isHistoryNavigation;
private int _historyIndex = -1;
private CancellationTokenSource? _searchCts;
private System.Threading.Timer? _debounceTimer;
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
@@ -48,6 +50,8 @@ public class LauncherViewModel : INotifyPropertyChanged
{
if (_inputText == value) return;
_inputText = value;
if (!_isHistoryNavigation)
_historyIndex = -1;
OnPropertyChanged();
OnPropertyChanged(nameof(HasActivePrefix));
OnPropertyChanged(nameof(ActivePrefixLabel));
@@ -56,6 +60,7 @@ public class LauncherViewModel : INotifyPropertyChanged
OnPropertyChanged(nameof(IsClipboardMode));
OnPropertyChanged(nameof(ShowMergeHint));
OnPropertyChanged(nameof(MergeHintText));
OnPropertyChanged(nameof(ShowQuickActions));
// 연속 입력 시 이전 검색 즉시 취소 + 50ms 디바운스 후 실제 검색 시작
_searchCts?.Cancel();
@@ -77,7 +82,12 @@ public class LauncherViewModel : INotifyPropertyChanged
public LauncherItem? SelectedItem
{
get => _selectedItem;
set { _selectedItem = value; OnPropertyChanged(); }
set
{
_selectedItem = value;
OnPropertyChanged();
_ = UpdatePreviewAsync(value);
}
}
public bool IsLoading
@@ -245,7 +255,9 @@ public class LauncherViewModel : INotifyPropertyChanged
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
Results.Clear();
_lastSearchQuery = "";
_historyIndex = -1;
ClearMerge();
LoadQuickActions();
}
// ─── 검색 ────────────────────────────────────────────────────────────────
@@ -333,6 +345,9 @@ public class LauncherViewModel : INotifyPropertyChanged
if (SelectedItem == null) return;
if (InputText.Trim().Length >= 2)
SearchHistoryService.Add(InputText.Trim());
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
CloseRequested?.Invoke(this, EventArgs.Empty);