chore(mac): apply swiftformat and lint fixes

This commit is contained in:
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) {
self.events.append(event)
if self.events.count > maxEvents {
self.events.removeFirst(self.events.count - maxEvents)
if self.events.count > self.maxEvents {
self.events.removeFirst(self.events.count - self.maxEvents)
}
}

View File

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

View File

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

View File

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

View File

@@ -108,7 +108,11 @@ struct ConfigSettings: View {
.frame(width: 100)
.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)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)

View File

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

View File

@@ -194,7 +194,10 @@ struct DebugSettings: View {
.foregroundStyle(.red)
} else {
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)
.foregroundStyle(.secondary)
}
@@ -289,7 +292,10 @@ struct DebugSettings: View {
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 shouldForward = config.enabled

View File

@@ -137,57 +137,57 @@ struct GeneralSettings: View {
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
switch self.remoteStatus {
case .idle:
EmptyView()
case .checking:
Text("Checking…").font(.caption).foregroundStyle(.secondary)
switch self.remoteStatus {
case .idle:
EmptyView()
case .checking:
Text("Checking…").font(.caption).foregroundStyle(.secondary)
case .ok:
Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
case let .failed(message):
Text(message)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
// Diagnostics
VStack(alignment: .leading, spacing: 4) {
Text("Control channel")
.font(.caption.weight(.semibold))
Text(self.controlStatusLine)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let ping = ControlChannel.shared.lastPingMs {
Text("Last ping: \(Int(ping)) ms")
.font(.caption)
.foregroundStyle(.secondary)
}
if let hb = HeartbeatStore.shared.lastEvent {
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
Text("Last heartbeat: \(hb.status) · \(ageText)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Diagnostics
VStack(alignment: .leading, spacing: 4) {
Text("Control channel")
.font(.caption.weight(.semibold))
Text(self.controlStatusLine)
.font(.caption)
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
if let ping = ControlChannel.shared.lastPingMs {
Text("Last ping: \(Int(ping)) ms")
.font(.caption)
.foregroundStyle(.secondary)
}
if let hb = HeartbeatStore.shared.lastEvent {
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
Text("Last heartbeat: \(hb.status) · \(ageText)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(1)
}
.transition(.opacity)
}
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: return "Connected"
case .connecting: return "Connecting…"
case .disconnected: return "Disconnected"
case let .degraded(msg): return "Degraded: \(msg)"
case .connected: "Connected"
case .connecting: "Connecting…"
case .disconnected: "Disconnected"
case let .degraded(msg): "Degraded: \(msg)"
}
}
@@ -276,8 +276,10 @@ struct GeneralSettings: View {
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {
Text(
"Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
let lastActivity = recent.updatedAt != nil
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
: "unknown"
Text("Last activity: \(recent.key) \(lastActivity)")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -386,7 +388,9 @@ extension GeneralSettings {
// Step 2: control channel health over tunnel
let originalMode = AppStateStore.shared.connectionMode
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)
if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ final class InstancesStore: ObservableObject {
self.logger.error("instances fetch returned empty payload")
self.instances = [self.localFallbackInstance()]
self.lastError = "No presence data returned from relay yet."
await self.probeHealthIfNeeded()
return
}
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
@@ -83,6 +84,7 @@ final class InstancesStore: ObservableObject {
if withIDs.isEmpty {
self.instances = [self.localFallbackInstance()]
self.lastError = nil
await self.probeHealthIfNeeded()
} else {
self.instances = withIDs
self.lastError = nil
@@ -93,10 +95,10 @@ final class InstancesStore: ObservableObject {
instances fetch failed: \(error.localizedDescription, privacy: .public) \
len=\(self.lastPayload?.count ?? 0, privacy: .public) \
utf8=\(self.snippet(self.lastPayload), privacy: .public)
"""
)
""")
self.instances = [self.localFallbackInstance()]
self.lastError = "Decode failed: \(error.localizedDescription)"
await self.probeHealthIfNeeded()
}
}
@@ -126,7 +128,7 @@ final class InstancesStore: ObservableObject {
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
@@ -143,10 +145,18 @@ final class InstancesStore: ObservableObject {
var addr = ptr.pointee.ifa_addr.pointee
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 }
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 fallback == nil { fallback = ip }
@@ -169,4 +179,27 @@ final class InstancesStore: ObservableObject {
}
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)
} else if !self.cliInstalled, self.cliInstallLocation == nil {
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)
.foregroundStyle(.secondary)
}
@@ -259,7 +262,10 @@ struct OnboardingView: View {
Text("Link WhatsApp")
.font(.largeTitle.weight(.semibold))
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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -273,11 +279,17 @@ struct OnboardingView: View {
systemImage: "terminal")
self.featureRow(
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")
self.featureRow(
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")
}
}
@@ -290,8 +302,10 @@ struct OnboardingView: View {
self.onboardingCard {
self.featureRow(
title: "Run the dashboard",
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from "
+ "Settings if you add a new user.",
subtitle: """
Use the CLI helper from your scripts, and reopen onboarding from Settings
if you add a new user.
""",
systemImage: "checkmark.seal")
self.featureRow(
title: "Test a notification",

View File

@@ -69,7 +69,7 @@ final class PresenceReporter {
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
@@ -86,10 +86,18 @@ final class PresenceReporter {
var addr = ptr.pointee.ifa_addr.pointee
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 }
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 fallback == nil { fallback = ip }

View File

@@ -10,7 +10,7 @@ struct RuntimeVersion: Comparable, CustomStringConvertible {
let minor: 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 {
if lhs.major != rhs.major { return lhs.major < rhs.major }
@@ -41,7 +41,12 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error {
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])
}
@@ -51,22 +56,31 @@ enum RuntimeLocator {
static func resolve(
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
searchPaths: [String] = CommandResolver.preferredPaths()
) -> Result<RuntimeResolution, RuntimeResolutionError> {
let order = runtimeOrder(preferred: preferred)
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
{
let order = self.runtimeOrder(preferred: preferred)
let pathEnv = searchPaths.joined(separator: ":")
for runtime in order {
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
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 {
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 {
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))
}
@@ -86,10 +100,11 @@ enum RuntimeLocator {
"Install Bun: https://bun.sh/docs/installation",
].joined(separator: "\n")
case let .unsupported(kind, found, required, path, searchPaths):
let fallbackRuntime = kind == .bun ? "node" : "bun"
return [
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
"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")
case let .versionParse(kind, raw, path, searchPaths):
return [
@@ -143,7 +158,6 @@ enum RuntimeLocator {
}
}
private extension RuntimeKind {
var binaryName: String { self == .bun ? "bun" : "node" }
extension RuntimeKind {
fileprivate var binaryName: String { self == .bun ? "bun" : "node" }
}

View File

@@ -1,7 +1,7 @@
import AppKit
import Foundation
struct SoundEffectCatalog {
enum SoundEffectCatalog {
/// All discoverable system sound names, with "Glass" pinned first.
static var systemOptions: [String] {
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 url(for name: String) -> URL? {
Self.discoveredSoundMap[name]
self.discoveredSoundMap[name]
}
// MARK: - Internals

View File

@@ -225,8 +225,8 @@ enum CommandResolver {
runtime: RuntimeResolution,
entrypoint: String,
subcommand: String,
extraArgs: [String]
) -> [String] {
extraArgs: [String]) -> [String]
{
[runtime.path, entrypoint, subcommand] + extraArgs
}
@@ -332,11 +332,19 @@ enum CommandResolver {
if let relay = self.bundledRelayRoot(),
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()) {
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() {
@@ -347,7 +355,9 @@ enum CommandResolver {
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)
case let .failure(error):
@@ -390,24 +400,32 @@ enum CommandResolver {
args.append(userHost)
// 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 userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
let projectSection: String
if userPRJ.isEmpty {
projectSection = """
DEFAULT_PRJ="$HOME/Projects/clawdis"
if [ -d "$DEFAULT_PRJ" ]; then
PRJ="$DEFAULT_PRJ"
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
fi
"""
let projectSection = if userPRJ.isEmpty {
"""
DEFAULT_PRJ="$HOME/Projects/clawdis"
if [ -d "$DEFAULT_PRJ" ]; then
PRJ="$DEFAULT_PRJ"
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
fi
"""
} else {
projectSection = """
PRJ=\(self.shellQuote(userPRJ))
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
"""
"""
PRJ=\(self.shellQuote(userPRJ))
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
"""
}
let scriptBody = """
@@ -448,7 +466,11 @@ enum CommandResolver {
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 let parsed = self.parseSSHTarget(settings.target) else { return nil }

View File

@@ -55,14 +55,14 @@ final class VoicePushToTalkHotkey {
}
let chordActive = self.optionDown
if chordActive && !self.active {
if chordActive, !self.active {
self.active = true
Task {
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
.info("ptt hotkey down")
await VoicePushToTalk.shared.begin()
}
} else if !chordActive && self.active {
} else if !chordActive, self.active {
self.active = false
Task {
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.
await VoiceWakeRuntime.shared.pauseForPushToTalk()
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 {
VoiceWakeOverlayController.shared.startSession(
source: .pushToTalk,
@@ -166,7 +169,10 @@ actor VoicePushToTalk {
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
self.recognizer = SFSpeechRecognizer(locale: locale)
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()
@@ -216,7 +222,10 @@ actor VoicePushToTalk {
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
if let token = self.overlayToken {
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),
volatile: self.volatile,
isFinal: true)
let forward: VoiceWakeForwardConfig
if let cached = self.activeConfig?.forwardConfig {
forward = cached
let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig {
cached
} else {
forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
}
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)

View File

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

View File

@@ -20,7 +20,12 @@ enum VoiceWakeForwarder {
?? ProcessInfo.processInfo.hostName
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() {
@@ -33,8 +38,8 @@ enum VoiceWakeForwarder {
var errorDescription: String? {
switch self {
case let .rpcFailed(message): return message
case .disabled: return "Voice wake forwarding disabled"
case let .rpcFailed(message): message
case .disabled: "Voice wake forwarding disabled"
}
}
}
@@ -149,7 +154,7 @@ enum VoiceWakeForwarder {
}
continue
}
if ch.isWhitespace && quote == nil {
if ch.isWhitespace, quote == nil {
flush()
continue
}

View File

@@ -20,7 +20,7 @@ final class VoiceWakeOverlayController: ObservableObject {
var isVisible: Bool = false
var forwardEnabled: Bool = false
var isSending: Bool = false
var attributed: NSAttributedString = NSAttributedString(string: "")
var attributed: NSAttributedString = .init(string: "")
var isOverflowing: Bool = false
var isEditing: Bool = false
var level: Double = 0 // normalized 0...1 speech level for UI
@@ -52,7 +52,11 @@ final class VoiceWakeOverlayController: ObservableObject {
isFinal: Bool = false) -> 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.activeSource = source
self.forwardConfig = nil
@@ -76,7 +80,11 @@ final class VoiceWakeOverlayController: ObservableObject {
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
guard self.guardToken(token, context: "partial") 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.forwardConfig = nil
self.model.text = transcript
@@ -99,7 +107,13 @@ final class VoiceWakeOverlayController: ObservableObject {
attributed: NSAttributedString? = nil)
{
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.autoSendToken = token
self.forwardConfig = forwardConfig
@@ -142,7 +156,13 @@ final class VoiceWakeOverlayController: ObservableObject {
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
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
if self.model.isSending { return }
self.model.isEditing = false
@@ -159,7 +179,8 @@ final class VoiceWakeOverlayController: ObservableObject {
}
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")
}
@@ -176,7 +197,14 @@ final class VoiceWakeOverlayController: ObservableObject {
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
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.model.isSending = false
self.model.isEditing = false
@@ -237,7 +265,9 @@ final class VoiceWakeOverlayController: ObservableObject {
guard let window else { return }
if !self.model.isVisible {
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.
AppStateStore.shared.triggerVoiceEars(ttl: nil)
let start = target.offsetBy(dx: 0, dy: -6)
@@ -309,7 +339,8 @@ final class VoiceWakeOverlayController: ObservableObject {
}
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 textInset = NSSize(width: 2, height: 6)
@@ -350,7 +381,13 @@ final class VoiceWakeOverlayController: ObservableObject {
}
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.autoSendToken = token
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
@@ -360,7 +397,9 @@ final class VoiceWakeOverlayController: ObservableObject {
await MainActor.run {
guard let self 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.autoSendTask = nil
}
@@ -376,6 +415,7 @@ final class VoiceWakeOverlayController: ObservableObject {
])
}
}
private struct VoiceWakeOverlayView: View {
@ObservedObject var controller: VoiceWakeOverlayController
@FocusState private var textFocused: Bool
@@ -469,9 +509,8 @@ private struct VoiceWakeOverlayView: View {
// Close button rendered above and outside the clipped bubble
CloseButtonOverlay(
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
onHover: { self.closeHovering = $0 }) {
self.controller.cancelEditingAndDismiss()
}
onHover: { self.closeHovering = $0 },
onClose: { self.controller.cancelEditingAndDismiss() })
}
.padding(.top, self.controller.closeOverflow)
.padding(.leading, self.controller.closeOverflow)
@@ -629,7 +668,6 @@ private struct VibrantLabelView: NSViewRepresentable {
label.attributedStringValue = self.attributed.strippingForegroundColor()
label.textColor = .labelColor
}
}
private final class ClickCatcher: NSView {
@@ -675,8 +713,8 @@ private struct CloseButtonOverlay: View {
var body: some View {
Group {
if isVisible {
Button(action: onClose) {
if self.isVisible {
Button(action: self.onClose) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.white.opacity(0.9))
@@ -695,7 +733,7 @@ private struct CloseButtonOverlay: View {
.transition(.opacity)
}
}
.allowsHitTesting(isVisible)
.allowsHitTesting(self.isVisible)
}
}
@@ -723,7 +761,7 @@ private final class TranscriptNSTextView: NSTextView {
self.onEscape?()
return
}
if isReturn && event.modifierFlags.contains(.command) {
if isReturn, event.modifierFlags.contains(.command) {
self.onSend?()
return
}

View File

@@ -136,7 +136,12 @@ actor VoiceWakeRuntime {
guard let self else { return }
let transcript = result?.bestTranscription.formattedString
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")
@@ -213,7 +218,10 @@ actor VoiceWakeRuntime {
let snapshot = self.committedTranscript + self.volatileTranscript
if let token = self.overlayToken {
await MainActor.run {
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed)
VoiceWakeOverlayController.shared.updatePartial(
token: token,
transcript: snapshot,
attributed: attributed)
}
}
}
@@ -335,10 +343,10 @@ actor VoiceWakeRuntime {
VoiceWakeOverlayController.shared.presentFinal(
token: token,
transcript: finalTranscript,
forwardConfig: forwardConfig,
autoSendAfter: delay,
sendChime: sendChime,
attributed: finalAttributed)
forwardConfig: forwardConfig,
autoSendAfter: delay,
sendChime: sendChime,
attributed: finalAttributed)
}
} else if forwardConfig.enabled, !finalTranscript.isEmpty {
if sendChime != .none {

View File

@@ -49,7 +49,10 @@ struct VoiceWakeSettings: View {
SettingsToggleRow(
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)
.disabled(!voiceWakeSupported)

View File

@@ -67,7 +67,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func bootstrap() async {
do {
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)
self.baseEndpoint = endpoint
@@ -90,9 +93,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func prepareEndpoint(remotePort: Int) async throws -> URL {
if CommandResolver.connectionModeIsRemote() {
return try await self.startOrRestartTunnel()
try await self.startOrRestartTunnel()
} 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)
if let http = response as? HTTPURLResponse {
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 {
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
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.tunnelRestartEnabled = true
@@ -162,7 +171,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private func showError(_ text: String) {
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)
}
@@ -247,7 +257,7 @@ final class WebChatTunnel {
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-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)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -263,7 +273,9 @@ final class WebChatTunnel {
// Consume stderr so ssh cannot block if it logs
pipe.fileHandleForReading.readabilityHandler = { handle in
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)")
}
try process.run()
@@ -272,7 +284,7 @@ final class WebChatTunnel {
}
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
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)

View File

@@ -44,8 +44,8 @@ final class WorkActivityStore: ObservableObject {
phase: String,
name: String?,
meta: String?,
args: [String: AnyCodable]?
) {
args: [String: AnyCodable]?)
{
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
if phase.lowercased() == "start" {
@@ -124,7 +124,7 @@ final class WorkActivityStore: ObservableObject {
return
}
// 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
} else {
self.currentSessionKey = nil
@@ -145,20 +145,20 @@ final class WorkActivityStore: ObservableObject {
private static func mapToolKind(_ name: String?) -> ToolKind {
switch name?.lowercased() {
case "bash", "shell": return .bash
case "read": return .read
case "write": return .write
case "edit": return .edit
case "attach": return .attach
default: return .other
case "bash", "shell": .bash
case "read": .read
case "write": .write
case "edit": .edit
case "attach": .attach
default: .other
}
}
private static func buildLabel(
kind: ToolKind,
meta: String?,
args: [String: AnyCodable]?
) -> String {
args: [String: AnyCodable]?) -> String
{
switch kind {
case .bash:
if let cmd = args?["command"]?.value as? String {
@@ -166,7 +166,7 @@ final class WorkActivityStore: ObservableObject {
}
return "bash"
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
@@ -179,9 +179,9 @@ final class WorkActivityStore: ObservableObject {
}
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?["file_path"]?.value as? String { return shortenHome(path: p) }
if let meta { return shortenHome(path: meta) }
if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
if let meta { return self.shortenHome(path: meta) }
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 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
Returns JSON to stdout:

View File

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

View File

@@ -12,8 +12,9 @@ import Testing
}
private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@@ -23,7 +24,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path)
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
try makeExec(at: clawdisPath)
try self.makeExec(at: clawdisPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
@@ -35,10 +36,10 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
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 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) }
setenv("CLAWDIS_RUNTIME", "node", 1)
@@ -63,7 +64,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try makeExec(at: pnpmPath)
try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
@@ -75,7 +76,7 @@ import Testing
CommandResolver.setProjectRoot(tmp.path)
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"])

View File

@@ -3,12 +3,10 @@ import Testing
@testable import Clawdis
@Suite struct HealthDecodeTests {
private let sampleJSON: String = {
// minimal but complete payload
private let sampleJSON: String = // 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}}
"""
}()
@Test func decodesCleanJSON() async throws {
let data = Data(sampleJSON.utf8)
@@ -20,7 +18,7 @@ import Testing
}
@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))
#expect(snap?.web.connect?.status == 200)

View File

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

View File

@@ -33,7 +33,8 @@ import Testing
@Test func trimsAfterFirstMatchingTrigger() {
let triggers = ["buddy", "claude"]
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() {