chore(mac): apply swiftformat and lint fixes

main
Peter Steinberger 2025-12-09 04:42:32 +01:00
parent b9cc914729
commit 51aed3ca0a
32 changed files with 416 additions and 236 deletions

View File

@ -9,8 +9,8 @@ final class AgentEventStore: ObservableObject {
func append(_ event: ControlAgentEvent) { func append(_ event: ControlAgentEvent) {
self.events.append(event) self.events.append(event)
if self.events.count > maxEvents { if self.events.count > self.maxEvents {
self.events.removeFirst(self.events.count - maxEvents) self.events.removeFirst(self.events.count - self.maxEvents)
} }
} }

View File

@ -10,14 +10,14 @@ struct AgentEventsWindow: View {
Text("Agent Events") Text("Agent Events")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Spacer() Spacer()
Button("Clear") { store.clear() } Button("Clear") { self.store.clear() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding(.bottom, 4) .padding(.bottom, 4)
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
ForEach(store.events.reversed(), id: \.seq) { evt in ForEach(self.store.events.reversed(), id: \.seq) { evt in
EventRow(event: evt) EventRow(event: evt)
} }
} }
@ -34,14 +34,14 @@ private struct EventRow: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(event.stream.uppercased()) Text(self.event.stream.uppercased())
.font(.caption2.weight(.bold)) .font(.caption2.weight(.bold))
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(self.tint) .background(self.tint)
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
Text("run " + event.runId) Text("run " + self.event.runId)
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
@ -61,16 +61,15 @@ private struct EventRow: View {
.padding(8) .padding(8)
.background( .background(
RoundedRectangle(cornerRadius: 8, style: .continuous) RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(0.04)) .fill(Color.primary.opacity(0.04)))
)
} }
private var tint: Color { private var tint: Color {
switch event.stream { switch self.event.stream {
case "job": return .blue case "job": .blue
case "tool": return .orange case "tool": .orange
case "assistant": return .green case "assistant": .green
default: return .gray default: .gray
} }
} }

View File

@ -1,5 +1,5 @@
import Foundation
import Darwin import Darwin
import Foundation
import OSLog import OSLog
struct ControlRequestParams: @unchecked Sendable { struct ControlRequestParams: @unchecked Sendable {

View File

@ -182,7 +182,8 @@ final class AppState: ObservableObject {
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
} }
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey), if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride) { let selection = IconOverrideSelection(rawValue: storedOverride)
{
self.iconOverride = selection self.iconOverride = selection
} else { } else {
self.iconOverride = .system self.iconOverride = .system

View File

@ -108,7 +108,11 @@ struct ConfigSettings: View {
.frame(width: 100) .frame(width: 100)
.disabled(!self.webChatEnabled) .disabled(!self.webChatEnabled)
} }
Text("Mac app connects to the relays loopback web chat on this port. Remote mode uses SSH -L to forward it.") Text(
"""
Mac app connects to the relays loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading) .frame(maxWidth: 480, alignment: .leading)

View File

@ -113,8 +113,7 @@ struct CritterStatusLabel: View {
.padding(3) .padding(3)
.background( .background(
Circle() Circle()
.fill(self.iconState.tint.opacity(0.9)) .fill(self.iconState.tint.opacity(0.9)))
)
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
.offset(x: -4, y: -2) .offset(x: -4, y: -2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)

View File

@ -194,7 +194,10 @@ struct DebugSettings: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} else { } else {
Text( Text(
"Uses the Voice Wake path: forwards over SSH when configured, otherwise runs locally via rpc.") """
Uses the Voice Wake path: forwards over SSH when configured,
otherwise runs locally via rpc.
""")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -289,7 +292,10 @@ struct DebugSettings: View {
self.debugSendStatus = nil self.debugSendStatus = nil
} }
let message = "This is a debug test from the Mac app. Reply with \"Debug test works (and a funny pun)\" if you received that." let message = """
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
if you received that.
"""
let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
let shouldForward = config.enabled let shouldForward = config.enabled

View File

@ -184,10 +184,10 @@ struct GeneralSettings: View {
private var controlStatusLine: String { private var controlStatusLine: String {
switch ControlChannel.shared.state { switch ControlChannel.shared.state {
case .connected: return "Connected" case .connected: "Connected"
case .connecting: return "Connecting…" case .connecting: "Connecting…"
case .disconnected: return "Disconnected" case .disconnected: "Disconnected"
case let .degraded(msg): return "Degraded: \(msg)" case let .degraded(msg): "Degraded: \(msg)"
} }
} }
@ -276,8 +276,10 @@ struct GeneralSettings: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first { if let recent = snap.sessions.recent.first {
Text( let lastActivity = recent.updatedAt != nil
"Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")") ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
: "unknown"
Text("Last activity: \(recent.key) \(lastActivity)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -386,7 +388,9 @@ extension GeneralSettings {
// Step 2: control channel health over tunnel // Step 2: control channel health over tunnel
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote(target: settings.target, identity: settings.identity)) try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10) let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil { if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok self.remoteStatus = .ok

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
import Network
import OSLog import OSLog
import SwiftUI import SwiftUI
import Network
struct HealthSnapshot: Codable, Sendable { struct HealthSnapshot: Codable, Sendable {
struct Web: Codable, Sendable { struct Web: Codable, Sendable {

View File

@ -13,7 +13,8 @@ final class HeartbeatStore: ObservableObject {
self.observer = NotificationCenter.default.addObserver( self.observer = NotificationCenter.default.addObserver(
forName: .controlHeartbeat, forName: .controlHeartbeat,
object: nil, object: nil,
queue: .main) { [weak self] note in queue: .main)
{ [weak self] note in
guard let data = note.object as? Data else { return } guard let data = note.object as? Data else { return }
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
Task { @MainActor in self?.lastEvent = decoded } Task { @MainActor in self?.lastEvent = decoded }

View File

@ -23,28 +23,28 @@ enum IconState: Equatable {
var glyph: String { var glyph: String {
switch self.activity { switch self.activity {
case .tool(.bash): return "💻" case .tool(.bash): "💻"
case .tool(.read): return "📄" case .tool(.read): "📄"
case .tool(.write): return "✍️" case .tool(.write): "✍️"
case .tool(.edit): return "📝" case .tool(.edit): "📝"
case .tool(.attach): return "📎" case .tool(.attach): "📎"
case .tool(.other), .job: return "🛠️" case .tool(.other), .job: "🛠️"
} }
} }
var tint: Color { var tint: Color {
switch self { switch self {
case .workingMain: return .accentColor case .workingMain: .accentColor
case .workingOther: return .gray case .workingOther: .gray
case .overridden: return .orange case .overridden: .orange
case .idle: return .clear case .idle: .clear
} }
} }
var isWorking: Bool { var isWorking: Bool {
switch self { switch self {
case .idle: return false case .idle: false
default: return true default: true
} }
} }
@ -53,9 +53,9 @@ enum IconState: Equatable {
case let .workingMain(kind), case let .workingMain(kind),
let .workingOther(kind), let .workingOther(kind),
let .overridden(kind): let .overridden(kind):
return kind kind
case .idle: case .idle:
return .job .job
} }
} }
} }
@ -70,18 +70,18 @@ enum IconOverrideSelection: String, CaseIterable, Identifiable {
var label: String { var label: String {
switch self { switch self {
case .system: return "System (auto)" case .system: "System (auto)"
case .idle: return "Idle" case .idle: "Idle"
case .mainBash: return "Working main bash" case .mainBash: "Working main bash"
case .mainRead: return "Working main read" case .mainRead: "Working main read"
case .mainWrite: return "Working main write" case .mainWrite: "Working main write"
case .mainEdit: return "Working main edit" case .mainEdit: "Working main edit"
case .mainOther: return "Working main other" case .mainOther: "Working main other"
case .otherBash: return "Working other bash" case .otherBash: "Working other bash"
case .otherRead: return "Working other read" case .otherRead: "Working other read"
case .otherWrite: return "Working other write" case .otherWrite: "Working other write"
case .otherEdit: return "Working other edit" case .otherEdit: "Working other edit"
case .otherOther: return "Working other other" case .otherOther: "Working other other"
} }
} }

View File

@ -5,24 +5,24 @@ struct InstancesSettings: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
header self.header
if let err = store.lastError { if let err = store.lastError {
Text("Error: \(err)") Text("Error: \(err)")
.foregroundStyle(.red) .foregroundStyle(.red)
} }
if store.instances.isEmpty { if self.store.instances.isEmpty {
Text("No instances reported yet.") Text("No instances reported yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
List(store.instances) { inst in List(self.store.instances) { inst in
instanceRow(inst) self.instanceRow(inst)
} }
.listStyle(.inset) .listStyle(.inset)
} }
Spacer() Spacer()
} }
.onAppear { store.start() } .onAppear { self.store.start() }
.onDisappear { store.stop() } .onDisappear { self.store.stop() }
} }
private var header: some View { private var header: some View {
@ -35,10 +35,10 @@ struct InstancesSettings: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if store.isLoading { if self.store.isLoading {
ProgressView() ProgressView()
} else { } else {
Button("Refresh") { Task { await store.refresh() } } Button("Refresh") { Task { await self.store.refresh() } }
} }
} }
} }
@ -52,12 +52,12 @@ struct InstancesSettings: View {
} }
HStack(spacing: 8) { HStack(spacing: 8) {
if let version = inst.version { if let version = inst.version {
label(icon: "shippingbox", text: version) self.label(icon: "shippingbox", text: version)
} }
label(icon: "clock", text: inst.lastInputDescription) self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { label(icon: "network", text: mode) } if let mode = inst.mode { self.label(icon: "network", text: mode) }
if let reason = inst.reason, !reason.isEmpty { if let reason = inst.reason, !reason.isEmpty {
label(icon: "info.circle", text: reason) self.label(icon: "info.circle", text: reason)
} }
} }
Text(inst.text) Text(inst.text)

View File

@ -64,6 +64,7 @@ final class InstancesStore: ObservableObject {
self.logger.error("instances fetch returned empty payload") self.logger.error("instances fetch returned empty payload")
self.instances = [self.localFallbackInstance()] self.instances = [self.localFallbackInstance()]
self.lastError = "No presence data returned from relay yet." self.lastError = "No presence data returned from relay yet."
await self.probeHealthIfNeeded()
return return
} }
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data) let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
@ -83,6 +84,7 @@ final class InstancesStore: ObservableObject {
if withIDs.isEmpty { if withIDs.isEmpty {
self.instances = [self.localFallbackInstance()] self.instances = [self.localFallbackInstance()]
self.lastError = nil self.lastError = nil
await self.probeHealthIfNeeded()
} else { } else {
self.instances = withIDs self.instances = withIDs
self.lastError = nil self.lastError = nil
@ -93,10 +95,10 @@ final class InstancesStore: ObservableObject {
instances fetch failed: \(error.localizedDescription, privacy: .public) \ instances fetch failed: \(error.localizedDescription, privacy: .public) \
len=\(self.lastPayload?.count ?? 0, privacy: .public) \ len=\(self.lastPayload?.count ?? 0, privacy: .public) \
utf8=\(self.snippet(self.lastPayload), privacy: .public) utf8=\(self.snippet(self.lastPayload), privacy: .public)
""" """)
)
self.instances = [self.localFallbackInstance()] self.instances = [self.localFallbackInstance()]
self.lastError = "Decode failed: \(error.localizedDescription)" self.lastError = "Decode failed: \(error.localizedDescription)"
await self.probeHealthIfNeeded()
} }
} }
@ -126,7 +128,7 @@ final class InstancesStore: ObservableObject {
} }
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? = nil var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) } defer { freeifaddrs(addrList) }
@ -143,10 +145,18 @@ final class InstancesStore: ObservableObject {
var addr = ptr.pointee.ifa_addr.pointee var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(&addr, socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue } guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 } let len = buffer.prefix { $0 != 0 }
let ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self) let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if name == "en0" { en0 = ip; break } if name == "en0" { en0 = ip; break }
if fallback == nil { fallback = ip } if fallback == nil { fallback = ip }
@ -169,4 +179,27 @@ final class InstancesStore: ObservableObject {
} }
return "<\(data.count) bytes non-utf8>" return "<\(data.count) bytes non-utf8>"
} }
private func probeHealthIfNeeded() async {
do {
let data = try await ControlChannel.shared.health(timeout: 8)
guard let snap = decodeHealthSnapshot(from: data) else { return }
let entry = InstanceInfo(
id: "health-\(snap.ts)",
host: "relay (health)",
ip: nil,
version: nil,
lastInputSeconds: nil,
mode: "health",
reason: "health probe",
text: "Health ok · linked=\(snap.web.linked) · ipc.exists=\(snap.ipc.exists)",
ts: snap.ts)
if !self.instances.contains(where: { $0.id == entry.id }) {
self.instances.insert(entry, at: 0)
}
self.lastError = nil
} catch {
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
}
}
} }

View File

@ -246,7 +246,10 @@ struct OnboardingView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if !self.cliInstalled, self.cliInstallLocation == nil { } else if !self.cliInstalled, self.cliInstallLocation == nil {
Text( Text(
"We install into /usr/local/bin and /opt/homebrew/bin. Rerun anytime if you move the build output.") """
We install into /usr/local/bin and /opt/homebrew/bin.
Rerun anytime if you move the build output.
""")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -259,7 +262,10 @@ struct OnboardingView: View {
Text("Link WhatsApp") Text("Link WhatsApp")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.") """
Run `clawdis login` where the relay runs (local if local mode, remote if remote).
Scan the QR to pair your account.
""")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -273,11 +279,17 @@ struct OnboardingView: View {
systemImage: "terminal") systemImage: "terminal")
self.featureRow( self.featureRow(
title: "Run `clawdis login --verbose`", title: "Run `clawdis login --verbose`",
subtitle: "Scan the QR code with WhatsApp on your phone. We only use your personal session; no cloud relay involved.", subtitle: """
Scan the QR code with WhatsApp on your phone.
We only use your personal session; no cloud relay involved.
""",
systemImage: "qrcode.viewfinder") systemImage: "qrcode.viewfinder")
self.featureRow( self.featureRow(
title: "Re-link after timeouts", title: "Re-link after timeouts",
subtitle: "If Baileys auth expires, re-run login on that host. Settings → General shows remote/local mode so you know where to run it.", subtitle: """
If Baileys auth expires, re-run login on that host.
Settings General shows remote/local mode so you know where to run it.
""",
systemImage: "clock.arrow.circlepath") systemImage: "clock.arrow.circlepath")
} }
} }
@ -290,8 +302,10 @@ struct OnboardingView: View {
self.onboardingCard { self.onboardingCard {
self.featureRow( self.featureRow(
title: "Run the dashboard", title: "Run the dashboard",
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from " subtitle: """
+ "Settings if you add a new user.", Use the CLI helper from your scripts, and reopen onboarding from Settings
if you add a new user.
""",
systemImage: "checkmark.seal") systemImage: "checkmark.seal")
self.featureRow( self.featureRow(
title: "Test a notification", title: "Test a notification",

View File

@ -69,7 +69,7 @@ final class PresenceReporter {
} }
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? = nil var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) } defer { freeifaddrs(addrList) }
@ -86,10 +86,18 @@ final class PresenceReporter {
var addr = ptr.pointee.ifa_addr.pointee var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(&addr, socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue } guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 } let len = buffer.prefix { $0 != 0 }
let ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self) let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if name == "en0" { en0 = ip; break } if name == "en0" { en0 = ip; break }
if fallback == nil { fallback = ip } if fallback == nil { fallback = ip }

View File

@ -10,7 +10,7 @@ struct RuntimeVersion: Comparable, CustomStringConvertible {
let minor: Int let minor: Int
let patch: Int let patch: Int
var description: String { "\(major).\(minor).\(patch)" } var description: String { "\(self.major).\(self.minor).\(self.patch)" }
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major } if lhs.major != rhs.major { return lhs.major < rhs.major }
@ -41,7 +41,12 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error { enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String], preferred: String?) case notFound(searchPaths: [String], preferred: String?)
case unsupported(kind: RuntimeKind, found: RuntimeVersion, required: RuntimeVersion, path: String, searchPaths: [String]) case unsupported(
kind: RuntimeKind,
found: RuntimeVersion,
required: RuntimeVersion,
path: String,
searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
} }
@ -51,22 +56,31 @@ enum RuntimeLocator {
static func resolve( static func resolve(
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"], preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
searchPaths: [String] = CommandResolver.preferredPaths() searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
) -> Result<RuntimeResolution, RuntimeResolutionError> { {
let order = runtimeOrder(preferred: preferred) let order = self.runtimeOrder(preferred: preferred)
let pathEnv = searchPaths.joined(separator: ":") let pathEnv = searchPaths.joined(separator: ":")
for runtime in order { for runtime in order {
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue } guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse(kind: runtime, raw: "(unreadable)", path: binary, searchPaths: searchPaths)) return .failure(.versionParse(
kind: runtime,
raw: "(unreadable)",
path: binary,
searchPaths: searchPaths))
} }
guard let parsed = RuntimeVersion.from(string: rawVersion) else { guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
} }
let minimum = runtime == .bun ? minBun : minNode let minimum = runtime == .bun ? self.minBun : self.minNode
guard parsed >= minimum else { guard parsed >= minimum else {
return .failure(.unsupported(kind: runtime, found: parsed, required: minimum, path: binary, searchPaths: searchPaths)) return .failure(.unsupported(
kind: runtime,
found: parsed,
required: minimum,
path: binary,
searchPaths: searchPaths))
} }
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
} }
@ -86,10 +100,11 @@ enum RuntimeLocator {
"Install Bun: https://bun.sh/docs/installation", "Install Bun: https://bun.sh/docs/installation",
].joined(separator: "\n") ].joined(separator: "\n")
case let .unsupported(kind, found, required, path, searchPaths): case let .unsupported(kind, found, required, path, searchPaths):
let fallbackRuntime = kind == .bun ? "node" : "bun"
return [ return [
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
"PATH searched: \(searchPaths.joined(separator: ":"))", "PATH searched: \(searchPaths.joined(separator: ":"))",
"Upgrade \(kind.rawValue) or set CLAWDIS_RUNTIME=\(kind == .bun ? "node" : "bun") to try the other runtime.", "Upgrade \(kind.rawValue) or set CLAWDIS_RUNTIME=\(fallbackRuntime) to try the other runtime.",
].joined(separator: "\n") ].joined(separator: "\n")
case let .versionParse(kind, raw, path, searchPaths): case let .versionParse(kind, raw, path, searchPaths):
return [ return [
@ -143,7 +158,6 @@ enum RuntimeLocator {
} }
} }
private extension RuntimeKind { extension RuntimeKind {
var binaryName: String { self == .bun ? "bun" : "node" } fileprivate var binaryName: String { self == .bun ? "bun" : "node" }
} }

View File

@ -1,7 +1,7 @@
import AppKit import AppKit
import Foundation import Foundation
struct SoundEffectCatalog { enum SoundEffectCatalog {
/// All discoverable system sound names, with "Glass" pinned first. /// All discoverable system sound names, with "Glass" pinned first.
static var systemOptions: [String] { static var systemOptions: [String] {
var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames) var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames)
@ -13,7 +13,7 @@ struct SoundEffectCatalog {
static func displayName(for raw: String) -> String { raw } static func displayName(for raw: String) -> String { raw }
static func url(for name: String) -> URL? { static func url(for name: String) -> URL? {
Self.discoveredSoundMap[name] self.discoveredSoundMap[name]
} }
// MARK: - Internals // MARK: - Internals

View File

@ -225,8 +225,8 @@ enum CommandResolver {
runtime: RuntimeResolution, runtime: RuntimeResolution,
entrypoint: String, entrypoint: String,
subcommand: String, subcommand: String,
extraArgs: [String] extraArgs: [String]) -> [String]
) -> [String] { {
[runtime.path, entrypoint, subcommand] + extraArgs [runtime.path, entrypoint, subcommand] + extraArgs
} }
@ -332,11 +332,19 @@ enum CommandResolver {
if let relay = self.bundledRelayRoot(), if let relay = self.bundledRelayRoot(),
let entry = self.relayEntrypoint(in: relay) let entry = self.relayEntrypoint(in: relay)
{ {
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs) return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: subcommand,
extraArgs: extraArgs)
} }
if let entry = self.relayEntrypoint(in: self.projectRoot()) { if let entry = self.relayEntrypoint(in: self.projectRoot()) {
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs) return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: subcommand,
extraArgs: extraArgs)
} }
if let clawdisPath = self.clawdisExecutable() { if let clawdisPath = self.clawdisExecutable() {
@ -347,7 +355,9 @@ enum CommandResolver {
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
} }
let missingEntry = "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build." let missingEntry = """
clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.
"""
return self.errorCommand(with: missingEntry) return self.errorCommand(with: missingEntry)
case let .failure(error): case let .failure(error):
@ -390,13 +400,21 @@ enum CommandResolver {
args.append(userHost) args.append(userHost)
// Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac. // Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac.
let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm:$PATH" let exportedPath = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
"/Users/steipete/Library/pnpm",
"$PATH",
].joined(separator: ":")
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
let projectSection: String let projectSection = if userPRJ.isEmpty {
if userPRJ.isEmpty { """
projectSection = """
DEFAULT_PRJ="$HOME/Projects/clawdis" DEFAULT_PRJ="$HOME/Projects/clawdis"
if [ -d "$DEFAULT_PRJ" ]; then if [ -d "$DEFAULT_PRJ" ]; then
PRJ="$DEFAULT_PRJ" PRJ="$DEFAULT_PRJ"
@ -404,7 +422,7 @@ enum CommandResolver {
fi fi
""" """
} else { } else {
projectSection = """ """
PRJ=\(self.shellQuote(userPRJ)) PRJ=\(self.shellQuote(userPRJ))
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; } cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
""" """
@ -448,7 +466,11 @@ enum CommandResolver {
return ["/usr/bin/ssh"] + args return ["/usr/bin/ssh"] + args
} }
private static func sshMacHelperCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { private static func sshMacHelperCommand(
subcommand: String,
extraArgs: [String],
settings: RemoteSettings) -> [String]?
{
guard !settings.target.isEmpty else { return nil } guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil } guard let parsed = self.parseSSHTarget(settings.target) else { return nil }

View File

@ -55,14 +55,14 @@ final class VoicePushToTalkHotkey {
} }
let chordActive = self.optionDown let chordActive = self.optionDown
if chordActive && !self.active { if chordActive, !self.active {
self.active = true self.active = true
Task { Task {
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt") Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
.info("ptt hotkey down") .info("ptt hotkey down")
await VoicePushToTalk.shared.begin() await VoicePushToTalk.shared.begin()
} }
} else if !chordActive && self.active { } else if !chordActive, self.active {
self.active = false self.active = false
Task { Task {
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt") Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
@ -126,7 +126,10 @@ actor VoicePushToTalk {
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
await VoiceWakeRuntime.shared.pauseForPushToTalk() await VoiceWakeRuntime.shared.pauseForPushToTalk()
let adoptedPrefix = self.adoptedPrefix let adoptedPrefix = self.adoptedPrefix
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(committed: adoptedPrefix, volatile: "", isFinal: false) let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(
committed: adoptedPrefix,
volatile: "",
isFinal: false)
self.overlayToken = await MainActor.run { self.overlayToken = await MainActor.run {
VoiceWakeOverlayController.shared.startSession( VoiceWakeOverlayController.shared.startSession(
source: .pushToTalk, source: .pushToTalk,
@ -166,7 +169,10 @@ actor VoicePushToTalk {
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
self.recognizer = SFSpeechRecognizer(locale: locale) self.recognizer = SFSpeechRecognizer(locale: locale)
guard let recognizer, recognizer.isAvailable else { guard let recognizer, recognizer.isAvailable else {
throw NSError(domain: "VoicePushToTalk", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"]) throw NSError(
domain: "VoicePushToTalk",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"])
} }
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
@ -216,7 +222,10 @@ actor VoicePushToTalk {
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
if let token = self.overlayToken { if let token = self.overlayToken {
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed) VoiceWakeOverlayController.shared.updatePartial(
token: token,
transcript: snapshot,
attributed: attributed)
} }
} }
} }
@ -238,11 +247,10 @@ actor VoicePushToTalk {
committed: Self.join(self.adoptedPrefix, self.committed), committed: Self.join(self.adoptedPrefix, self.committed),
volatile: self.volatile, volatile: self.volatile,
isFinal: true) isFinal: true)
let forward: VoiceWakeForwardConfig let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig {
if let cached = self.activeConfig?.forwardConfig { cached
forward = cached
} else { } else {
forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
} }
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)

View File

@ -17,16 +17,16 @@ enum VoiceWakeChime: Codable, Equatable, Sendable {
var displayLabel: String { var displayLabel: String {
switch self { switch self {
case .none: case .none:
return "No Sound" "No Sound"
case let .system(name): case let .system(name):
return VoiceWakeChimeCatalog.displayName(for: name) VoiceWakeChimeCatalog.displayName(for: name)
case let .custom(displayName, _): case let .custom(displayName, _):
return displayName displayName
} }
} }
} }
struct VoiceWakeChimeCatalog { enum VoiceWakeChimeCatalog {
/// Options shown in the picker. /// Options shown in the picker.
static var systemOptions: [String] { SoundEffectCatalog.systemOptions } static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
@ -57,12 +57,13 @@ enum VoiceWakeChimePlayer {
private static func sound(for chime: VoiceWakeChime) -> NSSound? { private static func sound(for chime: VoiceWakeChime) -> NSSound? {
switch chime { switch chime {
case .none: case .none:
return nil nil
case let .system(name): case let .system(name):
return SoundEffectPlayer.sound(named: name) SoundEffectPlayer.sound(named: name)
case let .custom(_, bookmark): case let .custom(_, bookmark):
return SoundEffectPlayer.sound(from: bookmark) SoundEffectPlayer.sound(from: bookmark)
} }
} }
} }

View File

@ -20,7 +20,12 @@ enum VoiceWakeForwarder {
?? ProcessInfo.processInfo.hostName ?? ProcessInfo.processInfo.hostName
let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine
return "User talked via voice recognition on \(safeMachine) - repeat prompt first + remember some words might be incorrectly transcribed.\n\n\(transcript)" return """
User talked via voice recognition on \(safeMachine) - repeat prompt first \
+ remember some words might be incorrectly transcribed.
\(transcript)
"""
} }
static func clearCliCache() { static func clearCliCache() {
@ -33,8 +38,8 @@ enum VoiceWakeForwarder {
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case let .rpcFailed(message): return message case let .rpcFailed(message): message
case .disabled: return "Voice wake forwarding disabled" case .disabled: "Voice wake forwarding disabled"
} }
} }
} }
@ -149,7 +154,7 @@ enum VoiceWakeForwarder {
} }
continue continue
} }
if ch.isWhitespace && quote == nil { if ch.isWhitespace, quote == nil {
flush() flush()
continue continue
} }

View File

@ -20,7 +20,7 @@ final class VoiceWakeOverlayController: ObservableObject {
var isVisible: Bool = false var isVisible: Bool = false
var forwardEnabled: Bool = false var forwardEnabled: Bool = false
var isSending: Bool = false var isSending: Bool = false
var attributed: NSAttributedString = NSAttributedString(string: "") var attributed: NSAttributedString = .init(string: "")
var isOverflowing: Bool = false var isOverflowing: Bool = false
var isEditing: Bool = false var isEditing: Bool = false
var level: Double = 0 // normalized 0...1 speech level for UI var level: Double = 0 // normalized 0...1 speech level for UI
@ -52,7 +52,11 @@ final class VoiceWakeOverlayController: ObservableObject {
isFinal: Bool = false) -> UUID isFinal: Bool = false) -> UUID
{ {
let token = UUID() let token = UUID()
self.logger.log(level: .info, "overlay session_start source=\(source.rawValue, privacy: .public) len=\(transcript.count, privacy: .public)") let message = """
overlay session_start source=\(source.rawValue, privacy: .public) \
len=\(transcript.count, privacy: .public)
"""
self.logger.log(level: .info, "\(message)")
self.activeToken = token self.activeToken = token
self.activeSource = source self.activeSource = source
self.forwardConfig = nil self.forwardConfig = nil
@ -76,7 +80,11 @@ final class VoiceWakeOverlayController: ObservableObject {
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) { func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
guard self.guardToken(token, context: "partial") else { return } guard self.guardToken(token, context: "partial") else { return }
guard !self.model.isFinal else { return } guard !self.model.isFinal else { return }
self.logger.log(level: .info, "overlay partial token=\(token.uuidString, privacy: .public) len=\(transcript.count, privacy: .public)") let message = """
overlay partial token=\(token.uuidString, privacy: .public) \
len=\(transcript.count, privacy: .public)
"""
self.logger.log(level: .info, "\(message)")
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
self.forwardConfig = nil self.forwardConfig = nil
self.model.text = transcript self.model.text = transcript
@ -99,7 +107,13 @@ final class VoiceWakeOverlayController: ObservableObject {
attributed: NSAttributedString? = nil) attributed: NSAttributedString? = nil)
{ {
guard self.guardToken(token, context: "final") else { return } guard self.guardToken(token, context: "final") else { return }
self.logger.log(level: .info, "overlay presentFinal token=\(token.uuidString, privacy: .public) len=\(transcript.count, privacy: .public) autoSendAfter=\(delay ?? -1, privacy: .public) forwardEnabled=\(forwardConfig.enabled, privacy: .public)") let message = """
overlay presentFinal token=\(token.uuidString, privacy: .public) \
len=\(transcript.count, privacy: .public) \
autoSendAfter=\(delay ?? -1, privacy: .public) \
forwardEnabled=\(forwardConfig.enabled, privacy: .public)
"""
self.logger.log(level: .info, "\(message)")
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.autoSendToken = token self.autoSendToken = token
self.forwardConfig = forwardConfig self.forwardConfig = forwardConfig
@ -142,7 +156,13 @@ final class VoiceWakeOverlayController: ObservableObject {
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) { func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
guard self.guardToken(token, context: "send") else { return } guard self.guardToken(token, context: "send") else { return }
self.logger.log(level: .info, "overlay sendNow called token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) isSending=\(self.model.isSending, privacy: .public) forwardEnabled=\(self.model.forwardEnabled, privacy: .public) textLen=\(self.model.text.count, privacy: .public)") let message = """
overlay sendNow called token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \
isSending=\(self.model.isSending, privacy: .public) \
forwardEnabled=\(self.model.forwardEnabled, privacy: .public) \
textLen=\(self.model.text.count, privacy: .public)
"""
self.logger.log(level: .info, "\(message)")
self.autoSendTask?.cancel(); self.autoSendToken = nil self.autoSendTask?.cancel(); self.autoSendToken = nil
if self.model.isSending { return } if self.model.isSending { return }
self.model.isEditing = false self.model.isEditing = false
@ -159,7 +179,8 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
if sendChime != .none { if sendChime != .none {
self.logger.log(level: .info, "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)") let message = "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)"
self.logger.log(level: .info, "\(message)")
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send") VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
} }
@ -176,7 +197,14 @@ final class VoiceWakeOverlayController: ObservableObject {
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
guard self.guardToken(token, context: "dismiss") else { return } guard self.guardToken(token, context: "dismiss") else { return }
self.logger.log(level: .info, "overlay dismiss token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) reason=\(String(describing: reason), privacy: .public) outcome=\(String(describing: outcome), privacy: .public) visible=\(self.model.isVisible, privacy: .public) sending=\(self.model.isSending, privacy: .public)") let message = """
overlay dismiss token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \
reason=\(String(describing: reason), privacy: .public) \
outcome=\(String(describing: outcome), privacy: .public) \
visible=\(self.model.isVisible, privacy: .public) \
sending=\(self.model.isSending, privacy: .public)
"""
self.logger.log(level: .info, "\(message)")
self.autoSendTask?.cancel(); self.autoSendToken = nil self.autoSendTask?.cancel(); self.autoSendToken = nil
self.model.isSending = false self.model.isSending = false
self.model.isEditing = false self.model.isEditing = false
@ -237,7 +265,9 @@ final class VoiceWakeOverlayController: ObservableObject {
guard let window else { return } guard let window else { return }
if !self.model.isVisible { if !self.model.isVisible {
self.model.isVisible = true self.model.isVisible = true
self.logger.log(level: .info, "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)") self.logger.log(
level: .info,
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
// Keep the status item in listening mode until we explicitly dismiss the overlay. // Keep the status item in listening mode until we explicitly dismiss the overlay.
AppStateStore.shared.triggerVoiceEars(ttl: nil) AppStateStore.shared.triggerVoiceEars(ttl: nil)
let start = target.offsetBy(dx: 0, dy: -6) let start = target.offsetBy(dx: 0, dy: -6)
@ -309,7 +339,8 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
private func measuredHeight() -> CGFloat { private func measuredHeight() -> CGFloat {
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self.makeAttributed(from: self.model.text) let attributed = self.model.attributed.length > 0 ? self.model.attributed : self
.makeAttributed(from: self.model.text)
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
let textInset = NSSize(width: 2, height: 6) let textInset = NSSize(width: 2, height: 6)
@ -350,7 +381,13 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) { private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) {
self.logger.log(level: .info, "overlay scheduleAutoSend token=\(token.uuidString, privacy: .public) after=\(delay, privacy: .public) sendChime=\(String(describing: sendChime), privacy: .public)") self.logger.log(
level: .info,
"""
overlay scheduleAutoSend token=\(token.uuidString, privacy: .public) \
after=\(delay, privacy: .public) \
sendChime=\(String(describing: sendChime), privacy: .public)
""")
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.autoSendToken = token self.autoSendToken = token
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
@ -360,7 +397,9 @@ final class VoiceWakeOverlayController: ObservableObject {
await MainActor.run { await MainActor.run {
guard let self else { return } guard let self else { return }
guard self.guardToken(token, context: "autoSend") else { return } guard self.guardToken(token, context: "autoSend") else { return }
self.logger.log(level: .info, "overlay autoSend firing token=\(token.uuidString, privacy: .public)") self.logger.log(
level: .info,
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
self.sendNow(token: token, sendChime: sendChime) self.sendNow(token: token, sendChime: sendChime)
self.autoSendTask = nil self.autoSendTask = nil
} }
@ -376,6 +415,7 @@ final class VoiceWakeOverlayController: ObservableObject {
]) ])
} }
} }
private struct VoiceWakeOverlayView: View { private struct VoiceWakeOverlayView: View {
@ObservedObject var controller: VoiceWakeOverlayController @ObservedObject var controller: VoiceWakeOverlayController
@FocusState private var textFocused: Bool @FocusState private var textFocused: Bool
@ -469,9 +509,8 @@ private struct VoiceWakeOverlayView: View {
// Close button rendered above and outside the clipped bubble // Close button rendered above and outside the clipped bubble
CloseButtonOverlay( CloseButtonOverlay(
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering, isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
onHover: { self.closeHovering = $0 }) { onHover: { self.closeHovering = $0 },
self.controller.cancelEditingAndDismiss() onClose: { self.controller.cancelEditingAndDismiss() })
}
} }
.padding(.top, self.controller.closeOverflow) .padding(.top, self.controller.closeOverflow)
.padding(.leading, self.controller.closeOverflow) .padding(.leading, self.controller.closeOverflow)
@ -629,7 +668,6 @@ private struct VibrantLabelView: NSViewRepresentable {
label.attributedStringValue = self.attributed.strippingForegroundColor() label.attributedStringValue = self.attributed.strippingForegroundColor()
label.textColor = .labelColor label.textColor = .labelColor
} }
} }
private final class ClickCatcher: NSView { private final class ClickCatcher: NSView {
@ -675,8 +713,8 @@ private struct CloseButtonOverlay: View {
var body: some View { var body: some View {
Group { Group {
if isVisible { if self.isVisible {
Button(action: onClose) { Button(action: self.onClose) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 12, weight: .bold))
.foregroundColor(Color.white.opacity(0.9)) .foregroundColor(Color.white.opacity(0.9))
@ -695,7 +733,7 @@ private struct CloseButtonOverlay: View {
.transition(.opacity) .transition(.opacity)
} }
} }
.allowsHitTesting(isVisible) .allowsHitTesting(self.isVisible)
} }
} }
@ -723,7 +761,7 @@ private final class TranscriptNSTextView: NSTextView {
self.onEscape?() self.onEscape?()
return return
} }
if isReturn && event.modifierFlags.contains(.command) { if isReturn, event.modifierFlags.contains(.command) {
self.onSend?() self.onSend?()
return return
} }

View File

@ -136,7 +136,12 @@ actor VoiceWakeRuntime {
guard let self else { return } guard let self else { return }
let transcript = result?.bestTranscription.formattedString let transcript = result?.bestTranscription.formattedString
let isFinal = result?.isFinal ?? false let isFinal = result?.isFinal ?? false
Task { await self.handleRecognition(transcript: transcript, isFinal: isFinal, error: error, config: config, generation: generation) } Task { await self.handleRecognition(
transcript: transcript,
isFinal: isFinal,
error: error,
config: config,
generation: generation) }
} }
self.logger.info("voicewake runtime started") self.logger.info("voicewake runtime started")
@ -213,7 +218,10 @@ actor VoiceWakeRuntime {
let snapshot = self.committedTranscript + self.volatileTranscript let snapshot = self.committedTranscript + self.volatileTranscript
if let token = self.overlayToken { if let token = self.overlayToken {
await MainActor.run { await MainActor.run {
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed) VoiceWakeOverlayController.shared.updatePartial(
token: token,
transcript: snapshot,
attributed: attributed)
} }
} }
} }

View File

@ -49,7 +49,10 @@ struct VoiceWakeSettings: View {
SettingsToggleRow( SettingsToggleRow(
title: "Hold Right Option to talk", title: "Hold Right Option to talk",
subtitle: "Push-to-talk mode that starts listening while you hold the key and shows the preview overlay.", subtitle: """
Push-to-talk mode that starts listening while you hold the key
and shows the preview overlay.
""",
binding: self.$state.voicePushToTalkEnabled) binding: self.$state.voicePushToTalkEnabled)
.disabled(!voiceWakeSupported) .disabled(!voiceWakeSupported)

View File

@ -67,7 +67,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func bootstrap() async { private func bootstrap() async {
do { do {
guard AppStateStore.webChatEnabled else { guard AppStateStore.webChatEnabled else {
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"]) throw NSError(
domain: "WebChat",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
} }
let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort) let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort)
self.baseEndpoint = endpoint self.baseEndpoint = endpoint
@ -90,9 +93,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func prepareEndpoint(remotePort: Int) async throws -> URL { private func prepareEndpoint(remotePort: Int) async throws -> URL {
if CommandResolver.connectionModeIsRemote() { if CommandResolver.connectionModeIsRemote() {
return try await self.startOrRestartTunnel() try await self.startOrRestartTunnel()
} else { } else {
return URL(string: "http://127.0.0.1:\(remotePort)/")! URL(string: "http://127.0.0.1:\(remotePort)/")!
} }
} }
@ -116,11 +119,17 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
let (_, response) = try await session.data(for: request) let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse { if let http = response as? HTTPURLResponse {
guard (200..<500).contains(http.statusCode) else { guard (200..<500).contains(http.statusCode) else {
throw NSError(domain: "WebChat", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "webchat returned HTTP \(http.statusCode)"]) throw NSError(
domain: "WebChat",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "webchat returned HTTP \(http.statusCode)"])
} }
} }
} catch { } catch {
throw NSError(domain: "WebChat", code: 7, userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"]) throw NSError(
domain: "WebChat",
code: 7,
userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"])
} }
} }
@ -128,7 +137,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
// Kill existing tunnel if any // Kill existing tunnel if any
self.stopTunnel(allowRestart: false) self.stopTunnel(allowRestart: false)
let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18_788) let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18788)
self.tunnel = tunnel self.tunnel = tunnel
self.tunnelRestartEnabled = true self.tunnelRestartEnabled = true
@ -162,7 +171,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func showError(_ text: String) { private func showError(_ text: String) {
let html = """ let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(text)</body></html> <html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(
text)</body></html>
""" """
self.webView.loadHTMLString(html, baseURL: nil) self.webView.loadHTMLString(html, baseURL: nil)
} }
@ -247,7 +257,7 @@ final class WebChatTunnel {
"-o", "ServerAliveCountMax=3", "-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes", "-o", "TCPKeepAlive=yes",
"-N", "-N",
"-L", "\(localPort):127.0.0.1:\(remotePort)" "-L", "\(localPort):127.0.0.1:\(remotePort)",
] ]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
@ -263,7 +273,9 @@ final class WebChatTunnel {
// Consume stderr so ssh cannot block if it logs // Consume stderr so ssh cannot block if it logs
pipe.fileHandleForReading.readabilityHandler = { handle in pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData let data = handle.availableData
guard !data.isEmpty, let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !line.isEmpty else { return } guard !data.isEmpty,
let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty else { return }
webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)") webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)")
} }
try process.run() try process.run()
@ -272,7 +284,7 @@ final class WebChatTunnel {
} }
private static func findPort(preferred: UInt16?) async throws -> UInt16 { private static func findPort(preferred: UInt16?) async throws -> UInt16 {
if let preferred, Self.portIsFree(preferred) { return preferred } if let preferred, portIsFree(preferred) { return preferred }
return try await withCheckedThrowingContinuation { cont in return try await withCheckedThrowingContinuation { cont in
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility) let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)

View File

@ -44,8 +44,8 @@ final class WorkActivityStore: ObservableObject {
phase: String, phase: String,
name: String?, name: String?,
meta: String?, meta: String?,
args: [String: AnyCodable]? args: [String: AnyCodable]?)
) { {
let toolKind = Self.mapToolKind(name) let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args) let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
if phase.lowercased() == "start" { if phase.lowercased() == "start" {
@ -124,7 +124,7 @@ final class WorkActivityStore: ObservableObject {
return return
} }
// Otherwise, pick most recent by lastUpdate. // Otherwise, pick most recent by lastUpdate.
if let next = self.active.values.sorted(by: { $0.lastUpdate > $1.lastUpdate }).first { if let next = self.active.values.max(by: { $0.lastUpdate < $1.lastUpdate }) {
self.currentSessionKey = next.sessionKey self.currentSessionKey = next.sessionKey
} else { } else {
self.currentSessionKey = nil self.currentSessionKey = nil
@ -145,20 +145,20 @@ final class WorkActivityStore: ObservableObject {
private static func mapToolKind(_ name: String?) -> ToolKind { private static func mapToolKind(_ name: String?) -> ToolKind {
switch name?.lowercased() { switch name?.lowercased() {
case "bash", "shell": return .bash case "bash", "shell": .bash
case "read": return .read case "read": .read
case "write": return .write case "write": .write
case "edit": return .edit case "edit": .edit
case "attach": return .attach case "attach": .attach
default: return .other default: .other
} }
} }
private static func buildLabel( private static func buildLabel(
kind: ToolKind, kind: ToolKind,
meta: String?, meta: String?,
args: [String: AnyCodable]? args: [String: AnyCodable]?) -> String
) -> String { {
switch kind { switch kind {
case .bash: case .bash:
if let cmd = args?["command"]?.value as? String { if let cmd = args?["command"]?.value as? String {
@ -166,7 +166,7 @@ final class WorkActivityStore: ObservableObject {
} }
return "bash" return "bash"
case .read, .write, .edit, .attach: case .read, .write, .edit, .attach:
if let path = Self.extractPath(args: args, meta: meta) { if let path = extractPath(args: args, meta: meta) {
return "\(kind.rawValue): \(path)" return "\(kind.rawValue): \(path)"
} }
return kind.rawValue return kind.rawValue
@ -179,9 +179,9 @@ final class WorkActivityStore: ObservableObject {
} }
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? { private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
if let p = args?["path"]?.value as? String { return shortenHome(path: p) } if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
if let p = args?["file_path"]?.value as? String { return shortenHome(path: p) } if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
if let meta { return shortenHome(path: meta) } if let meta { return self.shortenHome(path: meta) }
return nil return nil
} }

View File

@ -183,7 +183,8 @@ struct ClawdisCLI {
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...> clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
clawdis-mac status clawdis-mac status
clawdis-mac rpc-status clawdis-mac rpc-status
clawdis-mac agent --message <text> [--thinking <low|default|high>] [--session <key>] [--deliver] [--to <E.164>] clawdis-mac agent --message <text> [--thinking <low|default|high>]
[--session <key>] [--deliver] [--to <E.164>]
clawdis-mac --help clawdis-mac --help
Returns JSON to stdout: Returns JSON to stdout:

View File

@ -1,5 +1,5 @@
@testable import Clawdis
import Testing import Testing
@testable import Clawdis
@testable import ClawdisIPC @testable import ClawdisIPC
@Suite(.serialized) struct AgentRPCTests { @Suite(.serialized) struct AgentRPCTests {

View File

@ -12,7 +12,8 @@ import Testing
} }
private func makeExec(at path: URL) throws { private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
@ -23,7 +24,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path) CommandResolver.setProjectRoot(tmp.path)
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis") let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
try makeExec(at: clawdisPath) try self.makeExec(at: clawdisPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "relay") let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"])) #expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
@ -35,10 +36,10 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node") let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/clawdis.js") let scriptPath = tmp.appendingPathComponent("bin/clawdis.js")
try makeExec(at: nodePath) try self.makeExec(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExec(at: scriptPath) try self.makeExec(at: scriptPath)
let previous = getenv("CLAWDIS_RUNTIME").flatMap { String(validatingCString: $0) } let previous = getenv("CLAWDIS_RUNTIME").flatMap { String(validatingCString: $0) }
setenv("CLAWDIS_RUNTIME", "node", 1) setenv("CLAWDIS_RUNTIME", "node", 1)
@ -63,7 +64,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path) CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try makeExec(at: pnpmPath) try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc") let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
@ -75,7 +76,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path) CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try makeExec(at: pnpmPath) try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json", "--timeout", "5"]) let cmd = CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json", "--timeout", "5"])

View File

@ -3,12 +3,10 @@ import Testing
@testable import Clawdis @testable import Clawdis
@Suite struct HealthDecodeTests { @Suite struct HealthDecodeTests {
private let sampleJSON: String = { private let sampleJSON: String = // minimal but complete payload
// minimal but complete payload
""" """
{"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}} {"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}}
""" """
}()
@Test func decodesCleanJSON() async throws { @Test func decodesCleanJSON() async throws {
let data = Data(sampleJSON.utf8) let data = Data(sampleJSON.utf8)
@ -20,7 +18,7 @@ import Testing
} }
@Test func decodesWithLeadingNoise() async throws { @Test func decodesWithLeadingNoise() async throws {
let noisy = "debug: something logged\n" + sampleJSON + "\ntrailer" let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
#expect(snap?.web.connect?.status == 200) #expect(snap?.web.connect?.status == 200)

View File

@ -5,4 +5,3 @@ import Testing
#expect(true) #expect(true)
} }
} }

View File

@ -33,7 +33,8 @@ import Testing
@Test func trimsAfterFirstMatchingTrigger() { @Test func trimsAfterFirstMatchingTrigger() {
let triggers = ["buddy", "claude"] let triggers = ["buddy", "claude"]
let text = "hello buddy this is after trigger claude also here" let text = "hello buddy this is after trigger claude also here"
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here") #expect(VoiceWakeRuntime
._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here")
} }
@Test func hasContentAfterTriggerFalseWhenOnlyTrigger() { @Test func hasContentAfterTriggerFalseWhenOnlyTrigger() {