diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 51cc911..903e090 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -170,6 +170,8 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new TagHandler()); // Phase L3-8: 알림 센터 commandResolver.RegisterHandler(new NotifHandler()); + // Phase L3-9: 뽀모도로 타이머 + commandResolver.RegisterHandler(new PomoHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/NoteHandler.cs b/src/AxCopilot/Handlers/NoteHandler.cs index d29b867..d4bfa60 100644 --- a/src/AxCopilot/Handlers/NoteHandler.cs +++ b/src/AxCopilot/Handlers/NoteHandler.cs @@ -174,6 +174,9 @@ public class NoteHandler : IActionHandler } } + /// 저장된 메모 건수를 반환합니다 (위젯 표시용). + public static int GetNoteCount() => ReadNotes().Count; + /// /// 특정 메모 1건 삭제. content가 일치하는 가장 최근 항목을 제거합니다. /// diff --git a/src/AxCopilot/Handlers/PomoHandler.cs b/src/AxCopilot/Handlers/PomoHandler.cs new file mode 100644 index 0000000..06790cf --- /dev/null +++ b/src/AxCopilot/Handlers/PomoHandler.cs @@ -0,0 +1,130 @@ +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// Phase L3-9: 뽀모도로 타이머 핸들러. "pomo" 프리픽스로 사용합니다. +/// +/// 사용법: +/// pomo → 현재 타이머 상태 표시 +/// pomo start → 집중 타이머 시작 (25분) +/// pomo break → 휴식 타이머 시작 (5분) +/// pomo stop → 타이머 중지 +/// pomo reset → 타이머 초기화 +/// +/// Enter → 해당 명령 실행. +/// +public class PomoHandler : IActionHandler +{ + public string? Prefix => "pomo"; + + public PluginMetadata Metadata => new( + "Pomodoro", + "뽀모도로 타이머 — pomo", + "1.0", + "AX", + "집중/휴식 타이머를 관리합니다."); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var svc = PomodoroService.Instance; + var items = new List(); + + // ─── 명령 분기 ──────────────────────────────────────────────────────── + + if (q is "start" or "focus") + { + items.Add(new LauncherItem("집중 타이머 시작", + $"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행", + null, "__START__", Symbol: Symbols.Timer)); + return Task.FromResult>(items); + } + + if (q is "break" or "rest") + { + items.Add(new LauncherItem("휴식 타이머 시작", + $"{svc.BreakMinutes}분 휴식 모드 시작 · Enter로 실행", + null, "__BREAK__", Symbol: Symbols.Timer)); + return Task.FromResult>(items); + } + + if (q is "stop" or "pause") + { + items.Add(new LauncherItem("타이머 중지", + "현재 타이머를 중지합니다 · Enter로 실행", + null, "__STOP__", Symbol: Symbols.MediaPlay)); + return Task.FromResult>(items); + } + + if (q == "reset") + { + items.Add(new LauncherItem("타이머 초기화", + "타이머를 처음 상태로 초기화합니다 · Enter로 실행", + null, "__RESET__", Symbol: Symbols.Restart)); + return Task.FromResult>(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>(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; + } +} diff --git a/src/AxCopilot/Services/PerformanceMonitorService.cs b/src/AxCopilot/Services/PerformanceMonitorService.cs new file mode 100644 index 0000000..0b4514e --- /dev/null +++ b/src/AxCopilot/Services/PerformanceMonitorService.cs @@ -0,0 +1,138 @@ +using System.IO; +using System.Runtime.InteropServices; + +namespace AxCopilot.Services; + +/// +/// Phase L3-9: 시스템 성능 모니터 서비스. +/// CPU(GetSystemTimes P/Invoke), RAM(GlobalMemoryStatusEx), Disk C(DriveInfo) 수집. +/// 싱글턴 — 런처 표시 시 StartPolling(), 숨김 시 StopPolling(). +/// +internal sealed class PerformanceMonitorService +{ + public static readonly PerformanceMonitorService Instance = new(); + + // ─── P/Invoke ──────────────────────────────────────────────────────────── + + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME { public uint Low; public uint High; } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct MEMORYSTATUSEX + { + public uint dwLength; + public uint dwMemoryLoad; // % in use + public ulong ullTotalPhys; + public ulong ullAvailPhys; + public ulong ullTotalPageFile; + public ulong ullAvailPageFile; + public ulong ullTotalVirtual; + public ulong ullAvailVirtual; + public ulong ullAvailExtendedVirtual; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetSystemTimes(out FILETIME idle, out FILETIME kernel, out FILETIME user); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); + + // ─── 공개 프로퍼티 ──────────────────────────────────────────────────────── + + public double CpuPercent { get; private set; } + public double RamPercent { get; private set; } + public string RamText { get; private set; } = ""; + public double DiskCPercent { get; private set; } + public string DiskCText { get; private set; } = ""; + + // ─── 내부 상태 ─────────────────────────────────────────────────────────── + + private System.Threading.Timer? _timer; + private FILETIME _prevIdle, _prevKernel, _prevUser; + private bool _hasPrev; + + private PerformanceMonitorService() { } + + public void StartPolling() + { + if (_timer != null) return; + // 즉시 첫 샘플 + 이후 2초마다 갱신 + _timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000); + } + + public void StopPolling() + { + _timer?.Dispose(); + _timer = null; + _hasPrev = false; + } + + // ─── 샘플링 ────────────────────────────────────────────────────────────── + + private void Sample() + { + SampleCpu(); + SampleRam(); + SampleDisk(); + } + + private void SampleCpu() + { + try + { + if (!GetSystemTimes(out var idle, out var kernel, out var user)) return; + + if (_hasPrev) + { + var idleDelta = ToUlong(idle) - ToUlong(_prevIdle); + var kernelDelta = ToUlong(kernel) - ToUlong(_prevKernel); + var userDelta = ToUlong(user) - ToUlong(_prevUser); + + var total = kernelDelta + userDelta; + var busy = total - idleDelta; + CpuPercent = total > 0 ? Math.Max(0, Math.Min(100, 100.0 * busy / total)) : 0; + } + + _prevIdle = idle; + _prevKernel = kernel; + _prevUser = user; + _hasPrev = true; + } + catch { /* 오류 무시 */ } + } + + private void SampleRam() + { + try + { + var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf() }; + if (!GlobalMemoryStatusEx(ref mem)) return; + + RamPercent = mem.dwMemoryLoad; + var usedGB = (mem.ullTotalPhys - mem.ullAvailPhys) / (1024.0 * 1024 * 1024); + var totalGB = mem.ullTotalPhys / (1024.0 * 1024 * 1024); + RamText = $"{usedGB:F1}/{totalGB:F0}GB"; + } + catch { /* 오류 무시 */ } + } + + private void SampleDisk() + { + try + { + var drive = DriveInfo.GetDrives() + .FirstOrDefault(d => d.IsReady && + d.Name.StartsWith("C", StringComparison.OrdinalIgnoreCase)); + if (drive == null) return; + + var usedBytes = drive.TotalSize - drive.AvailableFreeSpace; + DiskCPercent = 100.0 * usedBytes / drive.TotalSize; + var usedGB = usedBytes / (1024.0 * 1024 * 1024); + var totalGB = drive.TotalSize / (1024.0 * 1024 * 1024); + DiskCText = $"C:{usedGB:F0}/{totalGB:F0}GB"; + } + catch { /* 오류 무시 */ } + } + + private static ulong ToUlong(FILETIME ft) => ((ulong)ft.High << 32) | ft.Low; +} diff --git a/src/AxCopilot/Services/PomodoroService.cs b/src/AxCopilot/Services/PomodoroService.cs new file mode 100644 index 0000000..aeafffe --- /dev/null +++ b/src/AxCopilot/Services/PomodoroService.cs @@ -0,0 +1,179 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AxCopilot.Services; + +public enum PomodoroMode { Idle, Focus, Break } + +/// +/// Phase L3-9: 뽀모도로 타이머 서비스. +/// 집중(기본 25분) / 휴식(기본 5분) 모드를 관리하며 AppData에 상태를 저장합니다. +/// +internal sealed class PomodoroService +{ + public static readonly PomodoroService Instance = new(); + + private static readonly string StateFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "pomodoro.json"); + + // ─── 설정 ──────────────────────────────────────────────────────────────── + + public int FocusMinutes { get; set; } = 25; + public int BreakMinutes { get; set; } = 5; + + // ─── 상태 ──────────────────────────────────────────────────────────────── + + public PomodoroMode Mode { get; private set; } = PomodoroMode.Idle; + public bool IsRunning { get; private set; } + public TimeSpan Remaining { get; private set; } + + public event EventHandler? StateChanged; + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private System.Threading.Timer? _ticker; + + private PomodoroService() + { + LoadState(); + // 앱 재시작 후 진행 중이던 타이머가 있으면 재개 + if (IsRunning && Remaining > TimeSpan.Zero) + StartTicker(); + else + IsRunning = false; + } + + // ─── 공개 API ──────────────────────────────────────────────────────────── + + public void StartFocus() => StartMode(PomodoroMode.Focus); + public void StartBreak() => StartMode(PomodoroMode.Break); + + public void Stop() + { + StopTicker(); + IsRunning = false; + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Reset() + { + StopTicker(); + Mode = PomodoroMode.Idle; + IsRunning = false; + Remaining = TimeSpan.FromMinutes(FocusMinutes); + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Toggle() + { + if (IsRunning) Stop(); + else if (Mode == PomodoroMode.Break) StartBreak(); + else StartFocus(); + } + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private void StartMode(PomodoroMode mode) + { + StopTicker(); + Mode = mode; + IsRunning = true; + if (Remaining <= TimeSpan.Zero || Mode != mode) + Remaining = TimeSpan.FromMinutes(mode == PomodoroMode.Focus ? FocusMinutes : BreakMinutes); + StartTicker(); + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void StartTicker() + { + _ticker = new System.Threading.Timer(_ => Tick(), null, 1000, 1000); + } + + private void StopTicker() + { + _ticker?.Dispose(); + _ticker = null; + } + + private void Tick() + { + if (Remaining <= TimeSpan.Zero) + { + // 타이머 종료 + Stop(); + var modeLabel = Mode == PomodoroMode.Focus ? "집중" : "휴식"; + NotificationCenterService.Show("뽀모도로 타이머", $"{modeLabel} 시간이 종료되었습니다.", NotificationType.Info); + return; + } + + Remaining -= TimeSpan.FromSeconds(1); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + // ─── 영속성 ────────────────────────────────────────────────────────────── + + private void SaveState() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(StateFile)!); + var dto = new PomodoroStateDto + { + Mode = Mode.ToString(), + IsRunning = IsRunning, + RemainingSeconds = (int)Remaining.TotalSeconds, + FocusMinutes = FocusMinutes, + BreakMinutes = BreakMinutes, + SavedAt = DateTime.Now, + }; + File.WriteAllText(StateFile, JsonSerializer.Serialize(dto)); + } + catch { /* 무시 */ } + } + + private void LoadState() + { + try + { + if (!File.Exists(StateFile)) goto defaults; + var json = File.ReadAllText(StateFile); + var dto = JsonSerializer.Deserialize(json); + if (dto == null) goto defaults; + + FocusMinutes = dto.FocusMinutes > 0 ? dto.FocusMinutes : 25; + BreakMinutes = dto.BreakMinutes > 0 ? dto.BreakMinutes : 5; + Mode = Enum.TryParse(dto.Mode, out var m) ? m : PomodoroMode.Idle; + IsRunning = dto.IsRunning; + + // 앱이 꺼진 동안 경과 시간 보정 + var elapsed = DateTime.Now - dto.SavedAt; + var saved = TimeSpan.FromSeconds(dto.RemainingSeconds); + Remaining = saved - elapsed; + if (Remaining < TimeSpan.Zero) { Remaining = TimeSpan.Zero; IsRunning = false; } + return; + } + catch { /* fall through */ } + + defaults: + FocusMinutes = 25; + BreakMinutes = 5; + Mode = PomodoroMode.Idle; + IsRunning = false; + Remaining = TimeSpan.FromMinutes(FocusMinutes); + } + + private class PomodoroStateDto + { + [JsonPropertyName("mode")] public string Mode { get; set; } = "Idle"; + [JsonPropertyName("isRunning")] public bool IsRunning { get; set; } + [JsonPropertyName("remainingSeconds")] public int RemainingSeconds { get; set; } + [JsonPropertyName("focusMinutes")] public int FocusMinutes { get; set; } = 25; + [JsonPropertyName("breakMinutes")] public int BreakMinutes { get; set; } = 5; + [JsonPropertyName("savedAt")] public DateTime SavedAt { get; set; } = DateTime.Now; + } +} diff --git a/src/AxCopilot/Services/ServerStatusService.cs b/src/AxCopilot/Services/ServerStatusService.cs new file mode 100644 index 0000000..ee99d81 --- /dev/null +++ b/src/AxCopilot/Services/ServerStatusService.cs @@ -0,0 +1,124 @@ +using System.Net.Http; + +namespace AxCopilot.Services; + +/// +/// Phase L3-9: 서버 상태 모니터 서비스. +/// Ollama / LLM API / 첫 번째 활성 MCP 서버의 연결 상태를 주기적으로 확인합니다. +/// 싱글턴 — 런처 표시 시 Start(), 숨김 시 Stop(). +/// +internal sealed class ServerStatusService +{ + public static readonly ServerStatusService Instance = new(); + + private static readonly HttpClient _http = new() + { + Timeout = TimeSpan.FromMilliseconds(1500) + }; + + // ─── 공개 상태 ─────────────────────────────────────────────────────────── + + public bool OllamaOnline { get; private set; } + public bool LlmOnline { get; private set; } + public bool McpOnline { get; private set; } + public string McpName { get; private set; } = "MCP"; + + public event EventHandler? StatusChanged; + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private System.Threading.Timer? _timer; + private string _ollamaEndpoint = "http://localhost:11434"; + private string _llmEndpoint = ""; + private string _llmService = "Ollama"; + private string _mcpEndpoint = ""; + + private ServerStatusService() { } + + public void Start(AxCopilot.Models.AppSettings? settings = null) + { + LoadEndpoints(settings); + if (_timer != null) return; + _timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15_000); + } + + public void Stop() + { + _timer?.Dispose(); + _timer = null; + } + + public void Refresh(AxCopilot.Models.AppSettings? settings = null) + { + LoadEndpoints(settings); + _ = CheckAllAsync(); + } + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private void LoadEndpoints(AxCopilot.Models.AppSettings? settings) + { + var llm = settings?.Llm; + if (llm == null) return; + + _ollamaEndpoint = llm.OllamaEndpoint?.TrimEnd('/') ?? "http://localhost:11434"; + _llmService = llm.Service ?? "Ollama"; + + // LLM API 엔드포인트: vLLM이면 별도, 나머지는 Ollama와 동일 + _llmEndpoint = _llmService.Equals("vLLM", StringComparison.OrdinalIgnoreCase) + ? (llm.VllmEndpoint?.TrimEnd('/') ?? "") + : _ollamaEndpoint; + + // 첫 번째 활성 MCP 서버 + var mcp = llm.McpServers?.FirstOrDefault(s => s.Enabled && !string.IsNullOrEmpty(s.Url)); + if (mcp != null) + { + McpName = mcp.Name; + _mcpEndpoint = mcp.Url?.TrimEnd('/') ?? ""; + } + else + { + McpName = "MCP"; + _mcpEndpoint = ""; + } + } + + private async Task CheckAllAsync() + { + var t1 = PingAsync(_ollamaEndpoint + "/api/version"); + var t2 = string.IsNullOrEmpty(_llmEndpoint) || _llmEndpoint == _ollamaEndpoint + ? t1 + : PingAsync(_llmEndpoint); + var t3 = string.IsNullOrEmpty(_mcpEndpoint) + ? Task.FromResult(false) + : PingAsync(_mcpEndpoint); + + await Task.WhenAll(t1, t2, t3).ConfigureAwait(false); + + bool changed = + OllamaOnline != t1.Result || + LlmOnline != t2.Result || + McpOnline != t3.Result; + + OllamaOnline = t1.Result; + LlmOnline = t2.Result; + McpOnline = t3.Result; + + if (changed) + StatusChanged?.Invoke(this, EventArgs.Empty); + } + + private static async Task PingAsync(string url) + { + if (string.IsNullOrEmpty(url)) return false; + try + { + var resp = await _http.GetAsync(url).ConfigureAwait(false); + return resp.IsSuccessStatusCode || (int)resp.StatusCode < 500; + } + catch + { + return false; + } + } +} diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs b/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs new file mode 100644 index 0000000..75e610a --- /dev/null +++ b/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs @@ -0,0 +1,81 @@ +using AxCopilot.Handlers; +using AxCopilot.Services; + +namespace AxCopilot.ViewModels; + +/// +/// Phase L3-9: 런처 하단 미니 위젯 데이터 바인딩 프로퍼티. +/// LauncherWindow.Widgets.cs의 타이머에서 UpdateWidgets()를 호출합니다. +/// +public partial class LauncherViewModel +{ + // ─── 시스템 모니터 ──────────────────────────────────────────────────────── + + /// 위젯 텍스트 예: "CPU 34% RAM 62% C:42%" + public string Widget_PerfText + { + get + { + var p = PerformanceMonitorService.Instance; + return $"CPU {p.CpuPercent:F0}% RAM {p.RamPercent:F0}% {p.DiskCText}"; + } + } + + // ─── 뽀모도로 ───────────────────────────────────────────────────────────── + + /// 위젯 텍스트 예: "집중 18:42" / "휴식 04:58" / "대기 25:00" + public string Widget_PomoText + { + get + { + var p = PomodoroService.Instance; + var rem = p.Remaining; + var timeStr = $"{(int)rem.TotalMinutes:D2}:{rem.Seconds:D2}"; + return p.Mode switch + { + PomodoroMode.Focus => $"집중 {timeStr}", + PomodoroMode.Break => $"휴식 {timeStr}", + _ => $"대기 {timeStr}", + }; + } + } + + /// 뽀모도로 실행 중이면 true → 위젯 색상 강조에 사용 + public bool Widget_PomoRunning => PomodoroService.Instance.IsRunning; + + // ─── 빠른 메모 ──────────────────────────────────────────────────────────── + + private int _noteCount; + + /// 위젯 텍스트 예: "메모 3건" / "메모 없음" + public string Widget_NoteText => _noteCount > 0 ? $"메모 {_noteCount}건" : "메모 없음"; + + // ─── 서버 상태 ──────────────────────────────────────────────────────────── + + 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; + + // ─── 갱신 메서드 ────────────────────────────────────────────────────────── + + /// 1초마다 LauncherWindow.Widgets.cs에서 호출 — UI 바인딩 갱신. + public void UpdateWidgets() + { + // 메모 건수 갱신 (파일 I/O 최소화: 5회마다 1회만) + _widgetRefreshTick++; + if (_widgetRefreshTick % 5 == 0) + _noteCount = NoteHandler.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)); + } + + private int _widgetRefreshTick; +} diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index a4126b8..82054f4 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -151,6 +151,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged { "tag", ("태그", Symbols.Tag, "#6366F1") }, // ─── Phase L3-8 알림 센터 ───────────────────────────────────────────── { "notif", ("알림", Symbols.ReminderBell, "#F59E0B") }, + // ─── Phase L3-9 위젯 핸들러 ────────────────────────────────────────── + { "pomo", ("타이머", Symbols.Timer, "#F59E0B") }, }; // ─── 설정 기능 토글 (런처 실동작 연결) ────────────────────────────────── diff --git a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml index 2039713..69d9bf4 100644 --- a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml +++ b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml @@ -7,6 +7,37 @@ ChatWindow 내부에서 AX Agent 모든 설정을 인라인으로 제어. SettingsWindow의 AX Agent 탭을 완전 대체. --> + + + + + diff --git a/src/AxCopilot/Views/LauncherWindow.Widgets.cs b/src/AxCopilot/Views/LauncherWindow.Widgets.cs new file mode 100644 index 0000000..9d75e30 --- /dev/null +++ b/src/AxCopilot/Views/LauncherWindow.Widgets.cs @@ -0,0 +1,143 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Threading; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// Phase L3-9: 런처 하단 미니 위젯 바 로직. +/// 타이머로 1초마다 ViewModel 갱신 + 서버 상태 dot 색상 직접 업데이트. +/// +public partial class LauncherWindow +{ + private DispatcherTimer? _widgetTimer; + + private static readonly SolidColorBrush _dotOnline = new(Color.FromRgb(0x10, 0xB9, 0x81)); + private static readonly SolidColorBrush _dotOffline = new(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + /// LauncherWindow.xaml.cs의 Show()에서 최초 1회 호출 — IsVisibleChanged로 자동 관리. + internal void InitWidgets() + { + IsVisibleChanged -= OnLauncherVisibleChanged; + IsVisibleChanged += OnLauncherVisibleChanged; + } + + private void OnLauncherVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if ((bool)e.NewValue) StartWidgetUpdates(); + else StopWidgetUpdates(); + } + + /// 런처가 표시될 때 위젯 업데이트 시작. + internal void StartWidgetUpdates() + { + var settings = CurrentApp?.SettingsService?.Settings; + + // 서비스 시작 + PerformanceMonitorService.Instance.StartPolling(); + ServerStatusService.Instance.Start(settings); + PomodoroService.Instance.StateChanged -= OnPomoStateChanged; + PomodoroService.Instance.StateChanged += OnPomoStateChanged; + ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged; + ServerStatusService.Instance.StatusChanged += OnServerStatusChanged; + + // 메모 건수 즉시 갱신 (최초 1회) + _vm.UpdateWidgets(); + UpdateServerDots(); + + // 1초 타이머 + if (_widgetTimer == null) + { + _widgetTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromSeconds(1) + }; + _widgetTimer.Tick += (_, _) => + { + _vm.UpdateWidgets(); + UpdateServerDots(); + }; + } + _widgetTimer.Start(); + + // 뽀모도로 실행 중이면 위젯 색상 강조 + UpdatePomoWidgetStyle(); + } + + /// 런처가 숨겨질 때 타이머 중지 (뽀모도로는 계속 실행). + internal void StopWidgetUpdates() + { + _widgetTimer?.Stop(); + PerformanceMonitorService.Instance.StopPolling(); + PomodoroService.Instance.StateChanged -= OnPomoStateChanged; + ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged; + } + + // ─── 이벤트 핸들러 ────────────────────────────────────────────────────── + + private void OnPomoStateChanged(object? sender, EventArgs e) + { + Dispatcher.InvokeAsync(() => + { + _vm.UpdateWidgets(); + UpdatePomoWidgetStyle(); + }); + } + + private void OnServerStatusChanged(object? sender, EventArgs e) + { + Dispatcher.InvokeAsync(UpdateServerDots); + } + + // ─── UI 업데이트 ──────────────────────────────────────────────────────── + + private void UpdateServerDots() + { + var srv = ServerStatusService.Instance; + if (OllamaStatusDot != null) + OllamaStatusDot.Fill = srv.OllamaOnline ? _dotOnline : _dotOffline; + if (LlmStatusDot != null) + LlmStatusDot.Fill = srv.LlmOnline ? _dotOnline : _dotOffline; + if (McpStatusDot != null) + McpStatusDot.Fill = srv.McpOnline ? _dotOnline : _dotOffline; + } + + private void UpdatePomoWidgetStyle() + { + if (WgtPomo == null) return; + var running = PomodoroService.Instance.IsRunning; + WgtPomo.Background = running + ? new SolidColorBrush(Color.FromArgb(0x1E, 0xF5, 0x9E, 0x0B)) + : new SolidColorBrush(Color.FromArgb(0x0D, 0xF5, 0x9E, 0x0B)); + } + + // ─── 위젯 클릭 핸들러 ─────────────────────────────────────────────────── + + private void WgtPerf_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + _vm.InputText = "info "; + InputBox?.Focus(); + } + + private void WgtPomo_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + _vm.InputText = "pomo "; + InputBox?.Focus(); + } + + private void WgtNote_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + _vm.InputText = "note "; + InputBox?.Focus(); + } + + private void WgtServer_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // 서버 상태 강제 새로고침 + var settings = CurrentApp?.SettingsService?.Settings; + ServerStatusService.Instance.Refresh(settings); + _vm.InputText = "port"; + InputBox?.Focus(); + } +} diff --git a/src/AxCopilot/Views/LauncherWindow.xaml b/src/AxCopilot/Views/LauncherWindow.xaml index 74f62ab..40af89b 100644 --- a/src/AxCopilot/Views/LauncherWindow.xaml +++ b/src/AxCopilot/Views/LauncherWindow.xaml @@ -201,6 +201,7 @@ + @@ -701,9 +702,121 @@ Margin="0,0,0,8" Opacity="0.7"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hide(); vm.NotificationRequested += (_, msg) => ShowNotification(msg); + // Phase L3-9: 위젯 가시성 감지 등록 (Show/Hide에 자동 반응) + InitWidgets(); + // InputText가 코드에서 변경될 때 커서를 끝으로 이동 // (F1→"help", Ctrl+B→"fav", Ctrl+D→"cd Downloads" 등 자동 입력 후 위화감 해소) vm.PropertyChanged += (_, e) => @@ -202,6 +205,8 @@ public partial class LauncherWindow : Window StopRainbowGlow(); UpdateSelectionGlow(); + + // Phase L3-9: 위젯은 IsVisibleChanged로 자동 제어 (InitWidgets에서 등록) } /// 아이콘 애니메이션을 중지하고 모든 픽셀을 완전 점등 상태로 복원합니다.