diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 343ca8a..1e2ca5e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -123,17 +123,37 @@ struct SessionDetailView: View { set: { store.setSessionEffort(sessionID, $0) }) } + /// Memo for the context-occupancy scan below. The backward scan usually stops at the last + /// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole + /// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan + /// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as + /// in the projection cache; a class held in `@State` so updating it from `body` doesn't + /// itself invalidate the view. + private final class ContextScanCache { + var count = -1 + var lastSeq: UInt64 = 0 + var used: Int? + } + @State private var contextScanCache = ContextScanCache() + /// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read - /// from the transcript exactly as the Mac header does. + /// from the transcript exactly as the Mac header does. Only the token scan is memoized — + /// the window division stays live, so a model switch reflects immediately. private var contextPercent: Int? { - let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in - switch event.kind { - case .turnCompleted(let turn): return turn.usage?.contextInputTokens - case .usage(let usage): return usage.contextInputTokens - default: return nil - } - }.first { $0 > 0 } - guard let used else { return nil } + let events = store.openEvents + let cache = contextScanCache + if cache.count != events.count || cache.lastSeq != events.last?.seq ?? 0 { + cache.count = events.count + cache.lastSeq = events.last?.seq ?? 0 + cache.used = events.reversed().lazy.compactMap { event -> Int? in + switch event.kind { + case .turnCompleted(let turn): return turn.usage?.contextInputTokens + case .usage(let usage): return usage.contextInputTokens + default: return nil + } + }.first { $0 > 0 } + } + guard let used = cache.used else { return nil } let window = store.modelCatalog.contextWindow(summary?.model) guard window > 0 else { return nil } return min(100, Int((Double(used) / Double(window)) * 100))