From 26f9533777ab3ffc2685b99238ce6ba9417a83f9 Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Tue, 9 Jun 2026 22:45:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(overlays):=20branded=20glass=20HUD=20over?= =?UTF-8?q?=20fullscreen=20=E2=80=94=20route=20all=20overlays=20through=20?= =?UTF-8?q?the=20Swift=20NSPanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every keyboard/HUD overlay (F13 toggle, F18 recording, transcribing, SIGNING OUT, the three CODEC Dictate pills, skill-fired, F16 task input) was a dated square tkinter box that macOS hides behind fullscreen apps. Root cause: commit faf3bef deleted the writers feeding the Swift CODECOverlay NSPanel — the renderer that floats over fullscreen (canJoinAllSpaces|fullScreenAuxiliary) — so everything fell back to tkinter. Fix + redesign: - codec_overlays.py emits JSON events to ~/.codec/overlay_events.jsonl (the channel the running Swift panel polls); tkinter kept as automatic fallback when the panel is down. One flag (_USE_SWIFT) reverts everything. - swift-overlay redesigned: dark-tinted glass (blur + black 0.66), 24px radius with maskImage (transparent corners), 84px tinted CODEC hexagon mark pinned left, solid accent-colored centered titles (orange/red/blue per state), shortcut chips (F18·voice …), pulsing dot / breathing mark, uniform 700×140 frame, fade in/out. - New focusable InputPanel replaces the raw AppleScript F16 dialog (glass field + Send; Enter submits, Esc cancels); codec_core.get_text_dialog falls back to osascript within 2s if the panel doesn't ack. Return contract preserved. - codec_dictate reuses the shared renderer (was 3 inline tkinter pills); codec.py hides the recording HUD on F18 release. Tests: tests/test_overlays.py (7, TDD red-then-green). Preview harness: tools/overlay_preview.py. Design: docs/OVERLAY-REDESIGN.md. Co-Authored-By: Claude Fable 5 --- codec.py | 4 +- codec_core.py | 60 +++ codec_dictate.py | 83 +--- codec_overlays.py | 124 +++++- docs/OVERLAY-REDESIGN.md | 89 ++++ swift-overlay/Sources/main.swift | 674 ++++++++++++++++++++++++------- tests/test_overlays.py | 86 ++++ tools/overlay_preview.py | 55 +++ 8 files changed, 952 insertions(+), 223 deletions(-) create mode 100644 docs/OVERLAY-REDESIGN.md create mode 100644 tests/test_overlays.py create mode 100644 tools/overlay_preview.py diff --git a/codec.py b/codec.py index 2e6bb6a..abda315 100644 --- a/codec.py +++ b/codec.py @@ -97,7 +97,7 @@ def _is_wake_utterance(text: str) -> bool: # A-4: canonical skill dispatch (lazy SkillRegistry + safety gate + run_with_hooks). from codec_dispatch import check_skill, run_skill, load_skills -from codec_overlays import show_overlay, show_recording_overlay, show_processing_overlay, show_toggle_overlay +from codec_overlays import show_overlay, show_recording_overlay, show_recording_stop, show_processing_overlay, show_toggle_overlay from codec_identity import CODEC_VOICE_PROMPT # ── SKILLS ─────────────────────────────────────────────────────────────────── @@ -648,6 +648,7 @@ def do_stop_voice(): try: ovl.terminate() except Exception as e: log.debug("Overlay process cleanup failed: %s", e) state["overlay_proc"] = None + show_recording_stop() # hide the Swift recording HUD if not audio or not os.path.exists(audio): return # Reject recordings shorter than 0.5s — just button taps, not speech rec_duration = time.time() - rec_start if rec_start else 0 @@ -886,6 +887,7 @@ def on_release(key): try: ovl.terminate() except Exception: pass state["overlay_proc"] = None + show_recording_stop() # hide the Swift recording HUD on release threading.Thread(target=lambda: subprocess.run( ['afplay', '/System/Library/Sounds/Pop.aiff'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL), daemon=True).start() diff --git a/codec_core.py b/codec_core.py index 48dba58..788bcd3 100644 --- a/codec_core.py +++ b/codec_core.py @@ -194,7 +194,67 @@ def focused_app(): log.debug("Focused app detection failed: %s", e) return "Unknown" +def _swift_text_input(timeout=120): + """F16 branded glass input via the Swift overlay. Emits an input_request and + waits for the reply file. Returns the typed text, "" on cancel, or None if + the panel never appeared / errored (→ caller falls back to osascript). + Fast-fallback: if no .ack within 2s, the panel didn't show — bail immediately.""" + import json as _json + import secrets as _secrets + import time as _time + rid = _secrets.token_hex(4) + base = os.path.expanduser(f"~/.codec/overlay_input_{rid}") + reply, ack = base + ".json", base + ".ack" + events = os.path.expanduser("~/.codec/overlay_events.jsonl") + for p in (reply, ack): + try: + os.remove(p) + except OSError: + pass + try: + with open(events, "a") as f: + f.write(_json.dumps({"type": "input_request", "id": rid, + "prompt": "What can I do for you?"}) + "\n") + except Exception: + return None + # 1) wait up to 2s for the panel to acknowledge it appeared + ack_deadline = _time.time() + 2.0 + while _time.time() < ack_deadline: + if os.path.exists(ack): + try: + os.remove(ack) + except OSError: + pass + break + _time.sleep(0.05) + else: + return None # panel never showed → osascript fallback + # 2) panel is up — wait for the user's reply + deadline = _time.time() + timeout + while _time.time() < deadline: + if os.path.exists(reply): + try: + with open(reply) as f: + data = _json.load(f) + os.remove(reply) + return str(data.get("text", "")).strip() + except Exception: + return None + _time.sleep(0.15) + return "" # timed out waiting for input — treat as cancel + + def get_text_dialog(): + # Branded Swift glass panel (floats over fullscreen, matches the HUD) with an + # automatic fall-through to the native osascript dialog if it isn't available. + try: + import codec_overlays + if getattr(codec_overlays, "_USE_SWIFT", False) and codec_overlays._swift_alive(): + result = _swift_text_input() + if result is not None: + return result + except Exception as e: + log.debug("Swift input panel unavailable, using osascript: %s", e) try: r = subprocess.run(["osascript", "-e", 'set t to text returned of (display dialog "CODEC - Enter task:" default answer "" with title "CODEC" buttons {"Cancel","Send"} default button "Send")'], diff --git a/codec_dictate.py b/codec_dictate.py index 5816095..0610bdb 100644 --- a/codec_dictate.py +++ b/codec_dictate.py @@ -27,6 +27,10 @@ CHANNELS = 1 CHUNK_DURATION_MS = 30 # ms per audio chunk +# Shared overlay renderer (Swift HUD + tkinter fallback) — unifies dictate's +# Listening / Transcribing / LIVE pills with the rest of CODEC. +import codec_overlays + # ── STATE ──────────────────────────────────────────────────────────────────── recording = False audio_frames = [] @@ -78,37 +82,8 @@ def load_model(): def show_overlay(): global overlay_proc try: - script = """ -import tkinter as tk -root = tk.Tk() -root.overrideredirect(True) -root.attributes('-topmost', True) -root.attributes('-alpha', 0.95) -root.configure(bg='#111111') -sw = root.winfo_screenwidth() -sh = root.winfo_screenheight() -w, h = 680, 88 -x = (sw - w) // 2 -y = sh - 120 -root.geometry(f'{w}x{h}+{x}+{y}') -c = tk.Canvas(root, bg='#111111', highlightthickness=0, width=w, height=h) -c.pack() -c.create_rectangle(2, 2, w-2, h-2, outline='#E8711A', width=2) -dot = c.create_oval(24, 30, 40, 46, fill='#ff3b3b', outline='') -c.create_text(w//2+10, 28, text='Listening \\u2014 release \\u2318 to transcribe', fill='#ffffff', font=('SF Pro Display', 16, 'bold')) -c.create_text(w//2+10, 58, text='Press F5 for hands-free live typing', fill='#777777', font=('SF Pro Display', 12)) -def pulse(): - cur = c.itemcget(dot,'fill') - c.itemconfig(dot, fill='#ff3b3b' if cur=='#440000' else '#440000') - root.after(500, pulse) -pulse() -root.mainloop() -""" - overlay_proc = subprocess.Popen( - [sys.executable, "-c", script], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) + # Shared Swift HUD: orange mark + "Listening / release ⌘ to send". + overlay_proc = codec_overlays.show_recording_overlay("⌘") except Exception as e: print(f"[DICTATE] Overlay error: {e}") @@ -117,40 +92,16 @@ def hide_overlay(): if overlay_proc: try: overlay_proc.terminate() - overlay_proc = None except Exception: pass + overlay_proc = None + codec_overlays.show_recording_stop() # ── SHOW PROCESSING OVERLAY ─────────────────────────────────────────────────── def show_processing(): try: - script = """ -import tkinter as tk -import sys -root = tk.Tk() -root.overrideredirect(True) -root.attributes('-topmost', True) -root.attributes('-alpha', 0.93) -root.configure(bg='#0a0a0a') -sw = root.winfo_screenwidth() -sh = root.winfo_screenheight() -w, h = 520, 90 -x = (sw - w) // 2 -y = sh - 130 -root.geometry(f'{w}x{h}+{x}+{y}') -c = tk.Canvas(root, bg='#0a0a0a', highlightthickness=0, width=w, height=h) -c.pack() -c.create_rectangle(1,1,w-1,h-1, outline='#00aaff', width=1) -c.create_text(w//2, h//2, text='\u26a1 Transcribing...', fill='#00aaff', font=('Helvetica', 13)) -root.after(20000, root.destroy) -root.mainloop() -""" - p = subprocess.Popen( - [sys.executable, "-c", script], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - return p + # Shared Swift HUD: blue "Transcribing\u2026" (auto-hides; or hide_overlay()). + return codec_overlays.show_processing_overlay("Transcribing...", duration=20000) except Exception: return None @@ -298,13 +249,8 @@ def start_live_dictation(): threading.Thread(target=lambda: subprocess.run( ['afplay', '/System/Library/Sounds/Blow.aiff'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL), daemon=True).start() - # Show overlay — log stderr to /tmp so we can diagnose if it fails to render - _ov_err = open("/tmp/codec_dictate_overlay.log", "w") - live_overlay = subprocess.Popen( - [sys.executable, "-c", _live_overlay_script()], - stdout=subprocess.DEVNULL, stderr=_ov_err - ) - print(f"[DICTATE] Overlay subprocess pid={live_overlay.pid}") + # Show the LIVE indicator via the shared Swift HUD (tkinter fallback if down) + live_overlay = codec_overlays.show_live_overlay() # Start recording loop in thread live_thread = threading.Thread(target=_live_record_loop, daemon=True) live_thread.start() @@ -320,7 +266,8 @@ def stop_live_dictation(): threading.Thread(target=lambda: subprocess.run( ['afplay', '/System/Library/Sounds/Funk.aiff'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL), daemon=True).start() - # Kill overlay — tkinter mainloop sometimes ignores SIGTERM, so SIGKILL it + # Hide the LIVE indicator (Swift) + terminate any tkinter fallback process + codec_overlays.show_recording_stop() if live_overlay: try: live_overlay.terminate() except OSError: pass # ProcessLookupError covered (subclass of OSError) @@ -393,6 +340,7 @@ def transcribe_and_type(audio_path): proc_overlay.terminate() except Exception: pass + codec_overlays.hide_overlay() # hide the Swift "Transcribing…" HUD if not text or is_hallucination(text): print(f"[DICTATE] No speech or hallucination: {text!r}") @@ -453,6 +401,7 @@ def transcribe_and_type(audio_path): proc_overlay.terminate() except Exception: pass + codec_overlays.hide_overlay() # hide the Swift "Transcribing…" HUD finally: try: os.unlink(audio_path) diff --git a/codec_overlays.py b/codec_overlays.py index 37185cb..767cde9 100644 --- a/codec_overlays.py +++ b/codec_overlays.py @@ -1,8 +1,76 @@ -"""CODEC Overlays — AppKit-based overlays that float above fullscreen apps. -Falls back to tkinter if PyObjC is not available.""" +"""CODEC Overlays — render through the always-running Swift CODECOverlay +NSPanel (floats above fullscreen, glass-blurred, branded) by appending JSON +event lines to ~/.codec/overlay_events.jsonl. Falls back to tkinter when the +Swift renderer isn't running.""" +import json import os +import shutil import subprocess import sys +import time + +# ── Swift CODECOverlay IPC channel ────────────────────────────────────────── +# Primary render path. Flip _USE_SWIFT=False to force the legacy tkinter path. +_USE_SWIFT = True +_EVENTS = os.path.expanduser("~/.codec/overlay_events.jsonl") +_MARK_DEST = os.path.expanduser("~/.codec/overlay_mark.png") +_alive_cache = {"ts": 0.0, "alive": None} + + +def _ensure_mark(): + """Stage the CODEC hexagon mark where the Swift app loads it (idempotent).""" + try: + if not os.path.exists(_MARK_DEST): + src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "favicon.png") + if os.path.exists(src): + os.makedirs(os.path.dirname(_MARK_DEST), exist_ok=True) + shutil.copyfile(src, _MARK_DEST) + except Exception: + pass + + +def _emit(event): + """Append one overlay event for the Swift renderer. Never raises.""" + try: + os.makedirs(os.path.dirname(_EVENTS), exist_ok=True) + with open(_EVENTS, "a") as f: + f.write(json.dumps(event) + "\n") + return True + except Exception: + return False + + +def _swift_alive(): + """True if the Swift CODECOverlay process is running (result cached 5s).""" + now = time.time() + if _alive_cache["alive"] is not None and now - _alive_cache["ts"] < 5: + return _alive_cache["alive"] + alive = False + try: + r = subprocess.run(["pgrep", "-f", "CODECOverlay"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1) + alive = (r.returncode == 0) + except Exception: + alive = False + _alive_cache["ts"] = now + _alive_cache["alive"] = alive + return alive + + +def _play_sound(path): + """Play a system sound off-thread (never blocks the overlay).""" + import threading + threading.Thread( + target=lambda: subprocess.run(["afplay", path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL), + daemon=True, + ).start() + + +try: + _ensure_mark() +except Exception: + pass def _has_appkit(): @@ -198,18 +266,59 @@ def pulse(): def show_overlay(text, color="#E8711A", duration=2500): + if _USE_SWIFT and _swift_alive(): + _emit({"type": "notify", "text": text, "color": color, + "duration": (duration / 1000.0) if duration else 2.5}) + return None if _USE_APPKIT: return _appkit_overlay(text, color, duration) return _tk_overlay(text, color, duration) def show_recording_overlay(key_label="F18"): + if _USE_SWIFT and _swift_alive(): + _emit({"type": "recording_start", "title": "Listening", + "subtitle": f"release {key_label} to send"}) + return None if _USE_APPKIT: return _appkit_overlay(f"\U0001f3a4 Recording — release {key_label} to send", "#E8711A", duration=0) return _tk_recording(key_label) +def show_recording_stop(): + """Hide a persistent recording/live overlay. Swift only — the tkinter + fallback is a child process the caller terminates directly.""" + if _USE_SWIFT and _swift_alive(): + _emit({"type": "recording_stop"}) + + +def hide_overlay(): + """Hide whatever overlay is currently showing (Swift).""" + if _USE_SWIFT and _swift_alive(): + _emit({"type": "hide"}) + + +def show_live_overlay(): + """CODEC Dictate F5 hands-free live-typing indicator (red, persistent).""" + if _USE_SWIFT and _swift_alive(): + _emit({"type": "live"}) + return None + return _tk_overlay("LIVE · press F5 to stop", "#ff3b3b", 0) + + +def show_refining_overlay(): + """CODEC Dictate draft-refinement indicator (blue, persistent).""" + if _USE_SWIFT and _swift_alive(): + _emit({"type": "refining"}) + return None + return _tk_overlay("Refining…", "#00aaff", 0) + + def show_processing_overlay(text="Transcribing...", duration=4000): + if _USE_SWIFT and _swift_alive(): + _emit({"type": "transcribing", "text": text, + "duration": (duration / 1000.0) if duration else 0}) + return None if _USE_APPKIT: return _appkit_overlay(f"\u26a1 {text}", "#00aaff", duration) # tkinter fallback @@ -239,15 +348,14 @@ def show_processing_overlay(text="Transcribing...", duration=4000): def show_toggle_overlay(is_on, shortcuts=""): + snd = '/System/Library/Sounds/Blow.aiff' if is_on else '/System/Library/Sounds/Funk.aiff' + _play_sound(snd) + if _USE_SWIFT and _swift_alive(): + _emit({"type": "toggle_on", "shortcuts": shortcuts} if is_on else {"type": "toggle_off"}) + return None color = '#E8711A' if is_on else '#ff3333' label = 'C O D E C' if is_on else 'S I G N I N G O U T' dur = 3000 if is_on else 1500 - import threading - snd = '/System/Library/Sounds/Blow.aiff' if is_on else '/System/Library/Sounds/Funk.aiff' - threading.Thread( - target=lambda: subprocess.run(['afplay', snd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL), - daemon=True - ).start() if _USE_APPKIT: return _appkit_overlay(label, color, dur, font_size=18, bold=True, subtitle=shortcuts) # tkinter fallback diff --git a/docs/OVERLAY-REDESIGN.md b/docs/OVERLAY-REDESIGN.md new file mode 100644 index 0000000..53c4125 --- /dev/null +++ b/docs/OVERLAY-REDESIGN.md @@ -0,0 +1,89 @@ +# CODEC Overlay Redesign + Chat Action Row + +**Date:** 2026-06-09 · **Status:** implemented (uncommitted), pending final visual sign-off + F16 typing test. + +## 1. What & why + +Two user-facing surfaces were dated and partly broken: + +1. **Keyboard/HUD overlays** (F13 toggle, F18 recording, transcribing, SIGNING OUT, + the 3 CODEC Dictate pills, skill-fired) looked like "Windows 98" (square tkinter + boxes, flat black, 1px border) **and did not appear over fullscreen apps**. +2. **Chat message action buttons** (`/chat`) were corner-floating absolute icons that + clipped off-screen and didn't form a clean row; the Speak button was unstyled. + +### Root cause (overlays) — one regression, two symptoms +CODEC already ships a **Swift `CODECOverlay` NSPanel** (PM2 `codec-overlay`) that renders +rounded, blurred, branded HUDs and **floats over fullscreen** via +`collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]` + screen-saver window level. +Commit `faf3bef` (dead-code cleanup) deleted the event-writers that fed it, so since then +only `skill_fired` reached it — every other overlay fell through to the tkinter path, which +is both ugly *and* can't beat fullscreen (`-topmost` is not Space-aware). **Both complaints +collapse to: re-feed the Swift renderer.** + +## 2. Architecture + +`codec_overlays.py` (and `codec_dictate.py`) **emit JSON event lines** to +`~/.codec/overlay_events.jsonl`; the Swift NSPanel polls that file every 0.15s and renders. +tkinter is kept as an **automatic fallback** when the Swift process isn't running +(`_swift_alive()` = cached `pgrep`), and the whole thing reverts via one flag +(`codec_overlays._USE_SWIFT = False`). + +``` +codec.py / codec_dictate.py / codec_voice.py / codec_core.py + │ show_*() → codec_overlays._emit({...}) + ▼ +~/.codec/overlay_events.jsonl (append-only JSON lines) + ▼ poll 0.15s +swift-overlay (CODECOverlay NSPanel) → glass HUD over fullscreen +``` + +### Event types +`toggle_on{shortcuts,duration?}` · `toggle_off{duration?}` · `recording_start{title?,subtitle?}` · +`recording_stop` · `transcribing{text,duration?}` · `live` · `live_stop` · `refining` · +`skill_fired{name,duration?}` · `notify{text,color,duration}` · `hide` · +`input_request{id,prompt}` (F16). + +## 3. Visual system (Swift `OverlayPanel`) +- Glass: `NSVisualEffectView .hudWindow`, **forced dark** (`.darkAqua`) so text reads on a + light desktop; 24px corner radius; `maskImage` clips blur **and** shadow (transparent corners). +- CODEC hexagon mark (`~/.codec/overlay_mark.png`, template-tinted per state), pinned left. +- Title **centered in the full pill**, **accent-colored** (orange/red/blue — never white), + soft dark halo for legibility. +- Shortcut hints render as **chips** (`F18·voice` …), not a monospace run. +- **Uniform fixed frame** (620×128) for every state. +- Per-state: toggle ON = orange "CODEC" + chips; SIGNING OUT = red; recording = orange + + pulsing dot; transcribing/refining = blue (breathing); notify = mapped color. + +### F16 — branded glass input panel (`InputPanel`) +Focusable borderless glass panel (mark + text field + Send; Enter submits, Esc cancels). +Triggered by `input_request`; writes the typed text to `~/.codec/overlay_input_.json` and +an `.ack` on appear. `codec_core.get_text_dialog()` emits the request, waits ≤2s for the ack +(else **fast-falls-back to the native osascript dialog**), then waits for the reply. Contract +preserved: returns the typed string, `""` on cancel. + +## 4. Files changed +| File | Change | +|---|---| +| `swift-overlay/Sources/main.swift` | redesigned `OverlayPanel` + new `InputPanel`; event handling | +| `codec_overlays.py` | `_emit`/`_swift_alive`/`_play_sound`/`_ensure_mark`; 4 public fns route to Swift + `show_recording_stop`/`hide_overlay`/`show_live_overlay`/`show_refining_overlay`; tkinter fallback kept | +| `codec.py` | F18 release + stop-voice emit `recording_stop` | +| `codec_dictate.py` | Listening / Transcribing / LIVE delegate to `codec_overlays` | +| `codec_core.py` | `get_text_dialog()` → Swift `InputPanel` with osascript fallback | +| `codec_chat.html` | message buttons → one inline `.msg-meta` action row (copy/edit/regen/speak) | +| `tests/test_overlays.py` | 7 new routing tests (TDD) | +| `tools/overlay_preview.py` | dev preview harness | + +No skill-manifest gate applies (engine modules, not `skills/`). + +## 5. Test + rollback +- `tests/test_overlays.py` (7) — emit routing + tkinter fallback. Green. +- Live preview: `python3 tools/overlay_preview.py [seconds]`. +- Rollback: `codec_overlays._USE_SWIFT = False` reverts all overlays to tkinter instantly; + `git checkout swift-overlay/` + `swift build -c release` + `pm2 restart codec-overlay` + restores the prior binary. tkinter path never deleted. + +## 6. Open items +- **F16 typing/focus** needs a human test (couldn't screen-capture under Screen Recording perms). +- `_live_overlay_script()` in `codec_dictate.py` is now dead code (safe to delete in PR). +- Restart to deploy: `pm2 restart codec-overlay open-codec codec-dictate`. diff --git a/swift-overlay/Sources/main.swift b/swift-overlay/Sources/main.swift index 0b866ad..8b9ed0c 100644 --- a/swift-overlay/Sources/main.swift +++ b/swift-overlay/Sources/main.swift @@ -1,127 +1,460 @@ import AppKit -import SwiftUI -// MARK: - Event Poller (reads ~/.codec/overlay_events.jsonl) -final class EventPoller: NSObject { - private var timer: Timer? - private var lastOffset: Int = 0 - weak var appDelegate: AppDelegate? - private let eventFile: String = { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return home + "/.codec/overlay_events.jsonl" - }() +// ============================================================================ +// CODEC Overlay — native AppKit HUD that floats above everything (incl. +// fullscreen apps) via `collectionBehavior = [.canJoinAllSpaces, +// .fullScreenAuxiliary]`. Fed by appending JSON lines to +// ~/.codec/overlay_events.jsonl (the codec_overlays.py / codec_dictate.py +// emitters write here). Redesigned 2026-06: glass vibrancy, 18px pill, +// tinted CODEC hexagon mark, shortcut chips, per-state accents. +// ============================================================================ - func start() { - // Ensure ~/.codec directory exists - let dir = (eventFile as NSString).deletingLastPathComponent - if !FileManager.default.fileExists(atPath: dir) { - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - } - // Create file if missing, with 0600 permissions (owner read/write only) - if !FileManager.default.fileExists(atPath: eventFile) { - FileManager.default.createFile(atPath: eventFile, contents: nil, - attributes: [.posixPermissions: 0o600]) - } - timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in - self?.poll() - } +// MARK: - Brand tokens +enum Brand { + static let orange = NSColor(srgbRed: 0.910, green: 0.443, blue: 0.102, alpha: 1) // #E8711A + static let red = NSColor(srgbRed: 1.000, green: 0.271, blue: 0.227, alpha: 1) // #ff453a + static let blue = NSColor(srgbRed: 0.039, green: 0.518, blue: 1.000, alpha: 1) // #0A84FF + static let green = NSColor(srgbRed: 0.188, green: 0.820, blue: 0.345, alpha: 1) // #30D158 + static let textPrimary = NSColor(white: 0.93, alpha: 1) + static let textMuted = NSColor(srgbRed: 0.74, green: 0.74, blue: 0.78, alpha: 1) + static let hairline = NSColor(white: 1.0, alpha: 0.08) + + static func from(hex: String) -> NSColor { + let h = hex.lowercased() + if h.contains("ff33") || h.contains("ff45") || h.contains("ff3b") || h.contains("ef44") { return red } + if h.contains("00aa") || h.contains("0a84") || h.contains("448a") || h.contains("3b82") { return blue } + if h.contains("30d1") || h.contains("22c5") { return green } + return orange } +} - private func poll() { - guard let data = FileManager.default.contents(atPath: eventFile), - let text = String(data: data, encoding: .utf8) else { return } - let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty } - guard lines.count > lastOffset else { return } - let newLines = lines[lastOffset...] - lastOffset = lines.count - for line in newLines { - guard let d = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any], - let type = json["type"] as? String else { continue } - DispatchQueue.main.async { [weak self] in - self?.appDelegate?.handleEvent(type: type, json: json) - } +// MARK: - Overlay state +enum OverlayState { + case toggleOn, toggleOff, recording, processing, live, refining + case notify(NSColor) + + var accent: NSColor { + switch self { + case .toggleOn, .recording: return Brand.orange + case .toggleOff, .live: return Brand.red + case .processing, .refining: return Brand.blue + case .notify(let c): return c } } + var pulses: Bool { // pulsing dot badge on the mark + switch self { case .recording, .live: return true; default: return false } + } + var breathes: Bool { // gentle mark opacity breathe + switch self { case .processing, .refining: return true; default: return false } + } + var wordmark: Bool { // letter-spaced uppercase title (CODEC / SIGNING OUT) + switch self { case .toggleOn, .toggleOff: return true; default: return false } + } +} + +// MARK: - Shared mark image (~/.codec/overlay_mark.png, template-tinted per state) +let markImage: NSImage? = { + let p = FileManager.default.homeDirectoryForCurrentUser.path + "/.codec/overlay_mark.png" + guard let img = NSImage(contentsOfFile: p) else { return nil } + img.isTemplate = true // render as a silhouette so contentTintColor recolors the hexagon+bars + return img +}() + +// SOLID, bright text — no stroke. (A dark outline around glyphs on a dark panel +// was making everything thin and muddy.) On the dark-tinted glass, clean solid +// fills read perfectly. +func strokedText(_ s: String, font: NSFont, fill: NSColor, kern: CGFloat = 0, center: Bool = false) -> NSAttributedString { + var attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: fill, + ] + if kern != 0 { attrs[.kern] = kern } + if center { + let p = NSMutableParagraphStyle(); p.alignment = .center + attrs[.paragraphStyle] = p + } + return NSAttributedString(string: s, attributes: attrs) } -// MARK: - Overlay Panel +// MARK: - Shortcut chip (e.g. F18 · voice) +final class ChipView: NSView { + init(key: String, label: String, accent: NSColor) { + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 10 + // Subtle orange capsule on the now-dark panel — white text reads cleanly. + layer?.backgroundColor = accent.withAlphaComponent(0.22).cgColor + layer?.borderWidth = 1 + layer?.borderColor = accent.withAlphaComponent(0.5).cgColor + + // Bright WHITE + dark outline ("line around the white") so chips read on glass. + let keyL = NSTextField(labelWithString: key) + keyL.attributedStringValue = strokedText( + key, font: NSFont.monospacedSystemFont(ofSize: 14.5, weight: .bold), fill: .white) + + let labL = NSTextField(labelWithString: label) + labL.attributedStringValue = strokedText( + label, font: NSFont.systemFont(ofSize: 14.5, weight: .semibold), fill: .white) + + let s = NSStackView(views: [keyL, labL]) + s.orientation = .horizontal + s.spacing = 5 + s.alignment = .firstBaseline + s.translatesAutoresizingMaskIntoConstraints = false + addSubview(s) + NSLayoutConstraint.activate([ + s.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 11), + s.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -11), + s.topAnchor.constraint(equalTo: topAnchor, constant: 5), + s.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), + ]) + } + required init?(coder: NSCoder) { fatalError() } +} + +// MARK: - Overlay Panel (status HUD) final class OverlayPanel: NSPanel { - private let label = NSTextField(labelWithString: "") - private let dot = NSTextField(labelWithString: "🔴") + private let vfx = NSVisualEffectView() + private let markView = NSImageView() + private let dotLayer = CAShapeLayer() + private let titleField = NSTextField(labelWithString: "") + private let subtitleField = NSTextField(labelWithString: "") + private let chips = NSStackView() + private let textStack = NSStackView() + private let hStack = NSStackView() + private var pulseTimer: Timer? + private var hideTimer: Timer? + private let hInset: CGFloat = 30, vInset: CGFloat = 22 + private let radius: CGFloat = 24 - override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, - backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { - super.init(contentRect: NSRect(x: 0, y: 0, width: 340, height: 64), + init() { + super.init(contentRect: NSRect(x: 0, y: 0, width: 360, height: 60), styleMask: [.nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false) isFloatingPanel = true - level = .floating - backgroundColor = NSColor(red: 0.082, green: 0.082, blue: 0.137, alpha: 0.96) + level = NSWindow.Level(rawValue: 25) // screen-saver level — beats fullscreen isOpaque = false + backgroundColor = .clear hasShadow = true - isMovableByWindowBackground = true - collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + ignoresMouseEvents = true + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + alphaValue = 0 + setupUI() + } + + private func setupUI() { + vfx.material = .hudWindow + vfx.blendingMode = .behindWindow + vfx.state = .active + // Keep the glass TRANSPARENT (no forced dark) — readability comes from + // the stroked text outlines, not from darkening the panel. + vfx.wantsLayer = true + vfx.layer?.cornerRadius = radius + vfx.layer?.masksToBounds = true + vfx.layer?.borderWidth = 1.5 + vfx.layer?.borderColor = Brand.orange.withAlphaComponent(0.6).cgColor + // maskImage clips the live BLUR + the window shadow to the rounded shape — + // without it the square window corners show an opaque/bright fill. + vfx.maskImage = OverlayPanel.roundedMask(radius: radius) + contentView = vfx - // Orange border via container view - let container = NSView(frame: contentView!.bounds) - container.wantsLayer = true - container.layer?.cornerRadius = 12 - container.layer?.borderWidth = 2 - container.layer?.borderColor = NSColor(red: 0.910, green: 0.443, blue: 0.102, alpha: 1).cgColor - container.layer?.masksToBounds = true - contentView?.addSubview(container) - contentView?.layer?.cornerRadius = 12 - contentView?.layer?.masksToBounds = true - - // Layout - dot.font = NSFont.systemFont(ofSize: 18) - dot.frame = NSRect(x: 16, y: 20, width: 28, height: 28) - container.addSubview(dot) - - label.font = NSFont.systemFont(ofSize: 15, weight: .semibold) - label.textColor = NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1) - label.frame = NSRect(x: 52, y: 22, width: 270, height: 24) - label.stringValue = "🔴 Listening — release to send" - container.addSubview(label) + // Dark tint OVER the blur. A fully-transparent glass leaves text sitting on + // whatever's behind the panel (unreadable on busy/light backgrounds). A + // dark-tinted glass keeps the blur but guarantees white + gold text reads — + // exactly like Spotlight / macOS notification HUDs. + let tint = NSView() + tint.wantsLayer = true + tint.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.66).cgColor + tint.translatesAutoresizingMaskIntoConstraints = false + vfx.addSubview(tint) + NSLayoutConstraint.activate([ + tint.leadingAnchor.constraint(equalTo: vfx.leadingAnchor), + tint.trailingAnchor.constraint(equalTo: vfx.trailingAnchor), + tint.topAnchor.constraint(equalTo: vfx.topAnchor), + tint.bottomAnchor.constraint(equalTo: vfx.bottomAnchor), + ]) + + // CODEC mark (52pt) with a pulsing dot badge layer + markView.image = markImage + markView.imageScaling = .scaleProportionallyUpOrDown + markView.contentTintColor = Brand.orange + markView.wantsLayer = true + markView.translatesAutoresizingMaskIntoConstraints = false + markView.widthAnchor.constraint(equalToConstant: 84).isActive = true + markView.heightAnchor.constraint(equalToConstant: 84).isActive = true + + dotLayer.fillColor = Brand.red.cgColor + dotLayer.path = CGPath(ellipseIn: CGRect(x: 61, y: 58, width: 20, height: 20), transform: nil) + dotLayer.isHidden = true + markView.layer?.addSublayer(dotLayer) + + titleField.alignment = .center + titleField.maximumNumberOfLines = 1 + titleField.lineBreakMode = .byTruncatingTail + + subtitleField.font = NSFont.systemFont(ofSize: 17, weight: .regular) + subtitleField.textColor = Brand.textMuted + subtitleField.alignment = .center + subtitleField.maximumNumberOfLines = 1 + + chips.orientation = .horizontal + chips.spacing = 9 + chips.alignment = .centerY + + textStack.orientation = .vertical + textStack.spacing = 8 + textStack.alignment = .centerX + textStack.setViews([titleField], in: .center) + textStack.translatesAutoresizingMaskIntoConstraints = false + + vfx.addSubview(markView) + vfx.addSubview(textStack) + NSLayoutConstraint.activate([ + // CODEC mark: always pinned left, vertically centered + markView.leadingAnchor.constraint(equalTo: vfx.leadingAnchor, constant: 26), + markView.centerYAnchor.constraint(equalTo: vfx.centerYAnchor), + // Title/content: perfectly centered in the FULL pill (clears the mark via >=) + textStack.centerXAnchor.constraint(equalTo: vfx.centerXAnchor), + textStack.centerYAnchor.constraint(equalTo: vfx.centerYAnchor), + textStack.leadingAnchor.constraint(greaterThanOrEqualTo: markView.trailingAnchor, constant: 14), + textStack.trailingAnchor.constraint(lessThanOrEqualTo: vfx.trailingAnchor, constant: -22), + ]) + } + + // Rounded-rect mask (resizable via capInsets) — clips the vibrancy + shadow. + static func roundedMask(radius: CGFloat) -> NSImage { + let d = radius * 2 + 2 + let img = NSImage(size: NSSize(width: d, height: d), flipped: false) { rect in + NSColor.black.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + img.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + img.resizingMode = .stretch + return img } - func show(text: String = "🔴 Listening — release to send") { - label.stringValue = text - // Centre-bottom of main screen + // White title needs a soft dark halo to read over translucent glass. + static func styledTitle(_ s: String, wordmark: Bool, color: NSColor) -> NSAttributedString { + // Gold/accent fill + dark outline ("line around the orange"), centered. + return strokedText(wordmark ? s.uppercased() : s, + font: NSFont.systemFont(ofSize: 27, weight: .bold), + fill: color, kern: wordmark ? 6.0 : 0, center: true) + } + + // Parse "F18=voice F16=text **=screen ++=doc --=chat" → chip views + private func buildChips(_ shortcuts: String, accent: NSColor) { + chips.arrangedSubviews.forEach { $0.removeFromSuperview() } + let tokens = shortcuts.split(whereSeparator: { $0 == " " }).map(String.init).filter { !$0.isEmpty } + for tok in tokens { + let parts = tok.split(separator: "=", maxSplits: 1).map(String.init) + let key = parts.first ?? tok + let label = parts.count > 1 ? parts[1] : "" + chips.addArrangedSubview(ChipView(key: key, label: label, accent: accent)) + } + } + + func configure(state: OverlayState, title: String, subtitle: String = "", + shortcuts: String = "", duration: Double = 0) { + let accent = state.accent + + // Mark + ring + glow + markView.contentTintColor = accent + vfx.layer?.borderColor = accent.withAlphaComponent(0.55).cgColor + vfx.layer?.shadowColor = accent.cgColor + vfx.layer?.shadowOpacity = 0.0 // glow handled by panel shadow; keep subtle + + // Title — centered, accent-colored (orange / red / blue — never blank white), soft halo for legibility + titleField.attributedStringValue = OverlayPanel.styledTitle(title, wordmark: state.wordmark, color: state.accent) + + // Subtitle row: chips (toggle-on) OR a muted subtitle line OR nothing + textStack.arrangedSubviews.filter { $0 != titleField }.forEach { $0.removeFromSuperview() } + if !shortcuts.isEmpty { + buildChips(shortcuts, accent: accent) + textStack.addArrangedSubview(chips) + } else if !subtitle.isEmpty { + subtitleField.stringValue = subtitle + textStack.addArrangedSubview(subtitleField) + } + + // Pulse / breathe animations + stopAnimations() + dotLayer.isHidden = !state.pulses + if state.pulses { dotLayer.fillColor = accent.cgColor; startPulse() } + if state.breathes { startBreathe() } + + // Fixed, uniform pill size for EVERY state, positioned bottom-center + let w: CGFloat = 700, h: CGFloat = 140 + setContentSize(NSSize(width: w, height: h)) + vfx.layoutSubtreeIfNeeded() if let screen = NSScreen.main { let sr = screen.visibleFrame - let x = sr.midX - frame.width / 2 - let y = sr.minY + 80 - setFrameOrigin(NSPoint(x: x, y: y)) + setFrameOrigin(NSPoint(x: sr.midX - w / 2, y: sr.minY + 80)) + } + + showFade() + hideTimer?.invalidate() + if duration > 0 { + hideTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in + self?.hide() + } } - makeKeyAndOrderFront(nil) - startPulse() } func hide() { - stopPulse() - orderOut(nil) + hideTimer?.invalidate() + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.16 + animator().alphaValue = 0 + }, completionHandler: { [weak self] in + self?.orderOut(nil) + self?.stopAnimations() + }) } - func update(text: String) { - label.stringValue = text + private func showFade() { + orderFrontRegardless() + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + animator().alphaValue = 1 + } } private func startPulse() { - stopPulse() - var visible = true - pulseTimer = Timer.scheduledTimer(withTimeInterval: 0.6, repeats: true) { [weak self] _ in - visible.toggle() - self?.dot.alphaValue = visible ? 1.0 : 0.2 + var on = true + pulseTimer = Timer.scheduledTimer(withTimeInterval: 0.55, repeats: true) { [weak self] _ in + on.toggle() + self?.dotLayer.opacity = on ? 1.0 : 0.25 + } + } + private func startBreathe() { + var on = true + pulseTimer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: true) { [weak self] _ in + on.toggle() + NSAnimationContext.runAnimationGroup { c in + c.duration = 0.7 + self?.markView.animator().alphaValue = on ? 1.0 : 0.45 + } } } + private func stopAnimations() { + pulseTimer?.invalidate(); pulseTimer = nil + dotLayer.opacity = 1.0 + markView.alphaValue = 1.0 + } +} + +// MARK: - Input Panel (F16 branded "Enter task" box — focusable glass) +final class InputPanel: NSPanel { + private let vfx = NSVisualEffectView() + private let field = NSTextField() + private let sendBtn = NSButton() + private var replyPath = "" + private let radius: CGFloat = 22 - private func stopPulse() { - pulseTimer?.invalidate() - pulseTimer = nil - dot.alphaValue = 1.0 + init() { + super.init(contentRect: NSRect(x: 0, y: 0, width: 640, height: 96), + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, defer: false) + level = NSWindow.Level(rawValue: 25) + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + isOpaque = false + backgroundColor = .clear + appearance = NSAppearance(named: .darkAqua) + hasShadow = true + isMovableByWindowBackground = true + setupUI() + } + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + private func setupUI() { + vfx.material = .hudWindow + vfx.blendingMode = .behindWindow + vfx.state = .active + vfx.appearance = NSAppearance(named: .darkAqua) + vfx.wantsLayer = true + vfx.layer?.cornerRadius = radius + vfx.layer?.masksToBounds = true + vfx.layer?.borderWidth = 1.5 + vfx.layer?.borderColor = Brand.orange.withAlphaComponent(0.65).cgColor + vfx.maskImage = OverlayPanel.roundedMask(radius: radius) + contentView = vfx + + let mark = NSImageView() + mark.image = markImage + mark.contentTintColor = Brand.orange + mark.imageScaling = .scaleProportionallyUpOrDown + mark.translatesAutoresizingMaskIntoConstraints = false + mark.widthAnchor.constraint(equalToConstant: 44).isActive = true + mark.heightAnchor.constraint(equalToConstant: 44).isActive = true + + field.placeholderString = "What can I do for you?" + field.font = NSFont.systemFont(ofSize: 20, weight: .regular) + field.textColor = Brand.textPrimary + field.isBezeled = false + field.drawsBackground = false + field.focusRingType = .none + field.translatesAutoresizingMaskIntoConstraints = false + field.target = self + field.action = #selector(submit) // Enter submits + + sendBtn.title = "Send" + sendBtn.bezelStyle = .rounded + sendBtn.controlSize = .large + sendBtn.contentTintColor = Brand.orange + sendBtn.translatesAutoresizingMaskIntoConstraints = false + sendBtn.target = self + sendBtn.action = #selector(submit) + + let stack = NSStackView(views: [mark, field, sendBtn]) + stack.orientation = .horizontal + stack.spacing = 16 + stack.alignment = .centerY + stack.translatesAutoresizingMaskIntoConstraints = false + vfx.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: vfx.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: vfx.trailingAnchor, constant: -22), + stack.centerYAnchor.constraint(equalTo: vfx.centerYAnchor), + ]) + field.setContentHuggingPriority(.defaultLow, for: .horizontal) // field grows + } + + func present(id: String, promptText: String) { + let home = FileManager.default.homeDirectoryForCurrentUser.path + replyPath = home + "/.codec/overlay_input_\(id).json" + // Ack immediately so codec_core knows the panel actually appeared + // (else it fast-falls-back to the native osascript dialog). + try? "1".write(toFile: home + "/.codec/overlay_input_\(id).ack", + atomically: true, encoding: .utf8) + if !promptText.isEmpty { field.placeholderString = promptText } + field.stringValue = "" + if let screen = NSScreen.main { + let sr = screen.visibleFrame + setFrameOrigin(NSPoint(x: sr.midX - frame.width / 2, y: sr.midY - frame.height / 2 + 140)) + } + NSApp.setActivationPolicy(.regular) // accessory apps need this to take keyboard focus + NSApp.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil) + field.becomeFirstResponder() + if let editor = field.currentEditor() as? NSTextView { + editor.insertionPointColor = Brand.orange + } + } + + @objc private func submit() { finish(field.stringValue) } + override func cancelOperation(_ sender: Any?) { finish("") } // Esc + + private func finish(_ text: String) { + if !replyPath.isEmpty { + let data = try? JSONSerialization.data(withJSONObject: ["text": text]) + try? data?.write(to: URL(fileURLWithPath: replyPath)) + replyPath = "" + } + orderOut(nil) + NSApp.setActivationPolicy(.accessory) } } @@ -129,6 +462,7 @@ final class OverlayPanel: NSPanel { final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private var overlay: OverlayPanel! + private var inputPanel: InputPanel! private let poller = EventPoller() private var lastSkill = "none" private var isOn = true @@ -136,8 +470,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - - overlay = OverlayPanel(contentRect: .zero, styleMask: [], backing: .buffered, defer: false) + overlay = OverlayPanel() + inputPanel = InputPanel() statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let btn = statusItem.button { @@ -151,104 +485,150 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func buildMenu() { let menu = NSMenu() - let header = NSMenuItem(title: "⚡ CODEC", action: nil, keyEquivalent: "") header.isEnabled = false menu.addItem(header) menu.addItem(.separator()) - - let statusTitle = isOn ? "● Status: ON" : "● Status: OFF" - let statusItem = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") - statusItem.isEnabled = false - menu.addItem(statusItem) - - let skillItem = NSMenuItem(title: "Last: \(lastSkill)", action: nil, keyEquivalent: "") - skillItem.isEnabled = false - menu.addItem(skillItem) + let st = NSMenuItem(title: isOn ? "● Status: ON" : "● Status: OFF", action: nil, keyEquivalent: "") + st.isEnabled = false + menu.addItem(st) + let sk = NSMenuItem(title: "Last: \(lastSkill)", action: nil, keyEquivalent: "") + sk.isEnabled = false + menu.addItem(sk) menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "🌐 Open Dashboard", action: #selector(openDashboard), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "💬 Open Chat", action: #selector(openChat), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "🎨 Open Vibe", action: #selector(openVibe), keyEquivalent: "")) menu.addItem(.separator()) - if !recentSkills.isEmpty { - let recHeader = NSMenuItem(title: "Recent Skills:", action: nil, keyEquivalent: "") - recHeader.isEnabled = false - menu.addItem(recHeader) + let rh = NSMenuItem(title: "Recent Skills:", action: nil, keyEquivalent: "") + rh.isEnabled = false + menu.addItem(rh) for skill in recentSkills.suffix(5) { - let item = NSMenuItem(title: " \(skill)", action: nil, keyEquivalent: "") - item.isEnabled = false - menu.addItem(item) + let it = NSMenuItem(title: " \(skill)", action: nil, keyEquivalent: "") + it.isEnabled = false + menu.addItem(it) } menu.addItem(.separator()) } - menu.addItem(NSMenuItem(title: "Quit CODEC Overlay", action: #selector(quitApp), keyEquivalent: "q")) - self.statusItem.menu = menu } func handleEvent(type: String, json: [String: Any]) { DispatchQueue.main.async { [weak self] in - guard let self = self else { return } + guard let self = self else { return } switch type { case "recording_start": - overlay.show(text: "🔴 Listening — release to send") - case "recording_stop": - overlay.hide() + self.overlay.configure(state: .recording, + title: (json["title"] as? String) ?? "Listening", + subtitle: (json["subtitle"] as? String) ?? "release to send") case "ptt_locked": - overlay.show(text: "🔴 REC LOCKED — tap F18 to stop") + self.overlay.configure(state: .recording, title: "REC LOCKED", + subtitle: "tap F18 to stop") + case "recording_stop", "live_stop", "hide": + self.overlay.hide() case "transcribing": - overlay.update(text: "⚙️ Transcribing...") - case "skill_fired": - let name = (json["name"] as? String) ?? "unknown" - lastSkill = name - recentSkills.append(name) - if recentSkills.count > 10 { recentSkills.removeFirst() } - buildMenu() - overlay.update(text: "✅ \(name)") - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.overlay.hide() - } + self.overlay.configure(state: .processing, + title: (json["text"] as? String) ?? "Transcribing…", + duration: (json["duration"] as? Double) ?? 0) + case "refining": + self.overlay.configure(state: .refining, title: "Refining…") + case "live": + self.overlay.configure(state: .live, title: "LIVE", + subtitle: "press F5 to stop") case "toggle_on": - isOn = true - buildMenu() + self.isOn = true; self.buildMenu() if let btn = self.statusItem.button { btn.image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "CODEC") btn.image?.isTemplate = true } + self.overlay.configure(state: .toggleOn, title: "CODEC", + shortcuts: (json["shortcuts"] as? String) ?? "", + duration: (json["duration"] as? Double) ?? 2.6) case "toggle_off": - isOn = false - buildMenu() + self.isOn = false; self.buildMenu() if let btn = self.statusItem.button { btn.image = NSImage(systemSymbolName: "bolt.slash.fill", accessibilityDescription: "CODEC") btn.image?.isTemplate = true } + self.overlay.configure(state: .toggleOff, title: "SIGNING OUT", + duration: (json["duration"] as? Double) ?? 1.8) + case "skill_fired": + let name = (json["name"] as? String) ?? "unknown" + self.lastSkill = name + self.recentSkills.append(name) + if self.recentSkills.count > 10 { self.recentSkills.removeFirst() } + self.buildMenu() + self.overlay.configure(state: .notify(Brand.orange), title: name, + subtitle: "skill", duration: (json["duration"] as? Double) ?? 2.0) case "notify": let text = (json["text"] as? String) ?? "CODEC" - let duration = (json["duration"] as? Double) ?? 2.5 - overlay.show(text: text) - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in - self?.overlay.hide() - } + let dur = (json["duration"] as? Double) ?? 2.5 + let accent = (json["color"] as? String).map { Brand.from(hex: $0) } ?? Brand.orange + self.overlay.configure(state: .notify(accent), title: text, duration: dur) + case "input_request": + let id = (json["id"] as? String) ?? "default" + let promptText = (json["prompt"] as? String) ?? "" + self.inputPanel.present(id: id, promptText: promptText) default: break } } } - @objc private func openDashboard() { - NSWorkspace.shared.open(URL(string: "http://localhost:8090")!) - } - @objc private func openChat() { - NSWorkspace.shared.open(URL(string: "http://localhost:8090/chat")!) - } - @objc private func openVibe() { - NSWorkspace.shared.open(URL(string: "http://localhost:8090/vibe")!) + @objc private func openDashboard() { NSWorkspace.shared.open(URL(string: "http://localhost:8090")!) } + @objc private func openChat() { NSWorkspace.shared.open(URL(string: "http://localhost:8090/chat")!) } + @objc private func openVibe() { NSWorkspace.shared.open(URL(string: "http://localhost:8090/vibe")!) } + @objc private func quitApp() { NSApplication.shared.terminate(nil) } +} + +// MARK: - Event Poller (reads ~/.codec/overlay_events.jsonl) +final class EventPoller: NSObject { + private var timer: Timer? + private var lastOffset: Int = 0 + weak var appDelegate: AppDelegate? + private let eventFile: String = { + FileManager.default.homeDirectoryForCurrentUser.path + "/.codec/overlay_events.jsonl" + }() + + func start() { + let dir = (eventFile as NSString).deletingLastPathComponent + if !FileManager.default.fileExists(atPath: dir) { + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + if !FileManager.default.fileExists(atPath: eventFile) { + FileManager.default.createFile(atPath: eventFile, contents: nil, + attributes: [.posixPermissions: 0o600]) + } + // Start from the current end so we don't replay history on launch + if let data = FileManager.default.contents(atPath: eventFile), + let text = String(data: data, encoding: .utf8) { + lastOffset = text.components(separatedBy: "\n").filter { !$0.isEmpty }.count + } + timer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in + self?.poll() + } } - @objc private func quitApp() { - NSApplication.shared.terminate(nil) + + private func poll() { + guard let data = FileManager.default.contents(atPath: eventFile), + let text = String(data: data, encoding: .utf8) else { return } + let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty } + guard lines.count > lastOffset else { + if lines.count < lastOffset { lastOffset = lines.count } // file was rotated/truncated + return + } + let newLines = lines[lastOffset...] + lastOffset = lines.count + for line in newLines { + guard let d = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any], + let type = json["type"] as? String else { continue } + DispatchQueue.main.async { [weak self] in + self?.appDelegate?.handleEvent(type: type, json: json) + } + } } } diff --git a/tests/test_overlays.py b/tests/test_overlays.py new file mode 100644 index 0000000..ba507fd --- /dev/null +++ b/tests/test_overlays.py @@ -0,0 +1,86 @@ +"""Tests for codec_overlays Swift-channel routing. + +Redesign (2026-06): the public overlay functions emit JSON events to the +running Swift CODECOverlay NSPanel (~/.codec/overlay_events.jsonl) instead of +spawning tkinter, with tkinter kept as an automatic fallback when the Swift +renderer isn't running. +""" +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import codec_overlays # noqa: E402 + + +def _events(path): + with open(path) as f: + return [json.loads(line) for line in f if line.strip()] + + +def _swifted(monkeypatch, tmp_path): + """Force the Swift-alive branch + a temp events file; silence the sound.""" + ev = tmp_path / "overlay_events.jsonl" + monkeypatch.setattr(codec_overlays, "_EVENTS", str(ev)) + monkeypatch.setattr(codec_overlays, "_swift_alive", lambda: True) + monkeypatch.setattr(codec_overlays, "_play_sound", lambda *_a, **_k: None) + return str(ev) + + +def test_toggle_on_emits_toggle_on_with_shortcuts(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_toggle_overlay(True, "F18=voice F16=text") + last = _events(ev)[-1] + assert last["type"] == "toggle_on" + assert last["shortcuts"] == "F18=voice F16=text" + + +def test_toggle_off_emits_toggle_off(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_toggle_overlay(False) + assert _events(ev)[-1]["type"] == "toggle_off" + + +def test_recording_emits_recording_start_with_key(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_recording_overlay("F18") + last = _events(ev)[-1] + assert last["type"] == "recording_start" + assert "F18" in last["subtitle"] + + +def test_processing_emits_transcribing(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_processing_overlay("Transcribing...") + last = _events(ev)[-1] + assert last["type"] == "transcribing" + assert last["text"] == "Transcribing..." + + +def test_notify_emits_notify_with_color_and_seconds(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_overlay("hello", "#00aaff", 3000) + last = _events(ev)[-1] + assert last["type"] == "notify" + assert last["text"] == "hello" + assert last["color"] == "#00aaff" + assert abs(last["duration"] - 3.0) < 0.001 # ms -> seconds + + +def test_recording_stop_emits_recording_stop(tmp_path, monkeypatch): + ev = _swifted(monkeypatch, tmp_path) + codec_overlays.show_recording_stop() + assert _events(ev)[-1]["type"] == "recording_stop" + + +def test_falls_back_to_tkinter_when_swift_down(tmp_path, monkeypatch): + ev = tmp_path / "overlay_events.jsonl" + monkeypatch.setattr(codec_overlays, "_EVENTS", str(ev)) + monkeypatch.setattr(codec_overlays, "_swift_alive", lambda: False) + called = {"tk": False} + monkeypatch.setattr(codec_overlays, "_tk_overlay", + lambda *a, **k: called.__setitem__("tk", True)) + codec_overlays.show_overlay("hi") + assert called["tk"] is True + assert not os.path.exists(str(ev)) # nothing emitted to the Swift channel diff --git a/tools/overlay_preview.py b/tools/overlay_preview.py new file mode 100644 index 0000000..5be670d --- /dev/null +++ b/tools/overlay_preview.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Throwaway preview: fire each overlay state at the running Swift CODECOverlay +NSPanel by appending events to ~/.codec/overlay_events.jsonl. Run while +`pm2 status codec-overlay` is online to watch the redesigned HUD live. + + python3 tools/overlay_preview.py +""" +import json +import os +import sys +import time + +EVENTS = os.path.expanduser("~/.codec/overlay_events.jsonl") +HOLD = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0 # seconds each state stays + + +def fire(ev, label, hold=HOLD): + # For persistent states (recording/live/refining/transcribing) re-fire with a + # duration so they hold for `hold` seconds then auto-clear between samples. + with open(EVENTS, "a") as f: + f.write(json.dumps(ev) + "\n") + print(f" → [{label}]") + time.sleep(hold) + # clear between samples so each is seen in isolation + with open(EVENTS, "a") as f: + f.write(json.dumps({"type": "hide"}) + "\n") + time.sleep(0.8) + + +seq = [ + ("1 · NOTIFY (orange glass pill)", + {"type": "notify", "text": "New CODEC overlay", "color": "#E8711A", "duration": HOLD}), + ("2 · TOGGLE ON — CODEC wordmark + shortcut chips", + {"type": "toggle_on", "duration": HOLD, + "shortcuts": "F18=voice F16=text **=screen ++=doc --=chat"}), + ("3 · RECORDING — orange mark + pulsing red dot", + {"type": "recording_start", "title": "Listening"}), + ("4 · TRANSCRIBING — blue, breathing mark", + {"type": "transcribing", "text": "Transcribing…"}), + ("5 · LIVE dictate — red mark", + {"type": "live"}), + ("6 · REFINING dictate — blue", + {"type": "refining"}), + ("7 · SKILL FIRED — orange", + {"type": "skill_fired", "name": "philips_hue", "duration": HOLD}), + ("8 · ANALYZING SCREEN — blue notify", + {"type": "notify", "text": "Analyzing your screen…", "color": "#0A84FF", "duration": HOLD}), + ("9 · SIGNING OUT — red wordmark", + {"type": "toggle_off", "duration": HOLD}), +] + +print(f"CODEC overlay preview ({HOLD:.0f}s each) — watch bottom-center of your screen:") +for label, ev in seq: + fire(ev, label) +print("done.")