mac: remove voice wake forward pref
This commit is contained in:
@@ -2,7 +2,9 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct ControlRequestParams: @unchecked Sendable {
|
struct ControlRequestParams: @unchecked Sendable {
|
||||||
let raw: [String: AnyHashable]
|
/// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]).
|
||||||
|
/// `@unchecked Sendable` is intentional: values are treated as immutable payloads.
|
||||||
|
let raw: [String: Any]
|
||||||
}
|
}
|
||||||
|
|
||||||
actor AgentRPC {
|
actor AgentRPC {
|
||||||
@@ -35,7 +37,7 @@ actor AgentRPC {
|
|||||||
do {
|
do {
|
||||||
_ = try await self.controlRequest(
|
_ = try await self.controlRequest(
|
||||||
method: "set-heartbeats",
|
method: "set-heartbeats",
|
||||||
params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
|
params: ControlRequestParams(raw: ["enabled": enabled]))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -65,13 +67,13 @@ actor AgentRPC {
|
|||||||
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
let params: [String: AnyHashable] = [
|
let params: [String: Any] = [
|
||||||
"message": AnyHashable(text),
|
"message": text,
|
||||||
"sessionId": AnyHashable(session),
|
"sessionId": session,
|
||||||
"thinking": AnyHashable(thinking ?? "default"),
|
"thinking": thinking ?? "default",
|
||||||
"deliver": AnyHashable(deliver),
|
"deliver": deliver,
|
||||||
"to": AnyHashable(to ?? ""),
|
"to": to ?? "",
|
||||||
"idempotencyKey": AnyHashable(UUID().uuidString),
|
"idempotencyKey": UUID().uuidString,
|
||||||
]
|
]
|
||||||
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||||
return (true, nil, nil)
|
return (true, nil, nil)
|
||||||
|
|||||||
@@ -108,18 +108,6 @@ final class AppState: ObservableObject {
|
|||||||
forKey: voiceWakeAdditionalLocalesKey) } }
|
forKey: voiceWakeAdditionalLocalesKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var voiceWakeForwardEnabled: Bool {
|
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
|
||||||
self.voiceWakeForwardEnabled,
|
|
||||||
forKey: voiceWakeForwardEnabledKey) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var voiceWakeForwardCommand: String {
|
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
|
||||||
self.voiceWakeForwardCommand,
|
|
||||||
forKey: voiceWakeForwardCommandKey) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var voicePushToTalkEnabled: Bool {
|
@Published var voicePushToTalkEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||||
self.voicePushToTalkEnabled,
|
self.voicePushToTalkEnabled,
|
||||||
@@ -210,18 +198,8 @@ final class AppState: ObservableObject {
|
|||||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||||
self.voiceWakeForwardEnabled = UserDefaults.standard.bool(forKey: voiceWakeForwardEnabledKey)
|
|
||||||
self.voicePushToTalkEnabled = UserDefaults.standard
|
self.voicePushToTalkEnabled = UserDefaults.standard
|
||||||
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
||||||
|
|
||||||
var storedForwardCommand = UserDefaults.standard
|
|
||||||
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
|
|
||||||
// Guard against older prefs missing flags; the forwarder depends on these for replies.
|
|
||||||
if !storedForwardCommand.contains("--deliver") || !storedForwardCommand.contains("--session") {
|
|
||||||
storedForwardCommand = defaultVoiceWakeForwardCommand
|
|
||||||
UserDefaults.standard.set(storedForwardCommand, forKey: voiceWakeForwardCommandKey)
|
|
||||||
}
|
|
||||||
self.voiceWakeForwardCommand = storedForwardCommand
|
|
||||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||||
self.heartbeatsEnabled = storedHeartbeats
|
self.heartbeatsEnabled = storedHeartbeats
|
||||||
} else {
|
} else {
|
||||||
@@ -350,8 +328,6 @@ extension AppState {
|
|||||||
state.voiceWakeMicID = "BuiltInMic"
|
state.voiceWakeMicID = "BuiltInMic"
|
||||||
state.voiceWakeLocaleID = Locale.current.identifier
|
state.voiceWakeLocaleID = Locale.current.identifier
|
||||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||||
state.voiceWakeForwardEnabled = true
|
|
||||||
state.voiceWakeForwardCommand = defaultVoiceWakeForwardCommand
|
|
||||||
state.voicePushToTalkEnabled = false
|
state.voicePushToTalkEnabled = false
|
||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
@@ -396,15 +372,6 @@ enum AppStateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppState {
|
|
||||||
var voiceWakeForwardConfig: VoiceWakeForwardConfig {
|
|
||||||
VoiceWakeForwardConfig(
|
|
||||||
enabled: self.voiceWakeForwardEnabled,
|
|
||||||
commandTemplate: self.voiceWakeForwardCommand,
|
|
||||||
timeout: defaultVoiceWakeForwardTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum AppActivationPolicy {
|
enum AppActivationPolicy {
|
||||||
static func apply(showDockIcon: Bool) {
|
static func apply(showDockIcon: Bool) {
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
|||||||
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
||||||
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
||||||
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
||||||
let voiceWakeForwardEnabledKey = "clawdis.voiceWakeForwardEnabled"
|
|
||||||
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
|
|
||||||
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
||||||
let iconOverrideKey = "clawdis.iconOverride"
|
let iconOverrideKey = "clawdis.iconOverride"
|
||||||
let connectionModeKey = "clawdis.connectionMode"
|
let connectionModeKey = "clawdis.connectionMode"
|
||||||
@@ -31,6 +29,3 @@ let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
|||||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||||
let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low --session main --deliver"
|
|
||||||
// Allow enough time for remote agent responses (LLM replies often take >10s).
|
|
||||||
let defaultVoiceWakeForwardTimeout: TimeInterval = 30
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ final actor ControlSocketServer {
|
|||||||
private let maxRequestBytes = 512 * 1024
|
private let maxRequestBytes = 512 * 1024
|
||||||
private let allowedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
private let allowedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
||||||
|
|
||||||
|
private func disableSigPipe(fd: Int32) {
|
||||||
|
var one: Int32 = 1
|
||||||
|
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
// Already running
|
// Already running
|
||||||
guard self.listenFD == -1 else { return }
|
guard self.listenFD == -1 else { return }
|
||||||
@@ -75,6 +80,7 @@ final actor ControlSocketServer {
|
|||||||
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
|
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
|
||||||
let client = accept(listenFD, &addr, &len)
|
let client = accept(listenFD, &addr, &len)
|
||||||
guard client >= 0 else { return }
|
guard client >= 0 else { return }
|
||||||
|
self.disableSigPipe(fd: client)
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
defer { close(client) }
|
defer { close(client) }
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|||||||
@@ -63,45 +63,13 @@ enum DebugActions {
|
|||||||
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
|
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
|
||||||
if you received that.
|
if you received that.
|
||||||
"""
|
"""
|
||||||
let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
let result = await VoiceWakeForwarder.forward(transcript: message)
|
||||||
let shouldForward = config.enabled
|
switch result {
|
||||||
|
case .success:
|
||||||
if shouldForward {
|
return .success("Sent. Await reply.")
|
||||||
let result = await VoiceWakeForwarder.forward(transcript: message, config: config)
|
case let .failure(error):
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
return .success("Forwarded. Await reply.")
|
|
||||||
case let .failure(error):
|
|
||||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return .failure(.message("Forward failed: \(detail)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let status = await AgentRPC.shared.status()
|
|
||||||
if !status.ok {
|
|
||||||
try await AgentRPC.shared.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
let rpcResult = await AgentRPC.shared.send(
|
|
||||||
text: message,
|
|
||||||
thinking: "low",
|
|
||||||
session: "main",
|
|
||||||
deliver: true,
|
|
||||||
to: nil)
|
|
||||||
|
|
||||||
if rpcResult.ok {
|
|
||||||
return .success("Sent locally via voice wake path.")
|
|
||||||
} else {
|
|
||||||
let reason = rpcResult.error?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let detail = (reason?.isEmpty == false)
|
|
||||||
? reason!
|
|
||||||
: "No error returned. Check logs or rpc output."
|
|
||||||
return .failure(.message("Local send failed: \(detail)"))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return .failure(.message("Local send failed: \(detail)"))
|
return .failure(.message("Send failed: \(detail)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ actor VoicePushToTalk {
|
|||||||
private struct Config {
|
private struct Config {
|
||||||
let micID: String?
|
let micID: String?
|
||||||
let localeID: String?
|
let localeID: String?
|
||||||
let forwardConfig: VoiceWakeForwardConfig
|
|
||||||
let triggerChime: VoiceWakeChime
|
let triggerChime: VoiceWakeChime
|
||||||
let sendChime: VoiceWakeChime
|
let sendChime: VoiceWakeChime
|
||||||
}
|
}
|
||||||
@@ -263,12 +262,6 @@ actor VoicePushToTalk {
|
|||||||
return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines)
|
return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}()
|
}()
|
||||||
let finalText = Self.join(self.adoptedPrefix, finalRecognized)
|
let finalText = Self.join(self.adoptedPrefix, finalRecognized)
|
||||||
|
|
||||||
let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig {
|
|
||||||
cached
|
|
||||||
} else {
|
|
||||||
await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
|
||||||
}
|
|
||||||
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
||||||
|
|
||||||
let token = self.overlayToken
|
let token = self.overlayToken
|
||||||
@@ -279,18 +272,15 @@ actor VoicePushToTalk {
|
|||||||
VoiceSessionCoordinator.shared.finalize(
|
VoiceSessionCoordinator.shared.finalize(
|
||||||
token: token,
|
token: token,
|
||||||
text: finalText,
|
text: finalText,
|
||||||
forwardConfig: forward,
|
|
||||||
sendChime: chime,
|
sendChime: chime,
|
||||||
autoSendAfter: nil)
|
autoSendAfter: nil)
|
||||||
VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason)
|
VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason)
|
||||||
} else if !finalText.isEmpty, forward.enabled {
|
} else if !finalText.isEmpty {
|
||||||
if chime != .none {
|
if chime != .none {
|
||||||
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
|
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
|
||||||
}
|
}
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(
|
await VoiceWakeForwarder.forward(transcript: finalText)
|
||||||
transcript: VoiceWakeForwarder.prefixedTranscript(finalText),
|
|
||||||
config: forward)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +309,6 @@ actor VoicePushToTalk {
|
|||||||
return Config(
|
return Config(
|
||||||
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
|
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
|
||||||
localeID: state.voiceWakeLocaleID,
|
localeID: state.voiceWakeLocaleID,
|
||||||
forwardConfig: state.voiceWakeForwardConfig,
|
|
||||||
triggerChime: state.voiceWakeTriggerChime,
|
triggerChime: state.voiceWakeTriggerChime,
|
||||||
sendChime: state.voiceWakeSendChime)
|
sendChime: state.voiceWakeSendChime)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
var text: String
|
var text: String
|
||||||
var attributed: NSAttributedString?
|
var attributed: NSAttributedString?
|
||||||
var isFinal: Bool
|
var isFinal: Bool
|
||||||
var forwardConfig: VoiceWakeForwardConfig?
|
|
||||||
var sendChime: VoiceWakeChime
|
var sendChime: VoiceWakeChime
|
||||||
var autoSendDelay: TimeInterval?
|
var autoSendDelay: TimeInterval?
|
||||||
}
|
}
|
||||||
@@ -45,7 +44,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
text: text,
|
text: text,
|
||||||
attributed: attributedText,
|
attributed: attributedText,
|
||||||
isFinal: false,
|
isFinal: false,
|
||||||
forwardConfig: forwardEnabled ? AppStateStore.shared.voiceWakeForwardConfig : nil,
|
|
||||||
sendChime: .none,
|
sendChime: .none,
|
||||||
autoSendDelay: nil)
|
autoSendDelay: nil)
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -69,7 +67,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
func finalize(
|
func finalize(
|
||||||
token: UUID,
|
token: UUID,
|
||||||
text: String,
|
text: String,
|
||||||
forwardConfig: VoiceWakeForwardConfig,
|
|
||||||
sendChime: VoiceWakeChime,
|
sendChime: VoiceWakeChime,
|
||||||
autoSendAfter: TimeInterval?)
|
autoSendAfter: TimeInterval?)
|
||||||
{
|
{
|
||||||
@@ -79,7 +76,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
||||||
self.session?.text = text
|
self.session?.text = text
|
||||||
self.session?.isFinal = true
|
self.session?.isFinal = true
|
||||||
self.session?.forwardConfig = forwardConfig
|
|
||||||
self.session?.sendChime = sendChime
|
self.session?.sendChime = sendChime
|
||||||
self.session?.autoSendDelay = autoSendAfter
|
self.session?.autoSendDelay = autoSendAfter
|
||||||
|
|
||||||
@@ -87,7 +83,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
VoiceWakeOverlayController.shared.presentFinal(
|
VoiceWakeOverlayController.shared.presentFinal(
|
||||||
token: token,
|
token: token,
|
||||||
transcript: text,
|
transcript: text,
|
||||||
forwardConfig: forwardConfig,
|
|
||||||
autoSendAfter: autoSendAfter,
|
autoSendAfter: autoSendAfter,
|
||||||
sendChime: sendChime,
|
sendChime: sendChime,
|
||||||
attributed: attributed)
|
attributed: attributed)
|
||||||
@@ -96,12 +91,6 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
func sendNow(token: UUID, reason: String = "explicit") {
|
func sendNow(token: UUID, reason: String = "explicit") {
|
||||||
guard let session, session.token == token else { return }
|
guard let session, session.token == token else { return }
|
||||||
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let forward = session.forwardConfig, forward.enabled else {
|
|
||||||
self.logger.info("coordinator sendNow \(reason) no forward config -> dismiss")
|
|
||||||
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .explicit, outcome: .empty)
|
|
||||||
self.clearSession()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !text.isEmpty else {
|
guard !text.isEmpty else {
|
||||||
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
|
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
|
||||||
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
|
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
|
||||||
@@ -110,9 +99,7 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
|
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
|
||||||
Task.detached {
|
Task.detached {
|
||||||
_ = await VoiceWakeForwarder.forward(
|
_ = await VoiceWakeForwarder.forward(transcript: text)
|
||||||
transcript: VoiceWakeForwarder.prefixedTranscript(text),
|
|
||||||
config: forward)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
enum VoiceWakeForwardStatus: Equatable {
|
|
||||||
case idle
|
|
||||||
case checking
|
|
||||||
case ok
|
|
||||||
case failed(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VoiceWakeForwardSection: View {
|
|
||||||
@Binding var enabled: Bool
|
|
||||||
@Binding var target: String
|
|
||||||
@Binding var identity: String
|
|
||||||
@Binding var command: String
|
|
||||||
@Binding var showAdvanced: Bool
|
|
||||||
@Binding var status: VoiceWakeForwardStatus
|
|
||||||
let onTest: () -> Void
|
|
||||||
let onChange: () -> Void
|
|
||||||
var showToggle: Bool = true
|
|
||||||
var title: String = "Forward wake to host (SSH)"
|
|
||||||
var subtitle: String = "Send wake transcripts to a remote Clawdis host."
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if self.showToggle {
|
|
||||||
Toggle(isOn: self.$enabled) {
|
|
||||||
Text(self.title)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(self.title)
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
Text(self.subtitle)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.enabled {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Text("SSH")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: 40, alignment: .leading)
|
|
||||||
TextField("steipete@peters-mac-studio-1", text: self.$target)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.onChange(of: self.target) { _, _ in
|
|
||||||
self.onChange()
|
|
||||||
}
|
|
||||||
self.statusIcon
|
|
||||||
.frame(width: 16, height: 16, alignment: .center)
|
|
||||||
Button("Test") { self.onTest() }
|
|
||||||
.disabled(self.target.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
if case let .failed(message) = self.status {
|
|
||||||
Text(message)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisclosureGroup(isExpanded: self.$showAdvanced) {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
LabeledContent("Identity file") {
|
|
||||||
TextField(
|
|
||||||
"/Users/you/.ssh/voicewake_ed25519",
|
|
||||||
text: self.$identity)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: 320)
|
|
||||||
.onChange(of: self.identity) { _, _ in
|
|
||||||
self.onChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Remote command template")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
TextField(
|
|
||||||
"clawdis-mac agent --message \"${text}\" --thinking low",
|
|
||||||
text: self.$command,
|
|
||||||
axis: .vertical)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onChange(of: self.command) { _, _ in
|
|
||||||
self.onChange()
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"${text} is replaced with the transcript."
|
|
||||||
+ "\nIt is also piped to stdin if you prefer $(cat).")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
} label: {
|
|
||||||
Text("Advanced")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusIcon: some View {
|
|
||||||
Group {
|
|
||||||
switch self.status {
|
|
||||||
case .idle:
|
|
||||||
Image(systemName: "circle.dashed").foregroundStyle(.secondary)
|
|
||||||
case .checking:
|
|
||||||
ProgressView().controlSize(.mini)
|
|
||||||
case .ok:
|
|
||||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
|
||||||
case .failed:
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct VoiceWakeForwardConfig: Sendable {
|
|
||||||
let enabled: Bool
|
|
||||||
let commandTemplate: String
|
|
||||||
let timeout: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VoiceWakeForwarder {
|
enum VoiceWakeForwarder {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward")
|
||||||
|
|
||||||
@@ -28,35 +22,32 @@ enum VoiceWakeForwarder {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clearCliCache() {
|
|
||||||
// Legacy no-op; CLI caching removed now that we rely on AgentRPC.
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VoiceWakeForwardError: LocalizedError, Equatable {
|
enum VoiceWakeForwardError: LocalizedError, Equatable {
|
||||||
case rpcFailed(String)
|
case rpcFailed(String)
|
||||||
case disabled
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .rpcFailed(message): message
|
case let .rpcFailed(message): message
|
||||||
case .disabled: "Voice wake forwarding disabled"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ForwardOptions: Sendable {
|
||||||
|
var session: String = "main"
|
||||||
|
var thinking: String = "low"
|
||||||
|
var deliver: Bool = true
|
||||||
|
var to: String?
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func forward(
|
static func forward(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
config: VoiceWakeForwardConfig) async -> Result<Void, VoiceWakeForwardError>
|
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||||
{
|
{
|
||||||
guard config.enabled else { return .failure(.disabled) }
|
|
||||||
let payload = Self.prefixedTranscript(transcript)
|
let payload = Self.prefixedTranscript(transcript)
|
||||||
let options = self.parseCommandTemplate(config.commandTemplate)
|
|
||||||
let thinking = options.thinking ?? "default"
|
|
||||||
|
|
||||||
let result = await AgentRPC.shared.send(
|
let result = await AgentRPC.shared.send(
|
||||||
text: payload,
|
text: payload,
|
||||||
thinking: thinking,
|
thinking: options.thinking,
|
||||||
session: options.session,
|
session: options.session,
|
||||||
deliver: options.deliver,
|
deliver: options.deliver,
|
||||||
to: options.to)
|
to: options.to)
|
||||||
@@ -71,102 +62,9 @@ enum VoiceWakeForwarder {
|
|||||||
return .failure(.rpcFailed(message))
|
return .failure(.rpcFailed(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func checkConnection(config: VoiceWakeForwardConfig) async -> Result<Void, VoiceWakeForwardError> {
|
static func checkConnection() async -> Result<Void, VoiceWakeForwardError> {
|
||||||
guard config.enabled else { return .failure(.disabled) }
|
|
||||||
let status = await AgentRPC.shared.status()
|
let status = await AgentRPC.shared.status()
|
||||||
if status.ok { return .success(()) }
|
if status.ok { return .success(()) }
|
||||||
return .failure(.rpcFailed(status.error ?? "agent rpc unreachable"))
|
return .failure(.rpcFailed(status.error ?? "agent rpc unreachable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func shellEscape(_ text: String) -> String {
|
|
||||||
// Single-quote based shell escaping.
|
|
||||||
let replaced = text.replacingOccurrences(of: "'", with: "'\\''")
|
|
||||||
return "'\(replaced)'"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Template parsing
|
|
||||||
|
|
||||||
struct ForwardOptions {
|
|
||||||
var session: String = "main"
|
|
||||||
var thinking: String? = "low"
|
|
||||||
var deliver: Bool = true
|
|
||||||
var to: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseCommandTemplate(_ template: String) -> ForwardOptions {
|
|
||||||
var options = ForwardOptions()
|
|
||||||
let parts = self.tokenize(template)
|
|
||||||
var idx = 0
|
|
||||||
while idx < parts.count {
|
|
||||||
let part = parts[idx]
|
|
||||||
switch part {
|
|
||||||
case "--session", "--session-id":
|
|
||||||
if idx + 1 < parts.count { options.session = parts[idx + 1] }
|
|
||||||
idx += 1
|
|
||||||
case "--thinking":
|
|
||||||
if idx + 1 < parts.count { options.thinking = parts[idx + 1] }
|
|
||||||
idx += 1
|
|
||||||
case "--deliver":
|
|
||||||
options.deliver = true
|
|
||||||
case "--no-deliver":
|
|
||||||
options.deliver = false
|
|
||||||
case "--to":
|
|
||||||
if idx + 1 < parts.count { options.to = parts[idx + 1] }
|
|
||||||
idx += 1
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
idx += 1
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func tokenize(_ text: String) -> [String] {
|
|
||||||
var tokens: [String] = []
|
|
||||||
var current = ""
|
|
||||||
var quote: Character?
|
|
||||||
var escapeNext = false
|
|
||||||
|
|
||||||
func flush() {
|
|
||||||
if !current.isEmpty {
|
|
||||||
tokens.append(current)
|
|
||||||
current = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for ch in text {
|
|
||||||
if escapeNext {
|
|
||||||
current.append(ch)
|
|
||||||
escapeNext = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == "\\" && quote == "\"" {
|
|
||||||
escapeNext = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == "\"" || ch == "'" {
|
|
||||||
if quote == ch {
|
|
||||||
quote = nil
|
|
||||||
} else if quote == nil {
|
|
||||||
quote = ch
|
|
||||||
} else {
|
|
||||||
current.append(ch)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch.isWhitespace, quote == nil {
|
|
||||||
flush()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current.append(ch)
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
static func _testParseCommandTemplate(_ template: String) -> ForwardOptions {
|
|
||||||
self.parseCommandTemplate(template)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
||||||
private var autoSendTask: Task<Void, Never>?
|
private var autoSendTask: Task<Void, Never>?
|
||||||
private var autoSendToken: UUID?
|
private var autoSendToken: UUID?
|
||||||
private var forwardConfig: VoiceWakeForwardConfig?
|
|
||||||
private var activeToken: UUID?
|
private var activeToken: UUID?
|
||||||
private var activeSource: Source?
|
private var activeSource: Source?
|
||||||
|
|
||||||
@@ -64,7 +63,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
self.logger.log(level: .info, "\(message)")
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.activeToken = token
|
self.activeToken = token
|
||||||
self.activeSource = source
|
self.activeSource = source
|
||||||
self.forwardConfig = nil
|
|
||||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||||
self.model.text = transcript
|
self.model.text = transcript
|
||||||
self.model.isFinal = isFinal
|
self.model.isFinal = isFinal
|
||||||
@@ -91,7 +89,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
self.logger.log(level: .info, "\(message)")
|
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.model.text = transcript
|
self.model.text = transcript
|
||||||
self.model.isFinal = false
|
self.model.isFinal = false
|
||||||
self.model.forwardEnabled = false
|
self.model.forwardEnabled = false
|
||||||
@@ -106,7 +103,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
func presentFinal(
|
func presentFinal(
|
||||||
token: UUID,
|
token: UUID,
|
||||||
transcript: String,
|
transcript: String,
|
||||||
forwardConfig: VoiceWakeForwardConfig,
|
|
||||||
autoSendAfter delay: TimeInterval?,
|
autoSendAfter delay: TimeInterval?,
|
||||||
sendChime: VoiceWakeChime = .none,
|
sendChime: VoiceWakeChime = .none,
|
||||||
attributed: NSAttributedString? = nil)
|
attributed: NSAttributedString? = nil)
|
||||||
@@ -116,15 +112,14 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
overlay presentFinal token=\(token.uuidString) \
|
overlay presentFinal token=\(token.uuidString) \
|
||||||
len=\(transcript.count) \
|
len=\(transcript.count) \
|
||||||
autoSendAfter=\(delay ?? -1) \
|
autoSendAfter=\(delay ?? -1) \
|
||||||
forwardEnabled=\(forwardConfig.enabled)
|
forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
"""
|
"""
|
||||||
self.logger.log(level: .info, "\(message)")
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel()
|
self.autoSendTask?.cancel()
|
||||||
self.autoSendToken = token
|
self.autoSendToken = token
|
||||||
self.forwardConfig = forwardConfig
|
|
||||||
self.model.text = transcript
|
self.model.text = transcript
|
||||||
self.model.isFinal = true
|
self.model.isFinal = true
|
||||||
self.model.forwardEnabled = forwardConfig.enabled
|
self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
self.model.isSending = false
|
self.model.isSending = false
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||||
@@ -228,7 +223,6 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
self.model.level = 0
|
self.model.level = 0
|
||||||
self.activeToken = nil
|
self.activeToken = nil
|
||||||
self.activeSource = nil
|
self.activeSource = nil
|
||||||
self.forwardConfig = nil
|
|
||||||
if outcome == .empty {
|
if outcome == .empty {
|
||||||
AppStateStore.shared.blinkOnce()
|
AppStateStore.shared.blinkOnce()
|
||||||
} else if outcome == .sent {
|
} else if outcome == .sent {
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ actor VoiceWakeRuntime {
|
|||||||
await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) }
|
await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
|
||||||
let delay: TimeInterval = 0.0
|
let delay: TimeInterval = 0.0
|
||||||
let sendChime = finalTranscript.isEmpty ? .none : config.sendChime
|
let sendChime = finalTranscript.isEmpty ? .none : config.sendChime
|
||||||
if let token = self.overlayToken {
|
if let token = self.overlayToken {
|
||||||
@@ -342,18 +341,15 @@ actor VoiceWakeRuntime {
|
|||||||
VoiceSessionCoordinator.shared.finalize(
|
VoiceSessionCoordinator.shared.finalize(
|
||||||
token: token,
|
token: token,
|
||||||
text: finalTranscript,
|
text: finalTranscript,
|
||||||
forwardConfig: forwardConfig,
|
|
||||||
sendChime: sendChime,
|
sendChime: sendChime,
|
||||||
autoSendAfter: delay)
|
autoSendAfter: delay)
|
||||||
}
|
}
|
||||||
} else if forwardConfig.enabled, !finalTranscript.isEmpty {
|
} else if !finalTranscript.isEmpty {
|
||||||
if sendChime != .none {
|
if sendChime != .none {
|
||||||
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
||||||
}
|
}
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(
|
await VoiceWakeForwarder.forward(transcript: finalTranscript)
|
||||||
transcript: VoiceWakeForwarder.prefixedTranscript(finalTranscript),
|
|
||||||
config: forwardConfig)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.overlayToken = nil
|
self.overlayToken = nil
|
||||||
|
|||||||
@@ -134,10 +134,8 @@ final class VoiceWakeTester {
|
|||||||
self.detectedText = text
|
self.detectedText = text
|
||||||
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
||||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||||
let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let payload = VoiceWakeForwarder.prefixedTranscript(text)
|
await VoiceWakeForwarder.forward(transcript: text)
|
||||||
await VoiceWakeForwarder.forward(transcript: payload, config: config)
|
|
||||||
}
|
}
|
||||||
Task { @MainActor in onUpdate(.detected(text)) }
|
Task { @MainActor in onUpdate(.detected(text)) }
|
||||||
self.holdUntilSilence(onUpdate: onUpdate)
|
self.holdUntilSilence(onUpdate: onUpdate)
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func prefersClawdisBinary() async throws {
|
@Test func prefersClawdisBinary() async throws {
|
||||||
|
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
defer {
|
||||||
|
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
}
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
@@ -31,6 +42,17 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func fallsBackToNodeAndScript() async throws {
|
@Test func fallsBackToNodeAndScript() async throws {
|
||||||
|
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
defer {
|
||||||
|
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
}
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
@@ -50,6 +72,17 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func fallsBackToPnpm() async throws {
|
@Test func fallsBackToPnpm() async throws {
|
||||||
|
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
defer {
|
||||||
|
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
}
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
@@ -62,6 +95,17 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func pnpmKeepsExtraArgsAfterSubcommand() async throws {
|
@Test func pnpmKeepsExtraArgsAfterSubcommand() async throws {
|
||||||
|
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
defer {
|
||||||
|
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
}
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
@@ -75,6 +119,17 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func preferredPathsStartWithProjectNodeBins() async throws {
|
@Test func preferredPathsStartWithProjectNodeBins() async throws {
|
||||||
|
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
defer {
|
||||||
|
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
||||||
|
}
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,6 @@ import Testing
|
|||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite(.serialized) struct VoiceWakeForwarderTests {
|
@Suite(.serialized) struct VoiceWakeForwarderTests {
|
||||||
@Test func shellEscapeHandlesQuotesAndParens() {
|
|
||||||
let text = "Debug test works (and a funny pun)"
|
|
||||||
let escaped = VoiceWakeForwarder.shellEscape(text)
|
|
||||||
#expect(escaped == "'Debug test works (and a funny pun)'")
|
|
||||||
|
|
||||||
let textWithQuote = "Debug test works (and a funny pun)'"
|
|
||||||
let escapedQuote = VoiceWakeForwarder.shellEscape(textWithQuote)
|
|
||||||
#expect(escapedQuote == "'Debug test works (and a funny pun)'\\'''")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func prefixedTranscriptUsesMachineName() {
|
@Test func prefixedTranscriptUsesMachineName() {
|
||||||
let transcript = "hello world"
|
let transcript = "hello world"
|
||||||
let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac")
|
let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac")
|
||||||
@@ -21,29 +11,11 @@ import Testing
|
|||||||
#expect(prefixed.hasSuffix("\n\nhello world"))
|
#expect(prefixed.hasSuffix("\n\nhello world"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parsesCommandTemplateOverrides() {
|
@Test func forwardOptionsDefaults() {
|
||||||
let opts = VoiceWakeForwarder._testParseCommandTemplate(
|
let opts = VoiceWakeForwarder.ForwardOptions()
|
||||||
"clawdis-mac agent --session alt --thinking high --no-deliver --to +123 --message \"${text}\"")
|
|
||||||
#expect(opts.session == "alt")
|
|
||||||
#expect(opts.thinking == "high")
|
|
||||||
#expect(opts.deliver == false)
|
|
||||||
#expect(opts.to == "+123")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func parsesCommandTemplateDefaults() {
|
|
||||||
let opts = VoiceWakeForwarder._testParseCommandTemplate("clawdis-mac agent --message \"${text}\"")
|
|
||||||
#expect(opts.session == "main")
|
#expect(opts.session == "main")
|
||||||
#expect(opts.thinking == "low")
|
#expect(opts.thinking == "low")
|
||||||
#expect(opts.deliver == true)
|
#expect(opts.deliver == true)
|
||||||
#expect(opts.to == nil)
|
#expect(opts.to == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parsesCommandTemplateWithQuotedValues() {
|
|
||||||
let opts = VoiceWakeForwarder._testParseCommandTemplate(
|
|
||||||
"clawdis-mac agent --session \"team chat\" --thinking \"deep focus\" --to \"+1 555 1212\" --message \"${text}\"")
|
|
||||||
#expect(opts.session == "team chat")
|
|
||||||
#expect(opts.thinking == "deep focus")
|
|
||||||
#expect(opts.deliver == true)
|
|
||||||
#expect(opts.to == "+1 555 1212")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ Updated: 2025-12-08 · Owners: mac app
|
|||||||
- Language & mic pickers, live level meter, trigger-word table, tester, forward target/command all remain unchanged.
|
- Language & mic pickers, live level meter, trigger-word table, tester, forward target/command all remain unchanged.
|
||||||
- **Sounds**: chimes on trigger detect and on send; defaults to the macOS “Glass” system sound. You can pick any `NSSound`-loadable file (e.g. MP3/WAV/AIFF) for each event or choose **No Sound**.
|
- **Sounds**: chimes on trigger detect and on send; defaults to the macOS “Glass” system sound. You can pick any `NSSound`-loadable file (e.g. MP3/WAV/AIFF) for each event or choose **No Sound**.
|
||||||
|
|
||||||
|
## Forwarding behavior
|
||||||
|
- When Voice Wake is enabled, transcripts are forwarded to the active gateway/agent (the same local vs remote mode used by the rest of the mac app).
|
||||||
|
|
||||||
## Forwarding payload
|
## Forwarding payload
|
||||||
- `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.
|
- `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user