Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 88 additions & 8 deletions Loom.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Loom/App/DockController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ public final class DockController: NSObject, NSApplicationDelegate {
initialProviderID: providerID
)
let minContentSize = NSSize(width: 640, height: 460)
let defaultContentSize = NSSize(width: 660, height: 710)
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: NSSize(width: 660, height: 560)),
contentRect: NSRect(origin: .zero, size: defaultContentSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
Expand Down
1 change: 1 addition & 0 deletions Loom/App/LoomApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ struct LoomApp: App {
fixture: fixture,
enabledProviderIDs: initialSettings.enabledProviderIDs
)
initialStore.settings = initialSettings
initialStore.startPeriodicRefresh(interval: TimeInterval(initialSettings.refreshIntervalSeconds))
initialSettings.onRefreshIntervalChanged = { [weak initialStore] interval in
initialStore?.startPeriodicRefresh(interval: TimeInterval(interval))
Expand Down
26 changes: 20 additions & 6 deletions Loom/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@ public struct MenuBarStatusResult: Equatable, Sendable {
public let text: String?
public let hasAttention: Bool
public let activeCount: Int
public let attentionCount: Int
public let lowestQuotaPercent: Int?

public init(
text: String? = nil,
hasAttention: Bool = false,
activeCount: Int = 0,
attentionCount: Int = 0,
lowestQuotaPercent: Int? = nil
) {
self.text = text
self.hasAttention = hasAttention
self.activeCount = activeCount
self.attentionCount = attentionCount
self.lowestQuotaPercent = lowestQuotaPercent
}
}
Expand All @@ -50,9 +53,9 @@ public enum MenuBarStatusEvaluator {
}.count

// 2. Restrained attention indicator for confirmed sessions needing input or failed
let hasAttention = allSessions.contains { session in
let attentionCount = allSessions.filter { session in
session.state == .waiting || session.state == .failed
}
}.count

// 3. Lowest quota percentage:
// Must be non-stale, non-errored, and supported.
Expand Down Expand Up @@ -82,14 +85,17 @@ public enum MenuBarStatusEvaluator {
text = nil
case .activeSessionCount:
text = activeCount > 0 ? "\(activeCount)" : nil
case .needsAttentionCount:
text = attentionCount > 0 ? "\(attentionCount)" : nil
case .lowestQuota:
text = lowestQuotaPercent.map { "\($0)%" }
}

return MenuBarStatusResult(
text: text,
hasAttention: hasAttention,
hasAttention: attentionCount > 0,
activeCount: activeCount,
attentionCount: attentionCount,
lowestQuotaPercent: lowestQuotaPercent
)
}
Expand Down Expand Up @@ -125,7 +131,7 @@ public final class StatusItemController {
let title = currentStatus.hasAttention ? "\(text) •" : text
button.title = " \(title)"
} else if currentStatus.hasAttention {
button.title = " •"
button.title = " \(currentStatus.attentionCount) •"
} else {
button.title = ""
}
Expand All @@ -137,9 +143,11 @@ public final class StatusItemController {
var parts: [String] = ["Loom"]
if let text = status.text {
parts.append(text)
} else if status.hasAttention {
parts.append("\(status.attentionCount) waiting")
}
if status.hasAttention {
parts.append("Confirmed session requires attention")
parts.append("\(status.attentionCount) \(status.attentionCount == 1 ? "session needs" : "sessions need") attention")
}
return parts.joined(separator: ", ")
}
Expand Down Expand Up @@ -169,6 +177,10 @@ public struct MenuBarStatusLabel: View {
if let text = result.text {
Text(text)
.font(.system(size: 11, weight: .semibold, design: .rounded))
} else if result.hasAttention {
Text("\(result.attentionCount)")
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(.orange)
}

if result.hasAttention {
Expand All @@ -185,9 +197,11 @@ public struct MenuBarStatusLabel: View {
var parts: [String] = ["Loom"]
if let text = result.text {
parts.append(text)
} else if result.hasAttention {
parts.append("\(result.attentionCount) waiting")
}
if result.hasAttention {
parts.append("Session needs attention")
parts.append("\(result.attentionCount) \(result.attentionCount == 1 ? "session needs" : "sessions need") attention")
}
return parts.joined(separator: ", ")
}
Expand Down
209 changes: 209 additions & 0 deletions Loom/Core/Discovery/BoundedJSONLReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//
// BoundedJSONLReader.swift
// Loom
//
// Copyright © 2026 Deepak Sharma (ChaiWithCode).
// Licensed under the MIT License. See LICENSE for details.
//

import Darwin
import Foundation

/// Result of a bounded head + tail read on an append-only JSONL transcript.
public struct JSONLSnapshot: Sendable {
/// Complete JSON lines from the start of the file (typically the first 4 KB).
public let headLines: [Data]
/// Complete JSON lines from the end of the file (typically the last 64 KB).
public let tailLines: [Data]
/// Total file size in bytes at the time of reading.
public let fileSize: UInt64
/// Last modification timestamp from the filesystem.
public let modifiedAt: Date

public init(
headLines: [Data],
tailLines: [Data],
fileSize: UInt64,
modifiedAt: Date
) {
self.headLines = headLines
self.tailLines = tailLines
self.fileSize = fileSize
self.modifiedAt = modifiedAt
}
}

public struct BoundedJSONLReader: Sendable {
public let headByteLimit: Int
public let tailByteLimit: Int

public init(headByteLimit: Int = 4 * 1024, tailByteLimit: Int = 64 * 1024) {
self.headByteLimit = headByteLimit
self.tailByteLimit = tailByteLimit
}

/// Reads the head (first 4 KB) and tail (last 64 KB) of a JSONL file without loading the entire file into memory.
public func readSnapshot(at url: URL) -> JSONLSnapshot? {
let path = url.path

// 1. Open via POSIX with O_RDONLY | O_NONBLOCK | O_CLOEXEC so we never block CLI writers
let fd = Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
guard fd >= 0 else { return nil }

// fileHandle takes ownership and closes fd when close() is called
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
defer {
try? handle.close()
}

// 2. Stat the file descriptor directly for size and mtime
var statBuf = Darwin.stat()
guard fstat(fd, &statBuf) == 0 else { return nil }

let fileSize = UInt64(max(0, statBuf.st_size))
let modifiedAt = Date(
timeIntervalSince1970: TimeInterval(statBuf.st_mtimespec.tv_sec)
+ TimeInterval(statBuf.st_mtimespec.tv_nsec) / 1_000_000_000
)

guard fileSize > 0 else {
return JSONLSnapshot(headLines: [], tailLines: [], fileSize: 0, modifiedAt: modifiedAt)
}

do {
// 3. If the entire file fits within tailByteLimit, read it in a single pass
if fileSize <= UInt64(tailByteLimit) {
try handle.seek(toOffset: 0)
let allData = try handle.readToEnd() ?? Data()
let lines = Self.extractCompleteLines(
from: allData,
dropLeadingPartialLine: false,
dropTrailingIncompleteLine: true
)
return JSONLSnapshot(
headLines: Array(lines.prefix(5)),
tailLines: lines,
fileSize: fileSize,
modifiedAt: modifiedAt
)
}

// 4. Large File Path: Read Head (First 4 KB) for session metadata
try handle.seek(toOffset: 0)
let headData = try handle.read(upToCount: headByteLimit) ?? Data()
let headLines = Self.extractCompleteLines(
from: headData,
dropLeadingPartialLine: false,
dropTrailingIncompleteLine: true
)

// 5. Seek to Tail (Last 64 KB)
let tailOffset = fileSize - UInt64(tailByteLimit)
try handle.seek(toOffset: tailOffset)
let tailData = try handle.read(upToCount: tailByteLimit) ?? Data()

// Because tailOffset > 0, the first bytes up to the first '\n' are almost certainly
// a truncated suffix of an earlier JSON line. Always drop the leading fragment.
let tailLines = Self.extractCompleteLines(
from: tailData,
dropLeadingPartialLine: true,
dropTrailingIncompleteLine: false
)

return JSONLSnapshot(
headLines: headLines,
tailLines: tailLines,
fileSize: fileSize,
modifiedAt: modifiedAt
)
} catch {
return nil
}
}

// MARK: - Byte-Level Line Splitting

/// Splits raw UTF-8 JSONL data on `0x0A` (`\n`), safely trimming partial boundary lines.
public static func extractCompleteLines(
from data: Data,
dropLeadingPartialLine: Bool,
dropTrailingIncompleteLine: Bool
) -> [Data] {
guard !data.isEmpty else { return [] }

let newline: UInt8 = 0x0A // '\n'
let carriageReturn: UInt8 = 0x0D // '\r'

var workingSlice = data[...]

// 1. If we sought into the middle of a file, discard everything up to and including the first '\n'
if dropLeadingPartialLine {
guard let firstNewlineIdx = workingSlice.firstIndex(of: newline) else {
// Single giant line exceeding 64 KB; cannot safely parse
return []
}
let nextStart = workingSlice.index(after: firstNewlineIdx)
guard nextStart < workingSlice.endIndex else { return [] }
workingSlice = workingSlice[nextStart...]
}

// 2. If we read a fixed chunk from the start of a file (or if an agent is mid-write without a trailing '\n'),
// discard bytes after the last '\n' if requested or if the line doesn't end with '}'
if dropTrailingIncompleteLine, workingSlice.last != newline {
if let lastNewlineIdx = workingSlice.lastIndex(of: newline) {
workingSlice = workingSlice[...lastNewlineIdx]
} else {
return []
}
}

// 3. Split into non-empty lines and strip optional '\r'
var result: [Data] = []
result.reserveCapacity(64)

var lineStart = workingSlice.startIndex
var idx = workingSlice.startIndex

while idx < workingSlice.endIndex {
if workingSlice[idx] == newline {
var lineEnd = idx
if lineEnd > lineStart && workingSlice[workingSlice.index(before: lineEnd)] == carriageReturn {
lineEnd = workingSlice.index(before: lineEnd)
}
if lineEnd > lineStart {
let lineData = Data(workingSlice[lineStart..<lineEnd])
if isLikelyCompleteJSONObject(lineData) {
result.append(lineData)
}
}
lineStart = workingSlice.index(after: idx)
}
idx = workingSlice.index(after: idx)
}

// Handle final trailing line if the file didn't end with '\n' yet is a valid complete JSON object
if !dropTrailingIncompleteLine && lineStart < workingSlice.endIndex {
let remaining = Data(workingSlice[lineStart..<workingSlice.endIndex])
if isLikelyCompleteJSONObject(remaining) {
result.append(remaining)
}
}

return result
}

/// Fast O(1) boundary check ensuring the line starts with `{` and ends with `}` (ignoring ASCII whitespace)
/// so JSONDecoder never wastes CPU on half-flushed lines while an agent is mid-write.
private static func isLikelyCompleteJSONObject(_ data: Data) -> Bool {
guard data.count >= 2 else { return false }

let openBrace: UInt8 = 0x7B // '{'
let closeBrace: UInt8 = 0x7D // '}'

guard let firstNonWS = data.first(where: { $0 > 0x20 }),
let lastNonWS = data.last(where: { $0 > 0x20 }) else {
return false
}
return firstNonWS == openBrace && lastNonWS == closeBrace
}
}
Loading
Loading