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에서 등록)
}
/// 아이콘 애니메이션을 중지하고 모든 픽셀을 완전 점등 상태로 복원합니다.