Files
VisionPOC/Sources/App/LogStreamView.swift
james.maesandClaude Opus 4.7 0d23d7ede9 Add a live Log Stream window (⌘L) and emit events from every subsystem
Built a Jarvis-style scrolling log viewer that surfaces what each
subsystem is doing in real time. The buffer is a thread-safe ring of
the last 1,000 entries; the SwiftUI viewer polls at 10 Hz, only
re-renders when the buffer version bumps, and supports level / source
filters, pause, auto-scroll, and clear.

Components
- Sources/App/LogStream.swift — singleton with Level (DBG/INF/WRN/ERR)
  and Source (APP / CAM / DETECT / TRACK / FACE / RENDER / VPOC)
  taxonomies, an os_unfair_lock-guarded buffer, and stdout mirroring
  so `log stream --process VisionPOC` shows the same feed in Terminal.
- Sources/App/LogStreamView.swift — SwiftUI viewer with toolbar
  (level menu, source menu, auto-scroll, pause, clear, filtered/total
  counts) and a monospaced row-per-entry list that auto-scrolls to
  the bottom when new entries arrive.
- Sources/App/LogStreamWindowController.swift — separate dark-aqua
  floating window so the log doesn't compete with the main 4-pane
  HUD for real estate.
- AppDelegate gains a ⌘L "Log Stream…" menu item and a held reference
  to the controller (lazy-instantiated on first open).

Wiring
- AppDelegate: launch + ready
- CameraCapture: device selection, session start, permission denied,
  enumeration failures
- ObjectDetector: model load (success or fallback), 1 Hz detector
  stats (yolo vs track mode, ms, track count + label list), per-track
  add and per-track lose
- ObjectDetector face match: when a face is recognized via FaceRegistry
  the distance is logged at DBG level
- FaceRegistry: load on init, enroll (+prints, total), forget

Implementation notes
- The viewer is not @Observable-bound to LogStream so background-
  thread log emit calls don't pay a MainActor hop. Instead the view
  polls .version every 100ms and reloads on bump — bursts up to ~100
  entries per tick render fine and the buffer absorbs writes from any
  rate the os_unfair_lock can sustain (~10 M/s on M-series).
- LogStream.shared.log() can be called from any thread/queue. Stdout
  is also written from the same call so terminal tail works.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 15:23:46 -05:00

143 lines
5.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Observation
/// SwiftUI viewer for the LogStream ring buffer. Polls every 100ms; only
/// re-renders when the LogStream version bumps. The viewer is intentionally
/// not @Observable-bound to the LogStream so background-thread log calls
/// don't pay MainActor hop cost — the polling design tolerates burst writes
/// at any rate the buffer can absorb (1000 entries × ~16 µs each).
struct LogStreamView: View {
@State private var entries: [LogStream.Entry] = []
@State private var lastVersion: UInt64 = 0
@State private var levelFilter: Set<LogStream.Level> = Set(LogStream.Level.allCases)
@State private var sourceFilter: Set<LogStream.Source> = Set(LogStream.Source.allCases)
@State private var autoScroll: Bool = true
@State private var paused: Bool = false
private let pollTimer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
private static let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "HH:mm:ss.SSS"
return df
}()
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
logList
}
.frame(minWidth: 640, idealWidth: 820, minHeight: 360, idealHeight: 540)
.background(Color(white: 0.07))
.onReceive(pollTimer) { _ in
guard !paused else { return }
let v = LogStream.shared.version
guard v != lastVersion else { return }
entries = LogStream.shared.snapshot()
lastVersion = v
}
}
@ViewBuilder
private var toolbar: some View {
HStack(spacing: 12) {
Menu("Level") {
ForEach(LogStream.Level.allCases) { lvl in
Toggle(lvl.rawValue, isOn: Binding(
get: { levelFilter.contains(lvl) },
set: { isOn in
if isOn { levelFilter.insert(lvl) } else { levelFilter.remove(lvl) }
}
))
}
}
.menuStyle(.borderlessButton)
.fixedSize()
Menu("Source") {
ForEach(LogStream.Source.allCases) { src in
Toggle(src.rawValue, isOn: Binding(
get: { sourceFilter.contains(src) },
set: { isOn in
if isOn { sourceFilter.insert(src) } else { sourceFilter.remove(src) }
}
))
}
}
.menuStyle(.borderlessButton)
.fixedSize()
Toggle("Auto-scroll", isOn: $autoScroll)
.toggleStyle(.checkbox)
.controlSize(.small)
Toggle("Pause", isOn: $paused)
.toggleStyle(.checkbox)
.controlSize(.small)
Spacer()
Text("\(filteredCount) / \(entries.count)")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Button("Clear") { LogStream.shared.clear() }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(white: 0.10))
}
private var filteredEntries: [LogStream.Entry] {
entries.filter { levelFilter.contains($0.level) && sourceFilter.contains($0.source) }
}
private var filteredCount: Int { filteredEntries.count }
@ViewBuilder
private var logList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(filteredEntries) { entry in
HStack(alignment: .top, spacing: 8) {
Text(Self.timeFormatter.string(from: entry.timestamp))
.foregroundStyle(.secondary)
Text(entry.level.rawValue)
.foregroundStyle(color(for: entry.level))
.frame(width: 32, alignment: .leading)
Text(entry.source.rawValue)
.foregroundStyle(Color(red: 0.20, green: 0.85, blue: 0.95))
.frame(width: 60, alignment: .leading)
Text(entry.message)
.foregroundStyle(.primary)
Spacer(minLength: 0)
}
.font(.system(.caption, design: .monospaced))
.padding(.horizontal, 12)
.padding(.vertical, 2)
.id(entry.id)
}
}
}
.onChange(of: filteredEntries.last?.id) { _, newValue in
guard autoScroll, let last = newValue else { return }
withAnimation(.linear(duration: 0.06)) {
proxy.scrollTo(last, anchor: .bottom)
}
}
}
}
private func color(for level: LogStream.Level) -> Color {
switch level {
case .debug: return .gray
case .info: return Color(white: 0.85)
case .warn: return .yellow
case .error: return .red
}
}
}