Compare commits

..
275 changed files with 5556 additions and 17215 deletions

No files matched your search

-37
View File
@@ -170,43 +170,6 @@ jobs:
sleep $((attempt * 20))
done
exit 1
ios:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint / SwiftFormat
run: brew install swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Generate iOS project
run: |
-1
View File
@@ -16,7 +16,6 @@ bin/clawdis-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/ClawdisKit/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
+1 -4
View File
@@ -40,13 +40,10 @@
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
-36
View File
@@ -1,41 +1,5 @@
# Changelog
## Unreleased — 2025-12-23
### Fixes
- Telegram/WhatsApp: native replies now target the original inbound message; reply context is captured in `ReplyTo*` fields for templates. (Thanks @joshp123 for the PR and follow-up question.)
## 2.0.0-beta2 — 2025-12-21
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
### Highlights
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
### Gateway & providers
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
- Telegram: normalized chat IDs with clearer error reporting.
### Canvas & browser control
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
### macOS UI polish
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
### Build, CI, deps
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
### Docs
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
## 2.0.0-beta1 — 2025-12-19
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
Submodule Peekaboo updated: 9db365b73c...be03d6e3df.
+1 -14
View File
@@ -1,7 +1,7 @@
# 🦞 CLAWDIS — Personal AI Assistant
<p align="center">
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
</p>
<p align="center">
@@ -43,7 +43,6 @@ Your surfaces
- **Canvas** — a live visual workspace you can drive from the agent.
- **Automation-ready** — browser control, media handling, and tool streaming.
- **Local-first control plane** — the Gateway owns state, everything else connects.
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
## How it works (short)
@@ -76,17 +75,6 @@ pnpm clawdis agent --message "Ship checklist" --thinking high
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
## Chat commands
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
## Architecture
### TypeScript Gateway (src/gateway/server.ts)
@@ -183,7 +171,6 @@ Browser control (optional):
- [`docs/index.md`](docs/index.md) (overview)
- [`docs/configuration.md`](docs/configuration.md)
- [`docs/group-messages.md`](docs/group-messages.md)
- [`docs/gateway.md`](docs/gateway.md)
- [`docs/web.md`](docs/web.md)
- [`docs/discovery.md`](docs/discovery.md)
-11
View File
@@ -1,11 +0,0 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
+1 -20
View File
@@ -4,12 +4,10 @@ import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v15),
.iOS(.v17),
.macOS(.v26),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
@@ -21,30 +19,13 @@ let package = Package(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",
dependencies: [
+4 -8
View File
@@ -1,10 +1,9 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
@@ -31,7 +30,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
```swift
// Package.swift
@@ -39,10 +38,7 @@ dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
]
```
@@ -97,7 +93,7 @@ Environment variables:
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Requests volatile + final results; wake gating is string match on partial/final.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
@@ -2,13 +2,11 @@ import AVFoundation
import Foundation
import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
@@ -16,7 +14,6 @@ public enum SpeechPipelineError: Error {
}
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
@@ -1,202 +0,0 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { self.start + self.duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var bestIndex: Int?
var bestTriggerEnd: TimeInterval = 0
var bestGap: TimeInterval = 0
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
var matched = true
for t in 0..<count {
if tokens[i + t].normalized != trigger.tokens[t] {
matched = false
break
}
}
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let bestIndex, i <= bestIndex { continue }
bestIndex = i
bestTriggerEnd = triggerEnd
bestGap = gap
}
}
guard let bestIndex else { return nil }
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif
@@ -1,7 +1,6 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
@@ -1,9 +1,7 @@
import Commander
import Foundation
import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@@ -70,12 +68,17 @@ struct ServeCommand: ParsableCommand {
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
let lowered = text.lowercased()
if lowered.contains(cfg.wake.word.lowercased()) { return true }
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.stripWake(text: text, triggers: triggers)
var out = text
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
+2 -9
View File
@@ -1,7 +1,6 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
private func runCLI() async -> Int32 {
do {
@@ -16,7 +15,6 @@ private func runCLI() async -> Int32 {
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
@@ -97,10 +95,5 @@ private func dispatch(invocation: CommandInvocation) async throws {
}
}
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}
let exitCode = await runCLI()
exit(exitCode)
@@ -1,63 +0,0 @@
import Foundation
import Testing
import SwabbleKit
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
+3 -4
View File
@@ -1,12 +1,11 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
@@ -18,7 +17,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
@@ -26,7 +25,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
- Advanced wake-word detector (text match only for now).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
-11
View File
@@ -6,17 +6,6 @@
<title>Clawdis Updates</title>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<description>Signed update feed for the Clawdis macOS companion app.</description>
<item>
<title>Clawdis 2.0.0-beta2</title>
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
sparkle:version="2.0.0-beta2"
sparkle:shortVersionString="2.0.0-beta2"
length="67435891"
type="application/octet-stream" />
</item>
<item>
<title>Clawdis 2.0.0-beta1</title>
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
+5 -11
View File
@@ -38,21 +38,15 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
disable += setOf("IconLauncherShape")
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
@@ -62,7 +56,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.activity:activity-compose:1.12.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -12,20 +12,12 @@
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".NodeApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
@@ -38,7 +38,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
@@ -79,10 +78,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
@@ -84,6 +85,7 @@ class NodeForegroundService : Service() {
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
@@ -99,7 +101,12 @@ class NodeForegroundService : Service() {
private fun buildNotification(title: String, text: String): Notification {
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
@@ -119,6 +126,11 @@ class NodeForegroundService : Service() {
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (Build.VERSION.SDK_INT < 29) {
startForeground(NOTIFICATION_ID, notification)
return
}
if (didStartForeground && requiresMic == lastRequiresMic) {
updateNotification(notification)
return
@@ -150,7 +162,11 @@ class NodeForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
@@ -109,8 +109,6 @@ class NodeRuntime(context: Context) {
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private val session =
BridgeSession(
scope = scope,
@@ -120,7 +118,6 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = remote
_isConnected.value = true
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message -> handleSessionDisconnected(message) },
onEvent = { event, payloadJson ->
@@ -139,21 +136,6 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
if (current.isEmpty() || current == lastAutoA2uiUrl) {
lastAutoA2uiUrl = a2uiUrl
canvas.navigate(a2uiUrl)
}
}
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
canvas.navigate("")
}
val instanceId: StateFlow<String> = prefs.instanceId
@@ -166,7 +148,6 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
private var didAutoConnect = false
private var suppressWakeWordsSync = false
@@ -247,22 +228,6 @@ class NodeRuntime(context: Context) {
connect(target)
}
}
scope.launch {
combine(
canvasDebugStatusEnabled,
statusText,
serverName,
remoteAddress,
) { debugEnabled, status, server, remote ->
Quad(debugEnabled, status, server, remote)
}.distinctUntilChanged()
.collect { (debugEnabled, status, server, remote) ->
canvas.setDebugStatusEnabled(debugEnabled)
if (!debugEnabled) return@collect
canvas.setDebugStatus(status, server ?: remote)
}
}
}
fun setForeground(value: Boolean) {
@@ -293,10 +258,6 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()
@@ -435,7 +396,8 @@ class NodeRuntime(context: Context) {
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
if (name.isEmpty()) return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
@@ -3,7 +3,6 @@
package com.steipete.clawdis.node
import android.content.Context
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
@@ -63,10 +62,6 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
@@ -75,47 +70,42 @@ class SecurePrefs(context: Context) {
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit { putString(displayNameKey, trimmed) }
prefs.edit().putString(displayNameKey, trimmed).apply()
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit { putBoolean("camera.enabled", value) }
prefs.edit().putBoolean("camera.enabled", value).apply()
_cameraEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
prefs.edit { putBoolean("screen.preventSleep", value) }
prefs.edit().putBoolean("screen.preventSleep", value).apply()
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit { putBoolean("bridge.manual.enabled", value) }
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit { putString("bridge.manual.host", trimmed) }
prefs.edit().putString("bridge.manual.host", trimmed).apply()
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit { putInt("bridge.manual.port", value) }
prefs.edit().putInt("bridge.manual.port", value).apply()
_manualPort.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
@@ -123,14 +113,14 @@ class SecurePrefs(context: Context) {
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) }
prefs.edit().putString(key, token.trim()).apply()
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit { putString("node.instanceId", fresh) }
prefs.edit().putString("node.instanceId", fresh).apply()
return fresh
}
@@ -141,7 +131,7 @@ class SecurePrefs(context: Context) {
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
prefs.edit { putString(displayNameKey, resolved) }
prefs.edit().putString(displayNameKey, resolved).apply()
return resolved
}
@@ -149,12 +139,12 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit { putString("voiceWake.triggerWords", encoded) }
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
_voiceWakeMode.value = mode
}
@@ -164,7 +154,7 @@ class SecurePrefs(context: Context) {
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
}
return resolved
@@ -6,6 +6,7 @@ import android.net.DnsResolver
import android.net.NetworkCapabilities
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import java.io.IOException
@@ -43,7 +44,6 @@ import org.xbill.DNS.Type
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class BridgeDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -181,6 +181,7 @@ class BridgeDiscovery(
}
private fun txt(info: NsdServiceInfo, key: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
@@ -400,7 +401,7 @@ class BridgeDiscovery(
dns.rawQuery(
network,
wireQuery,
DnsResolver.FLAG_EMPTY,
0,
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
@@ -2,7 +2,6 @@ package com.steipete.clawdis.node.node
import android.Manifest
import android.content.Context
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
@@ -19,7 +18,6 @@ import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import com.steipete.clawdis.node.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -94,7 +92,7 @@ class CameraCaptureManager(private val context: Context) {
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
decoded.scale(maxWidth, h)
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
} else {
decoded
}
@@ -110,7 +108,6 @@ class CameraCaptureManager(private val context: Context) {
)
}
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
@@ -1,17 +1,15 @@
package com.steipete.clawdis.node.node
import android.graphics.Bitmap
import android.os.Build
import android.graphics.Canvas
import android.os.Looper
import android.webkit.WebView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@@ -26,9 +24,6 @@ class CanvasController {
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -40,7 +35,6 @@ class CanvasController {
fun attach(webView: WebView) {
this.webView = webView
reload()
applyDebugStatus()
}
fun navigate(url: String) {
@@ -49,25 +43,6 @@ class CanvasController {
reload()
}
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
}
fun setDebugStatus(title: String?, subtitle: String?) {
debugStatusTitle = title
debugStatusSubtitle = subtitle
applyDebugStatus()
}
fun onPageFinished() {
applyDebugStatus()
}
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
val wv = webView ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -88,32 +63,6 @@ class CanvasController {
}
}
private fun applyDebugStatus() {
val enabled = debugStatusEnabled
val title = debugStatusTitle
val subtitle = debugStatusSubtitle
withWebViewOnMain { wv ->
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
val js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
}
if (!${if (enabled) "true" else "false"}) return;
if (typeof api.setStatus === 'function') {
api.setStatus($titleJs, $subtitleJs);
}
} catch (_) {}
})();
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
@@ -131,7 +80,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
} else {
bmp
}
@@ -148,7 +97,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
} else {
bmp
}
@@ -167,7 +116,7 @@ class CanvasController {
suspendCancellableCoroutine { cont ->
val width = width.coerceAtLeast(1)
val height = height.coerceAtLeast(1)
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
// cross-version snapshot for this lightweight "canvas" use-case.
@@ -1,24 +1,6 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object ClawdisCanvasA2UIAction {
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
if (name.isNotEmpty()) return name
val action =
(userAction["action"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
return action.ifEmpty { null }
}
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -64,3 +46,4 @@ object ClawdisCanvasA2UIAction {
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
}
}
@@ -187,22 +187,15 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
viewModel.canvas.onPageFinished()
}
}
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
val a2uiBridge =
addJavascriptInterface(
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
addJavascriptInterface(
CanvasA2UIActionLegacyBridge(a2uiBridge),
CanvasA2UIActionLegacyBridge.interfaceName,
},
CanvasA2UIActionBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
@@ -222,19 +215,3 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
const val interfaceName: String = "clawdisCanvasA2UIAction"
}
}
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
@JavascriptInterface
fun canvasAction(payload: String?) {
bridge.postMessage(payload)
}
@JavascriptInterface
fun postMessage(payload: String?) {
bridge.postMessage(payload)
}
companion object {
const val interfaceName: String = "Android"
}
}
@@ -64,7 +64,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -395,23 +394,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item { HorizontalDivider() }
// Debug
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
headlineContent = { Text("Debug Canvas Status") },
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
trailingContent = {
Switch(
checked = canvasDebugStatusEnabled,
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
)
},
)
}
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,7 +1,8 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<network-security-config>
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
<base-config cleartextTrafficPermitted="true" />
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">clawdis.internal</domain>
@@ -1,24 +1,9 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdisCanvasA2UIActionTest {
@Test
fun extractActionNameAcceptsNameOrAction() {
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj))
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj))
val fallbackObj =
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj))
}
@Test
fun formatAgentMessageMatchesSharedSpec() {
val msg =
@@ -47,3 +32,4 @@ class ClawdisCanvasA2UIActionTest {
)
}
}
-5
View File
@@ -22,11 +22,6 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_clawdis-bridge._tcp</string>
+7 -27
View File
@@ -28,7 +28,6 @@ final class NodeAppModel {
private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge }
@@ -81,7 +80,7 @@ final class NodeAppModel {
}()
guard !userAction.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id
@@ -103,12 +102,14 @@ final class NodeAppModel {
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main"
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: name,
session: .init(key: sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
sessionKey: sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: host,
instanceId: instanceId,
contextJSON: contextJSON)
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
let ok: Bool
var errorText: String?
@@ -148,20 +149,6 @@ final class NodeAppModel {
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
private func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
}
}
private func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -217,7 +204,6 @@ final class NodeAppModel {
}
}
await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded()
},
onInvoke: { [weak self] req in
guard let self else {
@@ -230,9 +216,6 @@ final class NodeAppModel {
})
if Task.isCancelled { break }
await MainActor.run {
self.showLocalCanvasOnDisconnect()
}
attempt += 1
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -243,7 +226,6 @@ final class NodeAppModel {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.showLocalCanvasOnDisconnect()
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -255,7 +237,6 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.showLocalCanvasOnDisconnect()
}
}
}
@@ -270,7 +251,6 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.showLocalCanvasOnDisconnect()
}
func setGlobalWakeWords(_ words: [String]) async {
-14
View File
@@ -8,7 +8,6 @@ struct RootCanvas: View {
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@@ -57,11 +56,6 @@ struct RootCanvas: View {
.onAppear { self.updateIdleTimer() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -108,14 +102,6 @@ struct RootCanvas: View {
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
}
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
}
private struct CanvasContent: View {
+1 -58
View File
@@ -19,18 +19,12 @@ final class ScreenController {
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
var onA2UIAction: (([String: Any]) -> Void)?
private var debugStatusEnabled: Bool = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
for name in CanvasA2UIActionMessageHandler.handlerNames {
userContentController.add(a2uiActionHandler, name: name)
}
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
@@ -84,39 +78,6 @@ final class ScreenController {
self.reload()
}
func setDebugStatusEnabled(_ enabled: Bool) {
self.debugStatusEnabled = enabled
self.applyDebugStatusIfNeeded()
}
func updateDebugStatus(title: String?, subtitle: String?) {
self.debugStatusTitle = title
self.debugStatusSubtitle = subtitle
self.applyDebugStatusIfNeeded()
}
fileprivate func applyDebugStatusIfNeeded() {
let enabled = self.debugStatusEnabled
let title = self.debugStatusTitle
let subtitle = self.debugStatusSubtitle
let js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
}
} catch (_) {}
})()
"""
self.webView.evaluateJavaScript(js) { _, _ in }
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
@@ -249,17 +210,6 @@ final class ScreenController {
return false
}
private static func jsValue(_ value: String?) -> String {
guard let value else { return "null" }
if let data = try? JSONSerialization.data(withJSONObject: [value]),
let encoded = String(data: data, encoding: .utf8),
encoded.count >= 2
{
return String(encoded.dropFirst().dropLast())
}
return "null"
}
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
@@ -366,11 +316,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
self.controller?.errorText = error.localizedDescription
}
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
self.controller?.applyDebugStatusIfNeeded()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
self.controller?.errorText = error.localizedDescription
}
@@ -378,8 +323,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
static let handlerNames = [messageName] + legacyMessageNames
weak var controller: ScreenController?
@@ -28,7 +28,6 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -143,8 +142,6 @@ struct SettingsTab: View {
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
}
}
+25 -17
View File
@@ -2,7 +2,6 @@ import AVFAudio
import Foundation
import Observation
import Speech
import SwabbleKit
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
@@ -290,18 +289,15 @@ final class VoiceWakeManager: NSObject {
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in
let transcript = result?.bestTranscription.formattedString
let segments = result.flatMap { result in
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? []
let errorText = error?.localizedDescription
Task { @MainActor in
self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
}
}
}
private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
if let errorText {
self.statusText = "Recognizer error: \(errorText)"
self.isListening = false
@@ -317,7 +313,7 @@ final class VoiceWakeManager: NSObject {
}
guard let transcript else { return }
guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return }
guard let cmd = self.extractCommand(from: transcript) else { return }
if cmd == self.lastDispatched { return }
self.lastDispatched = cmd
@@ -338,18 +334,30 @@ final class VoiceWakeManager: NSObject {
}
}
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
private func extractCommand(from transcript: String) -> String? {
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords)
}
nonisolated static func extractCommand(
from transcript: String,
segments: [WakeWordSegment],
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45) -> String?
{
let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
nonisolated static func extractCommand(from transcript: String, triggers: [String]) -> String? {
var bestRange: Range<String.Index>?
for trigger in triggers {
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { continue }
guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue }
if let currentBest = bestRange {
if range.lowerBound > currentBest.lowerBound {
bestRange = range
}
} else {
bestRange = range
}
}
guard let bestRange else { return nil }
let after = transcript[bestRange.upperBound...]
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return String(trimmed)
}
private static func configureAudioSession() throws {
-57
View File
@@ -1,57 +0,0 @@
Sources/Bridge/BridgeClient.swift
Sources/Bridge/BridgeConnectionController.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift
Sources/ClawdisApp.swift
Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/StatusPill.swift
Sources/Status/VoiceWakeToast.swift
Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift
../shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift
../shared/ClawdisKit/Sources/ClawdisKit/BonjourEscapes.swift
../shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift
../shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift
../shared/ClawdisKit/Sources/ClawdisKit/CameraCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIAction.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift
../shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift
../shared/ClawdisKit/Sources/ClawdisKit/DeepLinks.swift
../shared/ClawdisKit/Sources/ClawdisKit/JPEGTranscoder.swift
../shared/ClawdisKit/Sources/ClawdisKit/NodeError.swift
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
@@ -1,90 +1,33 @@
import Foundation
import Testing
import SwabbleKit
@testable import Clawdis
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
let transcript = "hello world"
let segments = makeSegments(
transcript: transcript,
words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
}
@Test func extractCommandTrimsTokensAndResult() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: [" clawd "],
minPostTriggerGap: 0.3)
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
#expect(cmd == "do thing")
}
@Test func extractCommandReturnsNilWhenGapTooShort() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["clawd"],
minPostTriggerGap: 0.3)
#expect(cmd == nil)
@Test func extractCommandPicksLatestTriggerOccurrence() {
let transcript = "clawd first\nthen something\nclaude second"
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"])
#expect(cmd == "second")
}
@Test func extractCommandIsCaseInsensitive() {
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"])
#expect(cmd == "run it")
}
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
let transcript = "hey clawd"
let segments = makeSegments(
transcript: transcript,
words: [("hey", 0.0, 0.1), ("clawd", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
}
@Test func extractCommandIgnoresEmptyTriggers() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["", " ", "clawd"],
minPostTriggerGap: 0.3)
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
#expect(cmd == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
+3 -17
View File
@@ -8,8 +8,6 @@ options:
packages:
ClawdisKit:
path: ../shared/ClawdisKit
Swabble:
path: ../../Swabble
schemes:
Clawdis:
@@ -31,35 +29,27 @@ targets:
- package: ClawdisKit
- package: ClawdisKit
product: ClawdisChatUI
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework
preBuildScripts:
- name: SwiftFormat (lint)
basedOnDependencyAnalysis: false
inputFileLists:
- $(SRCROOT)/SwiftSources.input.xcfilelist
script: |
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if ! command -v swiftformat >/dev/null 2>&1; then
echo "error: swiftformat not found (brew install swiftformat)" >&2
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
"$SRCROOT/Sources" \
"$SRCROOT/../shared/ClawdisKit/Sources"
- name: SwiftLint
basedOnDependencyAnalysis: false
inputFileLists:
- $(SRCROOT)/SwiftSources.input.xcfilelist
script: |
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if ! command -v swiftlint >/dev/null 2>&1; then
echo "error: swiftlint not found (brew install swiftlint)" >&2
exit 1
fi
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
@@ -75,8 +65,6 @@ targets:
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices:
- _clawdis-bridge._tcp
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
@@ -90,8 +78,6 @@ targets:
- path: Tests
dependencies:
- target: Clawdis
- package: Swabble
product: SwabbleKit
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
+1 -8
View File
@@ -17,7 +17,6 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(path: "../shared/ClawdisKit"),
.package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
],
@@ -42,7 +41,6 @@ let package = Package(
"ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
.product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),
@@ -58,12 +56,7 @@ let package = Package(
]),
.testTarget(
name: "ClawdisIPCTests",
dependencies: [
"ClawdisIPC",
"Clawdis",
"ClawdisProtocol",
.product(name: "SwabbleKit", package: "swabble"),
],
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
@@ -92,12 +92,10 @@ struct AboutSettings: View {
guard let updater, !self.didLoadUpdaterState else { return }
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
updater.automaticallyDownloadsUpdates = self.autoCheckEnabled
self.didLoadUpdaterState = true
}
.onChange(of: self.autoCheckEnabled) { _, newValue in
self.updater?.automaticallyChecksForUpdates = newValue
self.updater?.automaticallyDownloadsUpdates = newValue
}
}
@@ -0,0 +1,45 @@
import Foundation
struct AgentIdentity: Codable, Equatable {
var name: String
var theme: String
var emoji: String
var isEmpty: Bool {
self.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.theme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
enum AgentIdentityEmoji {
static func suggest(theme: String) -> String {
let normalized = theme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return "🦞" }
let table: [(needle: String, emoji: String)] = [
("lobster", "🦞"),
("sloth", "🦥"),
("octopus", "🐙"),
("crab", "🦀"),
("shark", "🦈"),
("cat", "🐈"),
("dog", "🐕"),
("owl", "🦉"),
("fox", "🦊"),
("otter", "🦦"),
("raccoon", "🦝"),
("badger", "🦡"),
("hedgehog", "🦔"),
("koala", "🐨"),
("penguin", "🐧"),
("frog", "🐸"),
("bear", "🐻"),
]
for entry in table where normalized.contains(entry.needle) {
return entry.emoji
}
return "🦞"
}
}
+45 -89
View File
@@ -9,18 +9,8 @@ enum AgentWorkspace {
static let userFilename = "USER.md"
static let bootstrapFilename = "BOOTSTRAP.md"
private static let templateDirname = "templates"
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
private static let templateEntries: Set<String> = [
AgentWorkspace.agentsFilename,
AgentWorkspace.soulFilename,
AgentWorkspace.identityFilename,
AgentWorkspace.userFilename,
AgentWorkspace.bootstrapFilename,
]
enum BootstrapSafety: Equatable {
case safe
case unsafe(reason: String)
}
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
@@ -43,53 +33,7 @@ enum AgentWorkspace {
workspaceURL.appendingPathComponent(self.agentsFilename)
}
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) }
}
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return false }
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
return entries.isEmpty
}
static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool {
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
guard !entries.isEmpty else { return true }
return Set(entries).isSubset(of: self.templateEntries)
}
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
}
if !isDir.boolValue {
return .unsafe(reason: "Workspace path points to a file.")
}
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) {
return .safe
}
do {
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
return entries.isEmpty
? .safe
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
} catch {
return .unsafe(reason: "Couldn't inspect the workspace folder.")
}
}
static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager.default.fileExists(atPath: agentsURL.path) {
@@ -112,44 +56,36 @@ enum AgentWorkspace {
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
if !FileManager.default.fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
}
return agentsURL
}
static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return true }
if self.hasIdentity(workspaceURL: workspaceURL) {
return false
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
guard fm.fileExists(atPath: bootstrapURL.path) else { return false }
return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL)
}
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
let block = self.identityBlock(identity: identity)
static func hasIdentity(workspaceURL: URL) -> Bool {
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
return self.identityLinesHaveValues(contents)
}
private static func identityLinesHaveValues(_ content: String) -> Bool {
for line in content.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return true
}
if let start = content.range(of: self.identityStartMarker),
let end = content.range(of: self.identityEndMarker),
start.lowerBound < end.upperBound
{
content.replaceSubrange(
start.lowerBound..<end.upperBound,
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
} else if let insert = self.identityInsertRange(in: content) {
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
} else {
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
.appending("\n")
}
return false
try content.write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)")
}
static func defaultTemplate() -> String {
@@ -336,5 +272,25 @@ enum AgentWorkspace {
return trimmed + "\n"
}
// Identity is written by the agent during the bootstrap ritual.
private static func identityBlock(identity: AgentIdentity) -> String {
let name = identity.name.trimmingCharacters(in: .whitespacesAndNewlines)
let theme = identity.theme.trimmingCharacters(in: .whitespacesAndNewlines)
let emoji = identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines)
return """
\(self.identityStartMarker)
- Name: \(name)
- Theme: \(theme)
- Emoji: \(emoji)
\(self.identityEndMarker)
"""
}
private static func identityInsertRange(in content: String) -> Range<String.Index>? {
if let firstHeading = content.range(of: "\n") {
// Insert after the first line (usually "# AGENTS.md …")
return firstHeading
}
return nil
}
}
@@ -6,7 +6,7 @@ import SwiftUI
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode != .local {
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
.buttonStyle(.bordered)
}
Text(ClawdisOAuthStore.oauthURL().path)
Text(PiOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -123,11 +123,7 @@ struct AnthropicAuthControls: View {
}
private func refresh() {
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
if imported != nil {
self.statusText = "Imported existing OAuth credentials."
}
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
}
private func startOAuth() {
@@ -165,11 +161,11 @@ struct AnthropicAuthControls: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
self.statusText = "Connected. Pi can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
+23 -67
View File
@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Clawdis token file)"
case .oauthFile: "OAuth (Pi token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
@@ -36,8 +36,7 @@ enum AnthropicAuthMode: Equatable {
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
) -> AnthropicAuthMode
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
@@ -93,7 +92,7 @@ enum AnthropicOAuth {
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match legacy flow: state is the verifier.
// Match Pi: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
@@ -141,7 +140,7 @@ enum AnthropicOAuth {
])
}
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
// Match Pi: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
@@ -151,11 +150,10 @@ enum AnthropicOAuth {
}
}
enum ClawdisOAuthStore {
enum PiOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@@ -172,18 +170,18 @@ enum ClawdisOAuthStore {
var shortDescription: String {
switch self {
case .missingFile: "Clawdis OAuth token file not found"
case .unreadableFile: "Clawdis OAuth token file not readable"
case .invalidJSON: "Clawdis OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Clawdis OAuth credentials found"
case .connected: "Pi OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
@@ -192,58 +190,14 @@ enum ClawdisOAuthStore {
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func legacyOAuthURLs() -> [URL] {
var urls: [URL] = []
let env = ProcessInfo.processInfo.environment
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
var seen = Set<String>()
return urls.filter { url in
let path = url.standardizedFileURL.path
if seen.contains(path) { return false }
seen.insert(path)
return true
}
}
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
try self.saveStorage(storage)
return url
} catch {
continue
}
}
return nil
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
@@ -286,15 +240,17 @@ enum ClawdisOAuthStore {
return nil
}
private static func loadStorage(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
return json as? [String: Any]
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let dict = json as? [String: Any]
{
dict
} else {
[:]
}
var updated = existing
updated[self.providerKey] = [
@@ -1,22 +0,0 @@
import ClawdisProtocol
import Foundation
extension AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}
@@ -38,7 +38,6 @@ final class AppState {
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
CanvasManager.shared.refreshDebugStatus()
}
}
@@ -11,12 +11,6 @@ final class CanvasManager {
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
private var lastAutoA2UIUrl: String?
private var gatewayWatchTask: Task<Void, Never>?
private init() {
self.startGatewayObserver()
}
var onPanelVisibilityChanged: ((Bool) -> Void)?
@@ -56,7 +50,6 @@ final class CanvasManager {
}
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
self.refreshDebugStatus()
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
@@ -67,7 +60,6 @@ final class CanvasManager {
effectiveTarget: normalizedTarget)
}
self.maybeAutoNavigateToA2UIAsync(controller: controller)
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
@@ -101,10 +93,6 @@ final class CanvasManager {
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
if normalizedTarget == nil {
self.maybeAutoNavigateToA2UIAsync(controller: controller)
}
self.refreshDebugStatus()
return self.makeShowResult(
directory: controller.directoryPath,
@@ -136,63 +124,6 @@ final class CanvasManager {
return try await controller.snapshot(to: outPath)
}
// MARK: - Gateway A2UI auto-nav
private func startGatewayObserver() {
self.gatewayWatchTask?.cancel()
self.gatewayWatchTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
for await push in stream {
self.handleGatewayPush(push)
}
}
}
private func handleGatewayPush(_ push: GatewayPush) {
guard case let .snapshot(snapshot) = push else { return }
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
guard let controller = self.panelController else { return }
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
Task { [weak self] in
guard let self else { return }
let a2uiUrl = await self.resolveA2UIHostUrl()
await MainActor.run {
guard self.panelController === controller else { return }
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
}
}
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
guard let a2uiUrl else { return }
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
controller.load(target: a2uiUrl)
self.lastAutoA2UIUrl = a2uiUrl
}
private func resolveA2UIHostUrl() async -> String? {
let raw = await GatewayConnection.shared.canvasHostUrl()
return Self.resolveA2UIHostUrl(from: raw)
}
func refreshDebugStatus() {
guard let controller = self.panelController else { return }
let enabled = AppStateStore.shared.debugPaneEnabled
let title = GatewayProcessManager.shared.status.label
let subtitle = AppStateStore.shared.connectionMode.rawValue
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
}
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
+7 -60
View File
@@ -43,10 +43,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
private var preferredPlacement: CanvasPlacement?
private(set) var currentTarget: String?
private var debugStatusEnabled = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
var onVisibilityChanged: ((Bool) -> Void)?
@@ -241,7 +237,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
self.currentTarget = trimmed
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
@@ -281,35 +276,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.webView.load(URLRequest(url: url))
}
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
self.debugStatusEnabled = enabled
self.debugStatusTitle = title
self.debugStatusSubtitle = subtitle
self.applyDebugStatusIfNeeded()
}
private func applyDebugStatusIfNeeded() {
let enabled = self.debugStatusEnabled
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
let js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(title), \(subtitle));
}
} catch (_) {}
})();
"""
self.webView.evaluateJavaScript(js) { _, _ in }
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
@@ -374,18 +340,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.sessionDir.path
}
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "/" { return true }
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
!lastAuto.isEmpty,
trimmed == lastAuto
{
return true
}
return false
}
// MARK: - Window
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
@@ -583,10 +537,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
decisionHandler(.cancel)
}
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.applyDebugStatusIfNeeded()
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
@@ -621,11 +571,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
private static func jsOptionalStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return Self.jsStringLiteral(value)
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
@@ -694,7 +639,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
}()
guard !userAction.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
@@ -709,12 +654,14 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: name,
session: .init(key: self.sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
sessionKey: self.sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: InstanceIdentity.displayName,
instanceId: instanceId,
contextJSON: contextJSON)
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
@@ -30,23 +30,6 @@ enum ClawdisConfigFile {
} catch {}
}
static func loadGatewayDict() -> [String: Any] {
let root = self.loadDict()
return root["gateway"] as? [String: Any] ?? [:]
}
static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
mutate(&gateway)
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
self.saveDict(root)
}
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
let root = self.loadDict()
let browser = root["browser"] as? [String: Any]
@@ -79,4 +62,28 @@ enum ClawdisConfigFile {
root["inbound"] = inbound
self.saveDict(root)
}
static func loadIdentity() -> AgentIdentity? {
let root = self.loadDict()
guard let identity = root["identity"] as? [String: Any] else { return nil }
let name = identity["name"] as? String ?? ""
let theme = identity["theme"] as? String ?? ""
let emoji = identity["emoji"] as? String ?? ""
let result = AgentIdentity(name: name, theme: theme, emoji: emoji)
return result.isEmpty ? nil : result
}
static func setIdentity(_ identity: AgentIdentity?) {
var root = self.loadDict()
if let identity, !identity.isEmpty {
root["identity"] = [
"name": identity.name.trimmingCharacters(in: .whitespacesAndNewlines),
"theme": identity.theme.trimmingCharacters(in: .whitespacesAndNewlines),
"emoji": identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines),
]
} else {
root.removeValue(forKey: "identity")
}
self.saveDict(root)
}
}
@@ -152,7 +152,7 @@ struct ConfigSettings: View {
}
private var anthropicAuthHelpText: String {
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
@@ -1,350 +0,0 @@
import AppKit
import SwiftUI
struct ConnectionsSettings: View {
@Bindable var store: ConnectionsStore
@State private var showTelegramToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) {
self.header
self.whatsAppSection
self.telegramSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Link and monitor WhatsApp and Telegram providers.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var whatsAppSection: some View {
GroupBox("WhatsApp") {
VStack(alignment: .leading, spacing: 10) {
self.providerHeader(
title: "WhatsApp Web",
color: self.whatsAppTint,
subtitle: self.whatsAppSummary)
if let details = self.whatsAppDetails {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
Spacer()
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var telegramSection: some View {
GroupBox("Telegram") {
VStack(alignment: .leading, spacing: 10) {
self.providerHeader(
title: "Telegram Bot",
color: self.telegramTint,
subtitle: self.telegramSummary)
if let details = self.telegramDetails {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider().padding(.vertical, 2)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Bot token")
if self.showTelegramToken {
TextField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
} else {
SecureField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
}
Toggle("Show", isOn: self.$showTelegramToken)
.toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook secret")
TextField("secret", text: self.$store.telegramWebhookSecret)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook path")
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
.textFieldStyle(.roundedBorder)
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var whatsAppTint: Color {
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
if !status.linked { return .red }
if status.connected { return .green }
if status.lastError != nil { return .orange }
return .green
}
private var telegramTint: Color {
guard let status = self.store.snapshot?.telegram else { return .secondary }
if !status.configured { return .secondary }
if status.running { return .green }
if status.lastError != nil { return .orange }
return .secondary
}
private var whatsAppSummary: String {
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
if !status.linked { return "Not linked" }
if status.connected { return "Connected" }
if status.running { return "Running" }
return "Linked"
}
private var telegramSummary: String {
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
}
private var whatsAppDetails: String? {
guard let status = self.store.snapshot?.whatsapp else { return nil }
var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
lines.append("Linked as \(e164)")
}
if let age = status.authAgeMs {
lines.append("Auth age \(msToAge(age))")
}
if let last = self.date(fromMs: status.lastConnectedAt) {
lines.append("Last connect \(relativeAge(from: last))")
}
if let disconnect = status.lastDisconnect {
let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown"
let code = disconnect.status.map { "status \($0)" } ?? "status unknown"
let err = disconnect.error ?? "disconnect"
lines.append("Last disconnect \(code) · \(err) · \(when)")
}
if status.reconnectAttempts > 0 {
lines.append("Reconnect attempts \(status.reconnectAttempts)")
}
if let msgAt = self.date(fromMs: status.lastMessageAt) {
lines.append("Last message \(relativeAge(from: msgAt))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var telegramDetails: String? {
guard let status = self.store.snapshot?.telegram else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let mode = status.mode {
lines.append("Mode: \(mode)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let url = probe.webhook?.url, !url.isEmpty {
lines.append("Webhook: \(url)")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var isTelegramTokenLocked: Bool {
self.store.snapshot?.telegram.tokenSource == "env"
}
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
HStack(spacing: 10) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.font(.callout.weight(.semibold))
.frame(width: 120, alignment: .leading)
}
private func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000)
}
private func qrImage(from dataUrl: String) -> NSImage? {
guard let comma = dataUrl.firstIndex(of: ",") else { return nil }
let header = dataUrl[..<comma]
guard header.contains("base64") else { return nil }
let base64 = dataUrl[dataUrl.index(after: comma)...]
guard let data = Data(base64Encoded: String(base64)) else { return nil }
return NSImage(data: data)
}
}
@@ -1,393 +0,0 @@
import ClawdisProtocol
import Foundation
import Observation
struct ProvidersStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
let e164: String?
let jid: String?
}
struct WhatsAppDisconnect: Codable {
let at: Double
let status: Int?
let error: String?
let loggedOut: Bool?
}
struct WhatsAppStatus: Codable {
let configured: Bool
let linked: Bool
let authAgeMs: Double?
let `self`: WhatsAppSelf?
let running: Bool
let connected: Bool
let lastConnectedAt: Double?
let lastDisconnect: WhatsAppDisconnect?
let reconnectAttempts: Int
let lastMessageAt: Double?
let lastEventAt: Double?
let lastError: String?
}
struct TelegramBot: Codable {
let id: Int?
let username: String?
}
struct TelegramWebhook: Codable {
let url: String?
let hasCustomCert: Bool?
}
struct TelegramProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: TelegramBot?
let webhook: TelegramWebhook?
}
struct TelegramStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let mode: String?
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: TelegramProbe?
let lastProbeAt: Double?
}
let ts: Double
let whatsapp: WhatsAppStatus
let telegram: TelegramStatus
}
struct ConfigSnapshot: Codable {
struct Issue: Codable {
let path: String
let message: String
}
let path: String?
let exists: Bool?
let raw: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
let issues: [Issue]?
}
@MainActor
@Observable
final class ConnectionsStore {
static let shared = ConnectionsStore()
var snapshot: ProvidersStatusSnapshot?
var lastError: String?
var lastSuccess: Date?
var isRefreshing = false
var whatsappLoginMessage: String?
var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool?
var whatsappBusy = false
var telegramToken: String = ""
var telegramRequireMention = true
var telegramAllowFrom: String = ""
var telegramProxy: String = ""
var telegramWebhookUrl: String = ""
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false
var configStatus: String?
var isSavingConfig = false
private let interval: TimeInterval = 45
private let isPreview: Bool
private var pollTask: Task<Void, Never>?
private var configRoot: [String: Any] = [:]
private var configLoaded = false
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
func start() {
guard !self.isPreview else { return }
guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refresh(probe: true)
await self.loadConfig()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refresh(probe: false)
}
}
}
func stop() {
self.pollTask?.cancel()
self.pollTask = nil
}
func refresh(probe: Bool) async {
guard !self.isRefreshing else { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
do {
let params: [String: AnyCodable] = [
"probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000),
]
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .providersStatus,
params: params,
timeoutMs: 12000)
self.snapshot = snap
self.lastSuccess = Date()
self.lastError = nil
} catch {
self.lastError = error.localizedDescription
}
}
func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
var shouldAutoWait = false
do {
let params: [String: AnyCodable] = [
"force": AnyCodable(force),
"timeoutMs": AnyCodable(30000),
]
let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginStart,
params: params,
timeoutMs: 35000)
self.whatsappLoginMessage = result.message
self.whatsappLoginQrDataUrl = result.qrDataUrl
self.whatsappLoginConnected = nil
shouldAutoWait = autoWait && result.qrDataUrl != nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
self.whatsappLoginQrDataUrl = nil
self.whatsappLoginConnected = nil
}
await self.refresh(probe: true)
if shouldAutoWait {
Task { await self.waitWhatsAppLogin() }
}
}
func waitWhatsAppLogin(timeoutMs: Int = 120_000) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
do {
let params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(timeoutMs),
]
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(timeoutMs) + 5000)
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if result.connected {
self.whatsappLoginQrDataUrl = nil
}
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutWhatsApp() async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
do {
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .webLogout,
params: nil,
timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared
? "Logged out and cleared credentials."
: "No WhatsApp session found."
self.whatsappLoginQrDataUrl = nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutTelegram() async {
guard !self.telegramBusy else { return }
self.telegramBusy = true
defer { self.telegramBusy = false }
do {
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .telegramLogout,
params: nil,
timeoutMs: 15000)
if result.envToken == true {
self.configStatus = "Telegram token still set via env; config cleared."
} else {
self.configStatus = result.cleared
? "Telegram token cleared."
: "No Telegram token configured."
}
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
await self.refresh(probe: true)
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdis/clawdis.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configLoaded = true
let telegram = snap.config?["telegram"]?.dictionaryValue
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
if let allow = telegram?["allowFrom"]?.arrayValue {
let strings = allow.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
self.telegramAllowFrom = strings.joined(separator: ", ")
} else {
self.telegramAllowFrom = ""
}
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
} catch {
self.configStatus = error.localizedDescription
}
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.telegramToken.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
}
if self.telegramRequireMention {
telegram["requireMention"] = true
} else {
telegram["requireMention"] = false
}
let allow = self.telegramAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
let proxy = self.telegramProxy.trimmingCharacters(in: .whitespacesAndNewlines)
if proxy.isEmpty {
telegram.removeValue(forKey: "proxy")
} else {
telegram["proxy"] = proxy
}
let webhookUrl = self.telegramWebhookUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookUrl.isEmpty {
telegram.removeValue(forKey: "webhookUrl")
} else {
telegram["webhookUrl"] = webhookUrl
}
let webhookSecret = self.telegramWebhookSecret.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookSecret.isEmpty {
telegram.removeValue(forKey: "webhookSecret")
} else {
telegram["webhookSecret"] = webhookSecret
}
let webhookPath = self.telegramWebhookPath.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookPath.isEmpty {
telegram.removeValue(forKey: "webhookPath")
} else {
telegram["webhookPath"] = webhookPath
}
if telegram.isEmpty {
self.configRoot.removeValue(forKey: "telegram")
} else {
self.configRoot["telegram"] = telegram
}
do {
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
}
private struct WhatsAppLoginStartResult: Codable {
let qrDataUrl: String?
let message: String
}
private struct WhatsAppLoginWaitResult: Codable {
let connected: Bool
let message: String
}
private struct WhatsAppLogoutResult: Codable {
let cleared: Bool
}
private struct TelegramLogoutResult: Codable {
let cleared: Bool
let envToken: Bool?
}
@@ -3,7 +3,6 @@ import SwiftUI
struct CritterStatusLabel: View {
var isPaused: Bool
var isSleeping: Bool
var isWorking: Bool
var earBoostActive: Bool
var blinkTick: Int
@@ -26,10 +25,6 @@ struct CritterStatusLabel: View {
self.iconState.isWorking || self.isWorking
}
private var effectiveAnimationsEnabled: Bool {
self.animationsEnabled && !self.isSleeping
}
var body: some View {
ZStack(alignment: .topTrailing) {
self.iconImage
@@ -39,7 +34,7 @@ struct CritterStatusLabel: View {
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
guard self.animationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() }
return
}
@@ -52,27 +47,24 @@ struct CritterStatusLabel: View {
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
guard !self.earBoostActive else { return }
self.blink()
}
.onChange(of: self.sendCelebrationTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
guard !self.earBoostActive else { return }
self.wiggleLegs()
}
.onChange(of: self.animationsEnabled) { _, enabled in
if enabled, !self.isSleeping {
if enabled {
self.scheduleRandomTimers(from: Date())
} else {
self.resetMotion()
}
}
.onChange(of: self.isSleeping) { _, _ in
self.resetMotion()
}
.onChange(of: self.earBoostActive) { _, active in
if active {
self.resetMotion()
} else if self.effectiveAnimationsEnabled {
} else if self.animationsEnabled {
self.scheduleRandomTimers(from: Date())
}
}
@@ -89,11 +81,11 @@ struct CritterStatusLabel: View {
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
@@ -136,10 +128,6 @@ struct CritterStatusLabel: View {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
if self.isSleeping {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
@@ -228,12 +216,11 @@ struct CritterStatusLabel: View {
}
private var gatewayNeedsAttention: Bool {
if self.isSleeping { return false }
switch self.gatewayStatus {
case .failed, .stopped:
return !self.isPaused
!self.isPaused
case .starting, .running, .attachedExisting:
return false
false
}
}
@@ -268,7 +255,6 @@ enum CritterIconRenderer {
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
eyesClosedLines: Bool = false,
badge: Badge? = nil) -> NSImage
{
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
@@ -334,7 +320,9 @@ enum CritterIconRenderer {
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH + h * 0.05)
let eyeOpen = max(0.05, 1 - blink)
let eyeW = snapX(bodyW * 0.2)
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
let eyeY = snapY(bodyY + bodyH * 0.56)
let eyeOffset = snapX(bodyW * 0.24)
@@ -404,50 +392,20 @@ enum CritterIconRenderer {
transform: nil))
}
if eyesClosedLines {
let lineW = snapX(eyeW * 0.95)
let lineH = snapY(max(stepY * 2, bodyH * 0.06))
let corner = snapX(lineH * 0.6)
let leftRect = CGRect(
x: snapX(leftCenter.x - lineW / 2),
y: snapY(leftCenter.y - lineH / 2),
width: lineW,
height: lineH)
let rightRect = CGRect(
x: snapX(rightCenter.x - lineW / 2),
y: snapY(rightCenter.y - lineH / 2),
width: lineW,
height: lineH)
context.cgContext.addPath(CGPath(
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
} else {
let eyeOpen = max(0.05, 1 - blink)
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
let left = CGMutablePath()
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let left = CGMutablePath()
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let right = CGMutablePath()
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
right.closeSubpath()
context.cgContext.addPath(left)
context.cgContext.addPath(right)
}
let right = CGMutablePath()
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
right.closeSubpath()
context.cgContext.addPath(left)
context.cgContext.addPath(right)
context.cgContext.fillPath()
context.cgContext.restoreGState()
@@ -109,61 +109,24 @@ actor GatewayConnection {
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
if error is GatewayResponseError || error is GatewayDecodingError {
throw error
}
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
switch mode {
case .local:
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
guard isLocal else { throw error }
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
throw lastError
case .remote:
let nsError = error as NSError
guard nsError.domain == URLError.errorDomain else { throw error }
var lastError: Error = error
await RemoteTunnelManager.shared.stopAll()
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token)
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
throw lastError
case .unconfigured:
throw error
}
throw lastError
}
}
@@ -327,8 +327,10 @@ final class GatewayDiscoveryModel {
return true
}
if let service = normalizeServiceToken(serviceName) {
for token in local.hostTokens where service.contains(token) {
return true
for token in local.hostTokens {
if service.contains(token) {
return true
}
}
}
return false
@@ -454,9 +456,8 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData())
if !txt.isEmpty {
let payload = self.formatTXT(txt)
self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
"discovery: resolved TXT for \(sender.name, privacy: .public): \(self.formatTXT(txt), privacy: .public)")
}
self.finish(result: .success(txt))
}
@@ -36,9 +36,7 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Note: removed redundant `kickstart -k` that caused race condition.
// bootstrap already starts the job; kickstart -k would kill it immediately
// and with KeepAlive=true, cause a restart loop with port conflicts.
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
@@ -30,10 +30,7 @@ final class GatewayProcessManager {
}
}
private(set) var status: Status = .stopped {
didSet { CanvasManager.shared.refreshDebugStatus() }
}
private(set) var status: Status = .stopped
private(set) var log: String = ""
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
@@ -126,17 +126,12 @@ struct GeneralSettings: View {
if self.state.connectionMode == .local {
self.gatewayInstallerCard
TailscaleIntegrationSection(
connectionMode: self.state.connectionMode,
isPaused: self.state.isPaused)
self.healthRow
}
if self.state.connectionMode == .remote {
self.remoteCard
}
self.cliInstaller
}
}
@@ -588,8 +583,7 @@ extension GeneralSettings {
trimmed.localizedCaseInsensitiveContains("host key verification failed")
{
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
return "SSH check failed: Host key verification failed. Remove the old key with " +
"`ssh-keygen -R \(host)` and try again."
return "SSH check failed: Host key verification failed. Remove the old key with `ssh-keygen -R \(host)` and try again."
}
if let trimmed, !trimmed.isEmpty {
if let message = response.message, message.hasPrefix("exit ") {
@@ -648,7 +642,6 @@ struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {
GeneralSettings(state: .preview)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.environment(TailscaleService.shared)
}
}
#endif
+18 -107
View File
@@ -2,7 +2,6 @@ import AppKit
import Darwin
import Foundation
import MenuBarExtraAccess
import Observation
import OSLog
import Security
import SwiftUI
@@ -12,12 +11,11 @@ struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var state: AppState
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@State private var tailscaleService = TailscaleService.shared
@State private var menuInjector = MenuContextCardInjector.shared
@MainActor
private func updateStatusHighlight() {
@@ -37,37 +35,30 @@ struct ClawdisApp: App {
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
CritterStatusLabel(
isPaused: self.state.isPaused,
isSleeping: self.isGatewaySleeping,
isWorking: self.state.isWorking,
earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick,
gatewayStatus: self.gatewayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
animationsEnabled: self.state.iconAnimationsEnabled,
iconState: self.effectiveIconState)
}
.menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
self.statusItem = item
MenuSessionsInjector.shared.install(into: item)
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
self.applyStatusItemAppearance(paused: self.state.isPaused)
self.installStatusItemMouseHandler(for: item)
self.menuInjector.install(into: item)
self.updateHoverHUDSuppression()
}
.onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
self.applyStatusItemAppearance(paused: paused)
if self.state.connectionMode == .local {
self.gatewayManager.setActive(!paused)
} else {
self.gatewayManager.stop()
}
}
.onChange(of: self.controlChannel.state) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
}
.onChange(of: self.gatewayManager.status) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
}
.onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
}
@@ -75,7 +66,6 @@ struct ClawdisApp: App {
Settings {
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.environment(self.tailscaleService)
}
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.windowResizability(.contentSize)
@@ -85,27 +75,8 @@ struct ClawdisApp: App {
}
}
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
self.statusItem?.button?.appearsDisabled = paused || sleeping
}
private var isGatewaySleeping: Bool {
if self.state.isPaused { return false }
switch self.state.connectionMode {
case .unconfigured:
return true
case .remote:
if case .connected = self.controlChannel.state { return false }
return true
case .local:
switch self.gatewayManager.status {
case .running, .starting, .attachedExisting:
if case .connected = self.controlChannel.state { return false }
return true
case .failed, .stopped:
return true
}
}
private func applyStatusItemAppearance(paused: Bool) {
self.statusItem?.button?.appearsDisabled = paused
}
@MainActor
@@ -307,94 +278,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor
protocol UpdaterProviding: AnyObject {
var automaticallyChecksForUpdates: Bool { get set }
var automaticallyDownloadsUpdates: Bool { get set }
var isAvailable: Bool { get }
var updateStatus: UpdateStatus { get }
func checkForUpdates(_ sender: Any?)
}
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
final class DisabledUpdaterController: UpdaterProviding {
var automaticallyChecksForUpdates: Bool = false
var automaticallyDownloadsUpdates: Bool = false
let isAvailable: Bool = false
let updateStatus = UpdateStatus()
func checkForUpdates(_: Any?) {}
}
@MainActor
@Observable
final class UpdateStatus {
static let disabled = UpdateStatus()
var isUpdateReady: Bool
init(isUpdateReady: Bool = false) {
self.isUpdateReady = isUpdateReady
}
}
#if canImport(Sparkle)
import Sparkle
@MainActor
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
private lazy var controller = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil)
let updateStatus = UpdateStatus()
init(savedAutoUpdate: Bool) {
super.init()
let updater = self.controller.updater
updater.automaticallyChecksForUpdates = savedAutoUpdate
updater.automaticallyDownloadsUpdates = savedAutoUpdate
self.controller.startUpdater()
}
extension SPUStandardUpdaterController: UpdaterProviding {
var automaticallyChecksForUpdates: Bool {
get { self.controller.updater.automaticallyChecksForUpdates }
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
}
var automaticallyDownloadsUpdates: Bool {
get { self.controller.updater.automaticallyDownloadsUpdates }
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
get { self.updater.automaticallyChecksForUpdates }
set { self.updater.automaticallyChecksForUpdates = newValue }
}
var isAvailable: Bool { true }
func checkForUpdates(_ sender: Any?) {
self.controller.checkForUpdates(sender)
}
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
self.updateStatus.isUpdateReady = true
}
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
self.updateStatus.isUpdateReady = false
}
func userDidCancelDownload(_ updater: SPUUpdater) {
self.updateStatus.isUpdateReady = false
}
func updater(
_ updater: SPUUpdater,
userDidMakeChoice choice: SPUUserUpdateChoice,
forUpdate updateItem: SUAppcastItem,
state: SPUUserUpdateState
) {
switch choice {
case .install, .skip:
self.updateStatus.isUpdateReady = false
case .dismiss:
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
@unknown default:
self.updateStatus.isUpdateReady = false
}
}
}
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
@@ -427,7 +331,14 @@ private func makeUpdaterController() -> UpdaterProviding {
let autoUpdateKey = "autoUpdateEnabled"
// Default to true; honor the user's last choice otherwise.
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
let controller = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: nil,
userDriverDelegate: nil)
controller.updater.automaticallyChecksForUpdates = savedAutoUpdate
controller.startUpdater()
return controller
}
#else
private func makeUpdaterController() -> UpdaterProviding {
@@ -8,7 +8,6 @@ import SwiftUI
struct MenuContent: View {
@Bindable var state: AppState
let updater: UpdaterProviding?
@Bindable private var updateStatus: UpdateStatus
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
@@ -17,14 +16,9 @@ struct MenuContent: View {
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = []
@State private var browserControlEnabled = true
init(state: AppState, updater: UpdaterProviding?) {
self._state = Bindable(wrappedValue: state)
self.updater = updater
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) {
@@ -34,7 +28,6 @@ struct MenuContent: View {
}
}
.disabled(self.state.connectionMode == .unconfigured)
Divider()
Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) {
@@ -48,7 +41,6 @@ struct MenuContent: View {
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
Divider()
Button("Open Chat") {
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
@@ -60,6 +52,15 @@ struct MenuContent: View {
await self.openDashboard()
}
}
Toggle(
isOn: Binding(
get: { self.browserControlEnabled },
set: { enabled in
self.browserControlEnabled = enabled
ClawdisConfigFile.setBrowserControlEnabled(enabled)
})) {
Text("Browser Control")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Text("Allow Canvas")
}
@@ -79,22 +80,12 @@ struct MenuContent: View {
}
}
Divider()
Toggle(
isOn: Binding(
get: { self.browserControlEnabled },
set: { enabled in
self.browserControlEnabled = enabled
ClawdisConfigFile.setBrowserControlEnabled(enabled)
})) {
Text("Browser Control")
}
Divider()
Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])
self.debugMenu
Button("About Clawdis") { self.open(tab: .about) }
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
if let updater, updater.isAvailable {
Button("Check for Updates…") { updater.checkForUpdates(nil) }
}
Button("Quit") { NSApplication.shared.terminate(nil) }
}
@@ -103,6 +94,9 @@ struct MenuContent: View {
await self.loadMicrophones(force: true)
}
}
.task {
await self.reloadSessionMenu()
}
.task {
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
}
@@ -129,6 +123,55 @@ struct MenuContent: View {
private var debugMenu: some View {
if self.state.debugPaneEnabled {
Menu("Debug") {
Menu {
ForEach(self.sessionMenu) { row in
Menu(row.key) {
Menu("Thinking") {
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: normalized,
verbose: row.verboseLevel)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Menu("Verbose") {
ForEach(["on", "off", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: row.thinkingLevel,
verbose: normalized)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Button {
DebugActions.openSessionStoreInCode()
} label: {
Label("Open Session Log", systemImage: "doc.text")
}
}
}
Divider()
} label: {
Label("Sessions", systemImage: "clock.arrow.circlepath")
}
Divider()
Button {
DebugActions.openConfigFolder()
} label: {
@@ -380,6 +423,11 @@ struct MenuContent: View {
return "System default"
}
@MainActor
private func reloadSessionMenu() async {
self.sessionMenu = await DebugActions.recentSessions()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {
@@ -48,7 +48,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialRows = self.cachedRows
let initialIsLoading = initialRows.isEmpty
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView(
rows: initialRows,
@@ -56,8 +55,8 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth)
let size = hosting.fittingSize
let initialWidth = self.initialCardWidth(for: menu)
hosting.frame = NSRect(
origin: .zero,
size: NSSize(width: initialWidth, height: size.height))
@@ -84,7 +83,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
hosting.rootView = view
hosting.invalidateIntrinsicContentSize()
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
hosting.frame.size.width = max(1, initialWidth)
let size = hosting.fittingSize
hosting.frame.size.height = size.height
}
@@ -1,44 +0,0 @@
import SwiftUI
struct MenuSessionsHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
if self.count == 1 { return "1 session · 24h" }
return "\(self.count) sessions · 24h"
}
}
@@ -1,538 +0,0 @@
import AppKit
import SwiftUI
@MainActor
final class MenuSessionsInjector: NSObject, NSMenuDelegate {
static let shared = MenuSessionsInjector()
private let tag = 9_415_557
private let fallbackWidth: CGFloat = 320
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
private weak var originalDelegate: NSMenuDelegate?
private weak var statusItem: NSStatusItem?
private var loadTask: Task<Void, Never>?
private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat?
private var cachedSnapshot: SessionStoreSnapshot?
private var cachedErrorText: String?
private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12
func install(into statusItem: NSStatusItem) {
self.statusItem = statusItem
guard let menu = statusItem.menu else { return }
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
if menu.delegate !== self {
self.originalDelegate = menu.delegate
menu.delegate = self
}
if self.loadTask == nil {
self.loadTask = Task { await self.refreshCache(force: true) }
}
}
func menuWillOpen(_ menu: NSMenu) {
self.originalDelegate?.menuWillOpen?(menu)
self.isMenuOpen = true
self.inject(into: menu)
// Refresh in background for the next open (but only when connected).
self.loadTask?.cancel()
self.loadTask = Task { [weak self] in
guard let self else { return }
await self.refreshCache(force: false)
await MainActor.run {
guard self.isMenuOpen else { return }
// SwiftUI might have refreshed menu items; re-inject once.
self.inject(into: menu)
}
}
}
func menuDidClose(_ menu: NSMenu) {
self.originalDelegate?.menuDidClose?(menu)
self.isMenuOpen = false
self.loadTask?.cancel()
}
func menuNeedsUpdate(_ menu: NSMenu) {
self.originalDelegate?.menuNeedsUpdate?(menu)
}
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
return rect
}
return NSRect.zero
}
// MARK: - Injection
private func inject(into menu: NSMenu) {
// Remove any previous injected items.
for item in menu.items where item.tag == self.tag {
menu.removeItem(item)
}
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else {
menu.insertItem(self.makeMessageItem(
text: "No connection to gateway",
symbolName: "wifi.slash",
width: width), at: insertIndex)
return
}
guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: insertIndex)
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
guard let self, let view else { return }
self.captureMenuWidthIfAvailable(from: view)
}
return
}
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let headerView = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = headerView
menu.insertItem(headerItem, at: insertIndex)
var cursor = insertIndex + 1
if rows.isEmpty {
menu.insertItem(self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), at: cursor)
return
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
self.captureMenuWidthIfAvailable(from: headerView)
}
}
private var isControlChannelConnected: Bool {
if case .connected = ControlChannel.shared.state { return true }
return false
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
let view = AnyView(
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(minWidth: 300, alignment: .leading))
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item
}
// MARK: - Cache
private func refreshCache(force: Bool) async {
if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds {
return
}
guard self.isControlChannelConnected else {
self.cachedSnapshot = nil
self.cachedErrorText = nil
self.cacheUpdatedAt = Date()
return
}
do {
self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32)
self.cachedErrorText = nil
self.cacheUpdatedAt = Date()
} catch {
self.cachedSnapshot = nil
self.cachedErrorText = self.compactError(error)
self.cacheUpdatedAt = Date()
}
}
private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError {
switch loadError {
case .gatewayUnavailable:
return "No connection to gateway"
case .decodeFailed:
return "Sessions unavailable"
}
}
return "Sessions unavailable"
}
// MARK: - Submenus
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
let menu = NSMenu()
let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "")
thinking.submenu = self.buildThinkingMenu(for: row)
menu.addItem(thinking)
let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "")
verbose.submenu = self.buildVerboseMenu(for: row)
menu.addItem(verbose)
if AppStateStore.shared.debugPaneEnabled,
AppStateStore.shared.connectionMode == .local,
let sessionId = row.sessionId,
!sessionId.isEmpty
{
menu.addItem(NSMenuItem.separator())
let openLog = NSMenuItem(title: "Open Session Log", action: #selector(self.openSessionLog(_:)), keyEquivalent: "")
openLog.target = self
openLog.representedObject = [
"sessionId": sessionId,
"storePath": storePath,
]
menu.addItem(openLog)
}
menu.addItem(NSMenuItem.separator())
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
reset.target = self
reset.representedObject = row.key
menu.addItem(reset)
let compact = NSMenuItem(title: "Compact Session Log", action: #selector(self.compactSession(_:)), keyEquivalent: "")
compact.target = self
compact.representedObject = row.key
menu.addItem(compact)
if row.key != "main" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self
del.representedObject = row.key
del.isAlternate = false
del.keyEquivalentModifierMask = []
menu.addItem(del)
}
return menu
}
private func buildThinkingMenu(for row: SessionRow) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
menu.showsStateColumn = true
let levels: [String] = ["off", "minimal", "low", "medium", "high"]
let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off"
for level in levels {
let title = level.capitalized
let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "")
item.target = self
item.representedObject = [
"key": row.key,
"value": level as Any,
]
item.state = (current == level) ? .on : .off
menu.addItem(item)
}
return menu
}
private func buildVerboseMenu(for row: SessionRow) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
menu.showsStateColumn = true
let levels: [String] = ["on", "off"]
let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off"
for level in levels {
let title = level.capitalized
let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "")
item.target = self
item.representedObject = [
"key": row.key,
"value": level as Any,
]
item.state = (current == level) ? .on : .off
menu.addItem(item)
}
return menu
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
let key = dict["key"] as? String
else { return }
let value = dict["value"] as? String
Task {
do {
try await SessionActions.patchSession(key: key, thinking: .some(value))
await self.refreshCache(force: true)
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update thinking failed", error: error)
}
}
}
}
@objc
private func patchVerbose(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
let key = dict["key"] as? String
else { return }
let value = dict["value"] as? String
Task {
do {
try await SessionActions.patchSession(key: key, verbose: .some(value))
await self.refreshCache(force: true)
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update verbose failed", error: error)
}
}
}
}
@objc
private func openSessionLog(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: String],
let sessionId = dict["sessionId"],
let storePath = dict["storePath"]
else { return }
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
}
@objc
private func resetSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Reset session?",
message: "Starts a new session id for “\(key)”.",
action: "Reset")
else { return }
do {
try await SessionActions.resetSession(key: key)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Reset failed", error: error)
}
}
}
@objc
private func compactSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.",
action: "Compact")
else { return }
do {
try await SessionActions.compactSession(key: key, maxLines: 400)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Compact failed", error: error)
}
}
}
@objc
private func deleteSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete")
else { return }
do {
try await SessionActions.deleteSession(key: key)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Delete failed", error: error)
}
}
}
// MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? {
// Insert right before the separator above "Send Heartbeats".
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
}
private func initialWidth(for menu: NSMenu) -> CGFloat {
let candidates: [CGFloat] = [
menu.minimumWidth,
self.lastKnownMenuWidth ?? 0,
self.fallbackWidth,
]
let resolved = candidates.max() ?? self.fallbackWidth
return max(300, resolved)
}
// MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
if highlighted {
let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
return container
}
let hosting = NSHostingView(rootView: rootView)
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
return hosting
}
private func captureMenuWidthIfAvailable(from view: NSView) {
guard let width = view.window?.contentView?.bounds.width, width > 0 else { return }
self.lastKnownMenuWidth = max(300, width)
}
}
private final class HighlightedMenuItemHostView: NSView {
private let baseView: AnyView
private let hosting: NSHostingView<AnyView>
private var targetWidth: CGFloat
private var tracking: NSTrackingArea?
private var hovered = false {
didSet { self.updateHighlight() }
}
init(rootView: AnyView, width: CGFloat) {
self.baseView = rootView
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
self.targetWidth = max(1, width)
super.init(frame: .zero)
self.addSubview(self.hosting)
self.hosting.autoresizingMask = [.width, .height]
self.updateSizing()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var intrinsicContentSize: NSSize {
self.hosting.fittingSize
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let options: NSTrackingArea.Options = [
.mouseEnteredAndExited,
.activeAlways,
.inVisibleRect,
]
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
override func mouseEntered(with event: NSEvent) {
_ = event
self.hovered = true
}
override func mouseExited(with event: NSEvent) {
_ = event
self.hovered = false
}
override func layout() {
super.layout()
self.hosting.frame = self.bounds
}
override func draw(_ dirtyRect: NSRect) {
if self.hovered {
NSColor.selectedContentBackgroundColor.setFill()
self.bounds.fill()
}
super.draw(dirtyRect)
}
private func updateHighlight() {
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
self.updateSizing()
self.needsDisplay = true
}
private func updateSizing() {
self.hosting.frame.size.width = self.targetWidth
let size = self.hosting.fittingSize
self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height))
}
}
@@ -7,7 +7,7 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
// swiftlint:disable:next function_body_length cyclomatic_complexity
// swiftlint:disable:next function_body_length
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui."), !Self.canvasEnabled() {
+196 -76
View File
@@ -24,7 +24,7 @@ final class OnboardingController {
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle
window.setContentSize(NSSize(width: 630, height: 644))
window.setContentSize(NSSize(width: 680, height: 840))
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
@@ -62,13 +62,18 @@ struct OnboardingView: View {
@State private var anthropicAuthStatus: String?
@State private var anthropicAuthBusy = false
@State private var anthropicAuthConnected = false
@State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthAutoDetectClipboard = true
@State private var anthropicAuthAutoConnectClipboard = true
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
@State private var monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>?
@State private var needsBootstrap = false
@State private var identityName: String = ""
@State private var identityTheme: String = ""
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var hasIdentity = false
@State private var didAutoKickoff = false
@State private var showAdvancedConnection = false
@State private var preferredGatewayID: String?
@@ -78,8 +83,8 @@ struct OnboardingView: View {
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 630
private let contentHeight: CGFloat = 420
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let anthropicAuthPageIndex = 2
private let onboardingChatPageIndex = 8
@@ -88,22 +93,22 @@ struct OnboardingView: View {
private let permissionsPageIndex = 5
static func pageOrder(
for mode: AppState.ConnectionMode,
needsBootstrap: Bool) -> [Int]
hasIdentity: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9]
case .unconfigured:
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
case .local:
needsBootstrap ? [0, 1, 2, 5, 6, 8, 9] : [0, 1, 2, 5, 6, 9]
hasIdentity ? [0, 1, 2, 5, 6, 9] : [0, 1, 2, 5, 6, 8, 9]
}
}
private var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
Self.pageOrder(for: self.state.connectionMode, hasIdentity: self.hasIdentity)
}
private var pageCount: Int { self.pageOrder.count }
@@ -140,9 +145,9 @@ struct OnboardingView: View {
var body: some View {
VStack(spacing: 0) {
GlowingClawdisIcon(size: 130, glowIntensity: 0.28)
.offset(y: 10)
.frame(height: 145)
GlowingClawdisIcon(size: 156, glowIntensity: 0.28)
.offset(y: 13)
.frame(height: 181)
GeometryReader { _ in
HStack(spacing: 0) {
@@ -160,10 +165,9 @@ struct OnboardingView: View {
}
.frame(height: self.contentHeight)
Spacer(minLength: 0)
self.navigationBar
}
.frame(width: self.pageWidth, height: 644)
.frame(width: self.pageWidth, height: 840)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.currentPage = 0
@@ -177,7 +181,7 @@ struct OnboardingView: View {
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
}
.onChange(of: self.needsBootstrap) { _, _ in
.onChange(of: self.hasIdentity) { _, _ in
if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1)
}
@@ -193,7 +197,7 @@ struct OnboardingView: View {
self.loadWorkspaceDefaults()
self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus()
self.refreshBootstrapStatus()
self.loadIdentityDefaults()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
}
}
@@ -225,6 +229,8 @@ struct OnboardingView: View {
self.connectionPage()
case 2:
self.anthropicAuthPage()
case 3:
self.identityPage()
case 5:
self.permissionsPage()
case 6:
@@ -243,7 +249,9 @@ struct OnboardingView: View {
VStack(spacing: 22) {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text("Clawdis is a powerful personal AI assistant that can connect to WhatsApp or Telegram.")
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation. " +
"Setup takes a few minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -530,7 +538,7 @@ struct OnboardingView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -555,14 +563,14 @@ struct OnboardingView: View {
}
Text(
"This lets Clawdis use Claude immediately. Credentials are stored at " +
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
"This lets Pi use Claude immediately. Credentials are stored at " +
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Text(ClawdisOAuthStore.oauthURL().path)
Text(PiOAuthStore.oauthURL().path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -571,7 +579,7 @@ struct OnboardingView: View {
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
@@ -683,9 +691,9 @@ struct OnboardingView: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
} catch {
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
}
@@ -717,12 +725,104 @@ struct OnboardingView: View {
}
private func refreshAnthropicOAuthStatus() {
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
let status = ClawdisOAuthStore.anthropicOAuthStatus()
let status = PiOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
}
private func identityPage() -> some View {
self.onboardingPage {
Text("Identity")
.font(.largeTitle.weight(.semibold))
Text("Name your agent, pick a vibe, and choose an emoji.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 16) {
VStack(alignment: .leading, spacing: 10) {
Text("Agent name")
.font(.headline)
TextField("Clawd", text: self.$identityName)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Theme")
.font(.headline)
TextField("helpful space lobster", text: self.$identityTheme)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Emoji")
.font(.headline)
HStack(spacing: 12) {
TextField("🦞", text: self.$identityEmoji)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
Button("Suggest") {
let suggested = AgentIdentityEmoji.suggest(theme: self.identityTheme)
self.identityEmoji = suggested
}
.buttonStyle(.bordered)
}
}
Divider().padding(.vertical, 2)
VStack(alignment: .leading, spacing: 8) {
Text("Workspace")
.font(.headline)
Text(self.workspacePath.isEmpty ? AgentWorkspace
.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()) : self.workspacePath)
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.applyIdentity() }
} label: {
if self.identityApplying {
ProgressView()
} else {
Text("Save identity")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.identityApplying || self.identityName.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
Button("Open workspace") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
NSWorkspace.shared.open(url)
}
.buttonStyle(.bordered)
.disabled(self.identityApplying)
}
Text(
"This writes your identity to `~/.clawdis/clawdis.json` and into `AGENTS.md` " +
"inside the workspace. " +
"Treat that workspace as the agent’s “memory” and consider making it a private git repo.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let status = self.identityStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
private func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
@@ -734,13 +834,9 @@ struct OnboardingView: View {
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 8, padding: 12) {
self.onboardingCard {
ForEach(Capability.allCases, id: \.self) { cap in
PermissionRow(
capability: cap,
status: self.permissionMonitor.status[cap] ?? false,
compact: true)
{
PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) {
Task { await self.request(cap) }
}
}
@@ -909,7 +1005,7 @@ struct OnboardingView: View {
}
private func onboardingChatPage() -> some View {
VStack(spacing: 16) {
self.onboardingPage {
Text("Meet your agent")
.font(.largeTitle.weight(.semibold))
Text(
@@ -923,12 +1019,9 @@ struct OnboardingView: View {
self.onboardingCard(padding: 8) {
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(maxHeight: .infinity)
.frame(height: 420)
}
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 28)
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
}
private func readyPage() -> some View {
@@ -948,8 +1041,8 @@ struct OnboardingView: View {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `clawdis` package and make sure credentials exist
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
""",
systemImage: "network")
Divider()
@@ -959,13 +1052,6 @@ struct OnboardingView: View {
title: "Open the menu bar panel",
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
systemImage: "bubble.left.and.bubble.right")
self.featureActionRow(
title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Connections to link providers and monitor status.",
systemImage: "link")
{
self.openSettings(tab: .connections)
}
self.featureRow(
title: "Try Voice Wake",
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
@@ -975,6 +1061,10 @@ struct OnboardingView: View {
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
"and richer visuals in Canvas.",
systemImage: "rectangle.inset.filled.and.person.filled")
self.featureRow(
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge")
self.featureActionRow(
title: "Give your agent more powers",
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
@@ -1044,12 +1134,12 @@ struct OnboardingView: View {
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 28)
.padding(.bottom, 13)
.frame(minHeight: 60, alignment: .bottom)
.padding(.bottom, 36)
.frame(minHeight: 60)
}
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
VStack(spacing: 16) {
VStack(spacing: 22) {
content()
Spacer()
}
@@ -1086,7 +1176,6 @@ struct OnboardingView: View {
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private func featureActionRow(
@@ -1111,7 +1200,6 @@ struct OnboardingView: View {
}
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
private func handleBack() {
@@ -1271,34 +1359,40 @@ struct OnboardingView: View {
let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.refreshBootstrapStatus()
}
private func ensureDefaultWorkspace() {
guard self.state.connectionMode == .local else { return }
let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
switch AgentWorkspace.bootstrapSafety(for: url) {
case .safe:
do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
}
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
}
case let .unsafe(reason):
self.workspaceStatus = "Workspace not touched: \(reason)"
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
self.refreshBootstrapStatus()
}
private func refreshBootstrapStatus() {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
if self.needsBootstrap {
self.didAutoKickoff = false
private func loadIdentityDefaults() {
if let identity = ClawdisConfigFile.loadIdentity() {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
self.hasIdentity = !identity.isEmpty
return
}
self.hasIdentity = false
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityName = "Clawd"
}
if self.identityTheme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityTheme = "helpful space lobster"
}
if self.identityEmoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityEmoji = AgentIdentityEmoji.suggest(theme: self.identityTheme)
}
}
@@ -1319,22 +1413,48 @@ struct OnboardingView: View {
do {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) {
self.workspaceStatus = "Workspace not created: \(reason)"
return
}
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
self.refreshBootstrapStatus()
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
private func applyIdentity() async {
guard !self.identityApplying else { return }
self.identityApplying = true
defer { self.identityApplying = false }
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityStatus = "Please enter a name first."
return
}
var identity = AgentIdentity(
name: self.identityName,
theme: self.identityTheme,
emoji: self.identityEmoji)
if identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
identity.emoji = AgentIdentityEmoji.suggest(theme: identity.theme)
self.identityEmoji = identity.emoji
}
do {
let workspaceURL = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
try AgentWorkspace.upsertIdentity(workspaceURL: workspaceURL, identity: identity)
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: workspaceURL))
ClawdisConfigFile.setIdentity(identity)
self.identityStatus = "Saved identity to AGENTS.md and ~/.clawdis/clawdis.json"
} catch {
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
}
}
private func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return }
guard self.needsBootstrap else { return }
guard !self.hasIdentity else { return }
guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true
@@ -58,21 +58,13 @@ struct PermissionStatusList: View {
struct PermissionRow: View {
let capability: Capability
let status: Bool
let compact: Bool
let action: () -> Void
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
self.capability = capability
self.status = status
self.compact = compact
self.action = action
}
var body: some View {
HStack(spacing: self.compact ? 10 : 12) {
HStack(spacing: 12) {
ZStack {
Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15))
.frame(width: self.iconSize, height: self.iconSize)
.frame(width: 32, height: 32)
Image(systemName: self.icon)
.foregroundStyle(self.status ? Color.green : Color.secondary)
}
@@ -89,11 +81,9 @@ struct PermissionRow: View {
.buttonStyle(.bordered)
}
}
.padding(.vertical, self.compact ? 4 : 6)
.padding(.vertical, 6)
}
private var iconSize: CGFloat { self.compact ? 28 : 32 }
private var title: String {
switch self.capability {
case .appleScript: "Automation (AppleScript)"
@@ -1,92 +0,0 @@
import AppKit
import Foundation
enum SessionActions {
static func patchSession(
key: String,
thinking: String?? = nil,
verbose: String?? = nil) async throws
{
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
if let thinking {
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
}
if let verbose {
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
}
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
}
static func resetSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.reset",
params: ["key": AnyHashable(key)])
}
static func deleteSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.delete",
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
}
static func compactSession(key: String, maxLines: Int = 400) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.compact",
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)])
}
@MainActor
static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: action)
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert.runModal() == .alertFirstButtonReturn
}
@MainActor
static func presentError(title: String, error: Error) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.runModal()
}
@MainActor
static func openSessionLogInCode(sessionId: String, storePath: String?) {
let candidates: [URL] = {
var urls: [URL] = []
if let storePath, !storePath.isEmpty {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
return urls
}()
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
guard let url = existing else {
let alert = NSAlert()
alert.messageText = "Session log not found"
alert.informativeText = sessionId
alert.runModal()
return
}
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", url.path]
if (try? proc.run()) != nil {
return
}
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
@@ -1,65 +0,0 @@
import SwiftUI
private struct MenuItemHighlightedKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var menuItemHighlighted: Bool {
get { self[MenuItemHighlightedKey.self] }
set { self[MenuItemHighlightedKey.self] = newValue }
}
}
struct SessionMenuLabelView: View {
let row: SessionRow
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private let paddingLeading: CGFloat = 26
private let paddingTrailing: CGFloat = 18
private let barHeight: CGFloat = 6
private var primaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryTextColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryTextColor)
.padding(.leading, 2)
}
}
.padding(.vertical, 10)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
}
}
@@ -21,10 +21,6 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general)
ConnectionsSettings()
.tabItem { Label("Connections", systemImage: "link") }
.tag(SettingsTab.connections)
VoiceWakeSettings(state: self.state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake)
@@ -129,13 +125,12 @@ struct SettingsRootView: View {
}
enum SettingsTab: CaseIterable {
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 824 // wider
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 724 // +10% (tabs fit better)
static let windowHeight: CGFloat = 790 // +10% (more room)
var title: String {
switch self {
case .general: "General"
case .connections: "Connections"
case .skills: "Skills"
case .sessions: "Sessions"
case .cron: "Cron"
@@ -56,8 +56,8 @@ enum ShellExecutor {
let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = Int(process.terminationStatus)
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
stderr: String(bytes: err, encoding: .utf8) ?? "",
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: status,
timedOut: false,
success: status == 0,
+55 -41
View File
@@ -6,6 +6,7 @@ struct SkillsSettings: View {
@Bindable var state: AppState
@State private var model = SkillsSettingsModel()
@State private var envEditor: EnvEditorState?
@State private var searchQuery = ""
@State private var filter: SkillsFilter = .all
init(state: AppState = AppStateStore.shared) {
@@ -82,7 +83,8 @@ struct SkillsSettings: View {
SkillRow(
skill: skill,
isBusy: self.model.isBusy(skill: skill),
connectionMode: self.state.connectionMode,
canInstallLocally: self.state.connectionMode == .local,
defaultInstallTarget: self.defaultInstallTarget(for: skill),
onToggleEnabled: { enabled in
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
},
@@ -104,6 +106,7 @@ struct SkillsSettings: View {
}
}
.listStyle(.inset)
.searchable(text: self.$searchQuery, placement: .automatic, prompt: "Search skills")
}
}
@@ -120,19 +123,31 @@ struct SkillsSettings: View {
}
private var filteredSkills: [SkillStatus] {
self.model.skills.filter { skill in
let trimmed = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let query = trimmed.lowercased()
return self.model.skills.filter { skill in
if !query.isEmpty {
let matchesName = skill.name.lowercased().contains(query)
let matchesDescription = skill.description.lowercased().contains(query)
if !(matchesName || matchesDescription) { return false }
}
switch self.filter {
case .all:
true
return true
case .ready:
!skill.disabled && skill.eligible
return !skill.disabled && skill.eligible
case .needsSetup:
!skill.disabled && !skill.eligible
return !skill.disabled && !skill.eligible
case .disabled:
skill.disabled
return skill.disabled
}
}
}
private func defaultInstallTarget(for skill: SkillStatus) -> InstallTarget {
let localPreferred = ["imsg", "peekaboo", "spotify-player"]
return localPreferred.contains(skill.skillKey) ? .local : .gateway
}
}
private enum SkillsFilter: String, CaseIterable, Identifiable {
@@ -165,10 +180,11 @@ private enum InstallTarget: String, CaseIterable {
private struct SkillRow: View {
let skill: SkillStatus
let isBusy: Bool
let connectionMode: AppState.ConnectionMode
let canInstallLocally: Bool
let onToggleEnabled: (Bool) -> Void
let onInstall: (SkillInstallOption, InstallTarget) -> Void
let onSetEnv: (String, Bool) -> Void
@State private var installTarget: InstallTarget
private var missingBins: [String] { self.skill.missing.bins }
private var missingEnv: [String] { self.skill.missing.env }
@@ -177,17 +193,22 @@ private struct SkillRow: View {
init(
skill: SkillStatus,
isBusy: Bool,
connectionMode: AppState.ConnectionMode,
canInstallLocally: Bool,
defaultInstallTarget: InstallTarget,
onToggleEnabled: @escaping (Bool) -> Void,
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
onSetEnv: @escaping (String, Bool) -> Void)
{
self.skill = skill
self.isBusy = isBusy
self.connectionMode = connectionMode
self.canInstallLocally = canInstallLocally
self.onToggleEnabled = onToggleEnabled
self.onInstall = onInstall
self.onSetEnv = onSetEnv
let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally)
? .gateway
: defaultInstallTarget
self._installTarget = State(initialValue: initialTarget)
}
var body: some View {
@@ -327,31 +348,28 @@ private struct SkillRow: View {
private var trailingActions: some View {
VStack(alignment: .trailing, spacing: 8) {
if !self.installOptions.isEmpty {
ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in
HStack(spacing: 6) {
if self.showGatewayInstall {
Button("Install on Gateway") { self.onInstall(option, .gateway) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
}
if self.showGatewayInstall {
Button("Install on This Mac") { self.onInstall(option, .local) }
.buttonStyle(.bordered)
.disabled(self.isBusy)
.help(
self.localInstallNeedsSwitch
? "Switches to Local mode to install on this Mac."
: "")
} else {
Button("Install on This Mac") { self.onInstall(option, .local) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
.help(
self.localInstallNeedsSwitch
? "Switches to Local mode to install on this Mac."
: "")
}
HStack(spacing: 6) {
Text("Install on")
.font(.caption)
.foregroundStyle(.secondary)
Picker("Install on", selection: self.$installTarget) {
Text("Gateway")
.tag(InstallTarget.gateway)
Text("This Mac")
.tag(InstallTarget.local)
.disabled(!self.canInstallLocally)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 160)
.controlSize(.small)
.help(self.canInstallLocally ? "" : "Local install requires a local gateway connection.")
}
ForEach(self.installOptions) { option in
Button("Install") { self.onInstall(option, self.installTarget) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy || self.installBlocked)
.help(self.installBlocked ? "Local install requires a local gateway connection." : "")
}
} else {
Toggle("", isOn: self.enabledBinding)
@@ -390,12 +408,8 @@ private struct SkillRow: View {
!self.missingConfig.isEmpty
}
private var showGatewayInstall: Bool {
self.connectionMode == .remote
}
private var localInstallNeedsSwitch: Bool {
self.connectionMode != .local
private var installBlocked: Bool {
self.installTarget == .local && !self.canInstallLocally
}
private func formatConfigValue(_ value: AnyCodable?) -> String {
@@ -507,8 +521,8 @@ final class SkillsSettingsModel {
await self.withBusy(skill.skillKey) {
do {
if target == .local, AppStateStore.shared.connectionMode != .local {
AppStateStore.shared.connectionMode = .local
self.statusMessage = "Switched to Local mode to install on this Mac"
self.statusMessage = "Local install requires a local gateway connection"
return
}
let result = try await GatewayConnection.shared.skillsInstall(
name: skill.name,
@@ -1,372 +0,0 @@
import SwiftUI
private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
case off
case serve
case funnel
var id: String { self.rawValue }
var label: String {
switch self {
case .off: "Off"
case .serve: "Tailnet (Serve)"
case .funnel: "Public (Funnel)"
}
}
var description: String {
switch self {
case .off:
"No automatic Tailscale configuration."
case .serve:
"Tailnet-only HTTPS via Tailscale Serve."
case .funnel:
"Public HTTPS via Tailscale Funnel (requires auth)."
}
}
}
private enum GatewayAuthMode: String, CaseIterable, Identifiable {
case system
case password
var id: String { self.rawValue }
var label: String {
switch self {
case .system: "System password"
case .password: "Shared password"
}
}
}
struct TailscaleIntegrationSection: View {
let connectionMode: AppState.ConnectionMode
let isPaused: Bool
@Environment(TailscaleService.self) private var tailscaleService
@State private var hasLoaded = false
@State private var tailscaleMode: GatewayTailscaleMode = .off
@State private var requireCredentialsForServe = false
@State private var authMode: GatewayAuthMode = .system
@State private var username: String = ""
@State private var password: String = ""
@State private var statusMessage: String?
@State private var validationMessage: String?
@State private var statusTimer: Timer?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Tailscale (dashboard access)")
.font(.callout.weight(.semibold))
self.statusRow
if !self.tailscaleService.isInstalled {
self.installButtons
} else {
self.modePicker
if self.tailscaleMode != .off {
self.accessURLRow
}
if self.tailscaleMode == .serve {
self.serveAuthSection
}
if self.tailscaleMode == .funnel {
self.funnelAuthSection
}
}
if self.connectionMode != .local {
Text("Local mode required. Update settings on the gateway host.")
.font(.caption)
.foregroundStyle(.secondary)
}
if let validationMessage {
Text(validationMessage)
.font(.caption)
.foregroundStyle(.orange)
} else if let statusMessage {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
.disabled(self.connectionMode != .local)
.task {
guard !self.hasLoaded else { return }
self.hasLoaded = true
self.loadConfig()
await self.tailscaleService.checkTailscaleStatus()
self.startStatusTimer()
}
.onDisappear {
self.stopStatusTimer()
}
.onChange(of: self.tailscaleMode) { _, _ in
self.applySettings()
}
.onChange(of: self.requireCredentialsForServe) { _, _ in
self.applySettings()
}
.onChange(of: self.authMode) { _, _ in
self.applySettings()
}
}
private var statusRow: some View {
HStack(spacing: 8) {
Circle()
.fill(self.statusColor)
.frame(width: 10, height: 10)
Text(self.statusText)
.font(.callout)
Spacer()
Button("Refresh") {
Task { await self.tailscaleService.checkTailscaleStatus() }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private var statusColor: Color {
if !self.tailscaleService.isInstalled { return .yellow }
if self.tailscaleService.isRunning { return .green }
return .orange
}
private var statusText: String {
if !self.tailscaleService.isInstalled { return "Tailscale is not installed" }
if self.tailscaleService.isRunning { return "Tailscale is installed and running" }
return "Tailscale is installed but not running"
}
private var installButtons: some View {
HStack(spacing: 12) {
Button("App Store") { self.tailscaleService.openAppStore() }
.buttonStyle(.link)
Button("Direct Download") { self.tailscaleService.openDownloadPage() }
.buttonStyle(.link)
Button("Setup Guide") { self.tailscaleService.openSetupGuide() }
.buttonStyle(.link)
}
.controlSize(.small)
}
private var modePicker: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Exposure mode")
.font(.callout.weight(.semibold))
Picker("Exposure", selection: self.$tailscaleMode) {
ForEach(GatewayTailscaleMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
Text(self.tailscaleMode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var accessURLRow: some View {
if let host = self.tailscaleService.tailscaleHostname {
let url = "https://\(host)/ui/"
HStack(spacing: 8) {
Text("Dashboard URL:")
.font(.caption)
.foregroundStyle(.secondary)
if let link = URL(string: url) {
Link(url, destination: link)
.font(.system(.caption, design: .monospaced))
} else {
Text(url)
.font(.system(.caption, design: .monospaced))
}
}
} else if !self.tailscaleService.isRunning {
Text("Start Tailscale to get your tailnet hostname.")
.font(.caption)
.foregroundStyle(.secondary)
}
if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning {
Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private var serveAuthSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Require credentials", isOn: self.$requireCredentialsForServe)
.toggleStyle(.checkbox)
if self.requireCredentialsForServe {
self.authModePicker
self.authFields
} else {
Text("Serve uses Tailscale identity headers; no password required.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var funnelAuthSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Funnel requires authentication.")
.font(.caption)
.foregroundStyle(.secondary)
self.authModePicker
self.authFields
}
}
private var authModePicker: some View {
Picker("Auth", selection: self.$authMode) {
ForEach(GatewayAuthMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var authFields: some View {
if self.authMode == .system {
TextField("Username (optional)", text: self.$username)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { self.applySettings() }
} else {
SecureField("Password", text: self.$password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { self.applySettings() }
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Update password") { self.applySettings() }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private func loadConfig() {
let gateway = ClawdisConfigFile.loadGatewayDict()
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
let modeRaw = (tailscale["mode"] as? String) ?? "off"
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
let auth = gateway["auth"] as? [String: Any] ?? [:]
let authModeRaw = auth["mode"] as? String
let allowTailscale = auth["allowTailscale"] as? Bool
if let authModeRaw, authModeRaw == "password" {
self.authMode = .password
} else {
self.authMode = .system
}
self.username = auth["username"] as? String ?? ""
self.password = auth["password"] as? String ?? ""
if self.tailscaleMode == .serve {
let usesExplicitAuth = authModeRaw == "password" || authModeRaw == "system"
if let allowTailscale, allowTailscale == false {
self.requireCredentialsForServe = true
} else {
self.requireCredentialsForServe = usesExplicitAuth
}
} else {
self.requireCredentialsForServe = false
}
}
private func applySettings() {
guard self.hasLoaded else { return }
self.validationMessage = nil
self.statusMessage = nil
let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
let requiresPassword = self.tailscaleMode == .funnel
|| (self.tailscaleMode == .serve && self.requireCredentialsForServe)
if requiresPassword, self.authMode == .password, trimmedPassword.isEmpty {
self.validationMessage = "Password required for this mode."
return
}
ClawdisConfigFile.updateGatewayDict { gateway in
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue
gateway["tailscale"] = tailscale
if self.tailscaleMode != .off {
gateway["bind"] = "loopback"
}
guard self.tailscaleMode != .off else { return }
var auth = gateway["auth"] as? [String: Any] ?? [:]
if self.tailscaleMode == .serve, !self.requireCredentialsForServe {
auth["allowTailscale"] = true
auth.removeValue(forKey: "mode")
auth.removeValue(forKey: "password")
auth.removeValue(forKey: "username")
} else {
auth["allowTailscale"] = false
auth["mode"] = self.authMode.rawValue
if self.authMode == .password {
auth["password"] = trimmedPassword
auth.removeValue(forKey: "username")
} else {
let trimmedUsername = self.username.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUsername.isEmpty {
auth.removeValue(forKey: "username")
} else {
auth["username"] = trimmedUsername
}
auth.removeValue(forKey: "password")
}
}
if auth.isEmpty {
gateway.removeValue(forKey: "auth")
} else {
gateway["auth"] = auth
}
}
if self.connectionMode == .local, !self.isPaused {
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
} else {
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
}
self.restartGatewayIfNeeded()
}
private func restartGatewayIfNeeded() {
guard self.connectionMode == .local, !self.isPaused else { return }
Task { await GatewayLaunchAgentManager.kickstart() }
}
private func startStatusTimer() {
self.stopStatusTimer()
self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { await self.tailscaleService.checkTailscaleStatus() }
}
}
private func stopStatusTimer() {
self.statusTimer?.invalidate()
self.statusTimer = nil
}
}
@@ -1,150 +0,0 @@
import AppKit
import Foundation
import Observation
import os
/// Manages Tailscale integration and status checking.
@Observable
@MainActor
final class TailscaleService {
static let shared = TailscaleService()
/// Tailscale local API endpoint.
private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data"
/// API request timeout in seconds.
private static let apiTimeoutInterval: TimeInterval = 5.0
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "tailscale")
/// Indicates if the Tailscale app is installed on the system.
private(set) var isInstalled = false
/// Indicates if Tailscale is currently running.
private(set) var isRunning = false
/// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net").
private(set) var tailscaleHostname: String?
/// The Tailscale IPv4 address for this device.
private(set) var tailscaleIP: String?
/// Error message if status check fails.
private(set) var statusError: String?
private init() {
Task { await self.checkTailscaleStatus() }
}
func checkAppInstallation() -> Bool {
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
self.logger.info("Tailscale app installed: \(installed)")
return installed
}
private struct TailscaleAPIResponse: Codable {
let status: String
let deviceName: String
let tailnetName: String
let iPv4: String?
private enum CodingKeys: String, CodingKey {
case status = "Status"
case deviceName = "DeviceName"
case tailnetName = "TailnetName"
case iPv4 = "IPv4"
}
}
private func fetchTailscaleStatus() async -> TailscaleAPIResponse? {
guard let url = URL(string: Self.tailscaleAPIEndpoint) else {
self.logger.error("Invalid Tailscale API URL")
return nil
}
do {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
self.logger.warning("Tailscale API returned non-200 status")
return nil
}
let decoder = JSONDecoder()
return try decoder.decode(TailscaleAPIResponse.self, from: data)
} catch {
self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))")
return nil
}
}
func checkTailscaleStatus() async {
self.isInstalled = self.checkAppInstallation()
guard self.isInstalled else {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not installed"
return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning {
let deviceName = apiResponse.deviceName
.lowercased()
.replacingOccurrences(of: " ", with: "-")
let tailnetName = apiResponse.tailnetName
.replacingOccurrences(of: ".ts.net", with: "")
.replacingOccurrences(of: ".tailscale.net", with: "")
self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
self.tailscaleIP = apiResponse.iPv4
self.statusError = nil
self.logger.info(
"Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")")
} else {
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not running"
}
} else {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running")
}
}
func openTailscaleApp() {
if let url = URL(string: "file:///Applications/Tailscale.app") {
NSWorkspace.shared.open(url)
}
}
func openAppStore() {
if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") {
NSWorkspace.shared.open(url)
}
}
func openDownloadPage() {
if let url = URL(string: "https://tailscale.com/download/macos") {
NSWorkspace.shared.open(url)
}
}
func openSetupGuide() {
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
NSWorkspace.shared.open(url)
}
}
}
@@ -2,7 +2,6 @@ import AVFoundation
import Foundation
import OSLog
import Speech
import SwabbleKit
#if canImport(AppKit)
import AppKit
#endif
@@ -36,7 +35,6 @@ actor VoiceWakeRuntime {
private var currentConfig: RuntimeConfig?
private var listeningState: ListeningState = .idle
private var overlayToken: UUID?
private var activeTriggerEndTime: TimeInterval?
// Tunables
// Silence threshold once we've captured user speech (post-trigger).
@@ -149,13 +147,9 @@ actor VoiceWakeRuntime {
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
guard let self else { return }
let transcript = result?.bestTranscription.formattedString
let segments = result.flatMap { result in
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? []
let isFinal = result?.isFinal ?? false
Task { await self.handleRecognition(
transcript: transcript,
segments: segments,
isFinal: isFinal,
error: error,
config: config,
@@ -190,7 +184,6 @@ actor VoiceWakeRuntime {
self.audioEngine = nil
self.currentConfig = nil
self.listeningState = .idle
self.activeTriggerEndTime = nil
self.logger.debug("voicewake runtime stopped")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
@@ -213,7 +206,6 @@ actor VoiceWakeRuntime {
private func handleRecognition(
transcript: String?,
segments: [WakeWordSegment],
isFinal: Bool,
error: Error?,
config: RuntimeConfig,
@@ -232,11 +224,7 @@ actor VoiceWakeRuntime {
if !transcript.isEmpty {
self.lastHeard = now
if self.isCapturing {
let trimmed = Self.commandAfterTrigger(
transcript: transcript,
segments: segments,
triggerEndTime: self.activeTriggerEndTime,
triggers: config.triggers)
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
self.capturedTranscript = trimmed
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
if isFinal {
@@ -264,27 +252,37 @@ actor VoiceWakeRuntime {
if self.isCapturing { return }
let gateConfig = WakeWordGateConfig(triggers: config.triggers)
if let match = WakeWordGate.match(transcript: transcript, segments: segments, config: gateConfig) {
if Self.matches(text: transcript, triggers: config.triggers) {
if let cooldown = cooldownUntil, now < cooldown {
return
}
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
await self.beginCapture(transcript: transcript, config: config)
}
}
private func beginCapture(command: String, triggerEndTime: TimeInterval, config: RuntimeConfig) async {
private static func matches(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let t = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
if t.isEmpty { continue }
if normalized.contains(t) { return true }
}
return false
}
private func beginCapture(transcript: String, config: RuntimeConfig) async {
self.listeningState = .voiceWake
self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
self.capturedTranscript = command
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
self.capturedTranscript = trimmed
self.committedTranscript = ""
self.volatileTranscript = command
self.volatileTranscript = trimmed
self.captureStartedAt = Date()
self.cooldownUntil = nil
self.heardBeyondTrigger = !command.isEmpty
self.heardBeyondTrigger = !trimmed.isEmpty
self.triggerChimePlayed = false
self.activeTriggerEndTime = triggerEndTime
if config.triggerChime != .none, !self.triggerChimePlayed {
self.triggerChimePlayed = true
@@ -356,7 +354,6 @@ actor VoiceWakeRuntime {
self.lastHeard = nil
self.heardBeyondTrigger = false
self.triggerChimePlayed = false
self.activeTriggerEndTime = nil
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let token = self.overlayToken {
@@ -470,22 +467,6 @@ actor VoiceWakeRuntime {
return text
}
private static func commandAfterTrigger(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval?,
triggers: [String]) -> String
{
guard let triggerEndTime else {
return trimmedAfterTrigger(transcript, triggers: triggers)
}
let trimmed = WakeWordGate.commandText(
transcript: transcript,
segments: segments,
triggerEndTime: triggerEndTime)
return trimmed.isEmpty ? trimmedAfterTrigger(transcript, triggers: triggers) : trimmed
}
#if DEBUG
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
self.trimmedAfterTrigger(text, triggers: triggers)
@@ -500,6 +481,9 @@ actor VoiceWakeRuntime {
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
}
static func _testMatches(text: String, triggers: [String]) -> Bool {
self.matches(text: text, triggers: triggers)
}
#endif
private static func delta(after committed: String, current: String) -> String {
@@ -2,7 +2,6 @@ import AVFoundation
import Foundation
import OSLog
import Speech
import SwabbleKit
enum VoiceWakeTestState: Equatable {
case idle
@@ -94,16 +93,14 @@ final class VoiceWakeTester {
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self, !self.isStopping else { return }
let text = result?.bestTranscription.formattedString ?? ""
let segments = result.map { WakeWordSpeechSegments.from(transcription: $0.bestTranscription, transcript: text) } ?? []
let gateConfig = WakeWordGateConfig(triggers: triggers)
let match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
let matched = Self.matches(text: text, triggers: triggers)
let isFinal = result?.isFinal ?? false
let errorMessage = error?.localizedDescription
Task { [weak self] in
guard let self, !self.isStopping else { return }
await self.handleResult(
match: match,
matched: matched,
text: text,
isFinal: isFinal,
errorMessage: errorMessage,
@@ -123,7 +120,7 @@ final class VoiceWakeTester {
}
private func handleResult(
match: WakeWordGateMatch?,
matched: Bool,
text: String,
isFinal: Bool,
errorMessage: String?,
@@ -132,15 +129,15 @@ final class VoiceWakeTester {
if !text.isEmpty {
self.lastHeard = Date()
}
if let match, !match.command.isEmpty {
if matched, !text.isEmpty {
self.holdingAfterDetect = true
self.detectedText = match.command
self.logger.info("voice wake detected; forwarding (len=\(match.command.count))")
self.detectedText = text
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
Task.detached {
await VoiceWakeForwarder.forward(transcript: match.command)
await VoiceWakeForwarder.forward(transcript: text)
}
Task { @MainActor in onUpdate(.detected(match.command)) }
Task { @MainActor in onUpdate(.detected(text)) }
self.holdUntilSilence(onUpdate: onUpdate)
return
}
@@ -190,6 +187,15 @@ final class VoiceWakeTester {
_ = preferredMicID
}
private static func matches(text: String, triggers: [String]) -> Bool {
let lowered = text.lowercased()
return triggers.contains { lowered.contains($0.lowercased()) }
}
static func _testMatches(text: String, triggers: [String]) -> Bool {
self.matches(text: text, triggers: triggers)
}
private nonisolated static func ensurePermissions() async throws -> Bool {
let speechStatus = SFSpeechRecognizer.authorizationStatus()
if speechStatus == .notDetermined {
@@ -140,7 +140,6 @@ final class WebChatSwiftUIWindowController {
private let presentation: WebChatPresentation
private let sessionKey: String
private let hosting: NSHostingController<ClawdisChatView>
private let contentController: NSViewController
private var window: NSWindow?
private var dismissMonitor: Any?
var onClosed: (() -> Void)?
@@ -155,8 +154,11 @@ final class WebChatSwiftUIWindowController {
self.presentation = presentation
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm))
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
self.hosting.view.wantsLayer = true
self.hosting.view.layer?.cornerCurve = .continuous
self.hosting.view.layer?.cornerRadius = 16
self.hosting.view.layer?.masksToBounds = true
self.window = Self.makeWindow(for: presentation, contentViewController: self.hosting)
}
deinit {}
@@ -265,13 +267,11 @@ final class WebChatSwiftUIWindowController {
window.isReleasedWhenClosed = false
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.backgroundColor = .clear
window.isOpaque = false
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
window.center()
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
window.minSize = NSSize(width: 880, height: 680)
window.contentView?.wantsLayer = true
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
return window
case .panel:
let panel = WebChatPanel(
@@ -290,8 +290,6 @@ final class WebChatSwiftUIWindowController {
panel.isOpaque = false
panel.contentViewController = contentViewController
panel.becomesKeyOnlyIfNeeded = true
panel.contentView?.wantsLayer = true
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
panel.setFrame(
WindowPlacement.topRightFrame(
size: WebChatSwiftUILayout.panelSize,
@@ -300,45 +298,4 @@ final class WebChatSwiftUIWindowController {
return panel
}
}
private static func makeContentController(
for presentation: WebChatPresentation,
hosting: NSHostingController<ClawdisChatView>) -> NSViewController
{
let controller = NSViewController()
let effectView = NSVisualEffectView()
effectView.material = .sidebar
effectView.blendingMode = .behindWindow
effectView.state = .active
effectView.translatesAutoresizingMaskIntoConstraints = true
effectView.autoresizingMask = [.width, .height]
effectView.wantsLayer = true
effectView.layer?.cornerCurve = .continuous
let cornerRadius: CGFloat
switch presentation {
case .panel:
cornerRadius = 16
case .window:
cornerRadius = 12
}
effectView.layer?.cornerRadius = cornerRadius
effectView.layer?.masksToBounds = true
hosting.view.translatesAutoresizingMaskIntoConstraints = false
hosting.view.wantsLayer = true
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
controller.addChild(hosting)
effectView.addSubview(hosting.view)
controller.view = effectView
NSLayoutConstraint.activate([
hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor)
])
return controller
}
}
@@ -0,0 +1,21 @@
import Foundation
import Testing
@testable import Clawdis
@Suite
struct AgentIdentityTests {
@Test
func isEmptyTreatsWhitespaceAsEmpty() {
#expect(AgentIdentity(name: " ", theme: "\n", emoji: "\t").isEmpty == true)
#expect(AgentIdentity(name: "Pi", theme: "", emoji: "").isEmpty == false)
}
@Test
func emojiSuggestMatchesKnownThemes() {
#expect(AgentIdentityEmoji.suggest(theme: "") == "🦞")
#expect(AgentIdentityEmoji.suggest(theme: "shark") == "🦈")
#expect(AgentIdentityEmoji.suggest(theme: " Octopus helper ") == "🐙")
#expect(AgentIdentityEmoji.suggest(theme: "unknown") == "🦞")
}
}
@@ -48,76 +48,4 @@ struct AgentWorkspaceTests {
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL)
}
@Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
let result = AgentWorkspace.bootstrapSafety(for: tmp)
switch result {
case .unsafe:
break
case .safe:
#expect(false, "Expected unsafe bootstrap safety result.")
}
}
@Test
func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
let result = AgentWorkspace.bootstrapSafety(for: tmp)
switch result {
case .safe:
break
case .unsafe:
#expect(false, "Expected safe bootstrap safety result.")
}
}
@Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
}
@Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
try """
# IDENTITY.md - Agent Identity
- Name: Clawd
- Creature: Space Lobster
- Vibe: Helpful
- Emoji: lobster
""".write(to: identityURL, atomically: true, encoding: .utf8)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8)
#expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp))
}
}
@@ -6,7 +6,7 @@ import Testing
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let key = "CLAWDIS_OAUTH_DIR"
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -61,3 +61,4 @@ struct AnthropicAuthResolverTests {
#expect(mode == .missing)
}
}
@@ -3,18 +3,18 @@ import Testing
@testable import Clawdis
@Suite
struct ClawdisOAuthStoreTests {
struct PiOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForClawdisOAuthDir() throws {
let key = "CLAWDIS_OAUTH_DIR"
func usesEnvOverrideForPiAgentDir() throws {
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -25,10 +25,10 @@ struct ClawdisOAuthStoreTests {
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
@@ -42,7 +42,7 @@ struct ClawdisOAuthStoreTests {
],
])
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -55,7 +55,7 @@ struct ClawdisOAuthStoreTests {
],
])
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -68,7 +68,7 @@ struct ClawdisOAuthStoreTests {
],
])
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
}
@Test
@@ -81,7 +81,7 @@ struct ClawdisOAuthStoreTests {
],
])
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
@@ -1,9 +1,23 @@
import Foundation
import Testing
import SwabbleKit
@testable import Clawdis
@Suite struct VoiceWakeRuntimeTests {
@Test func matchesIsCaseInsensitive() {
let triggers = ["ClAwD", "buddy"]
#expect(VoiceWakeRuntime._testMatches(text: "hey clawd are you there", triggers: triggers))
#expect(!VoiceWakeRuntime._testMatches(text: "nothing to see", triggers: triggers))
}
@Test func matchesIgnoresWhitespace() {
let triggers = [" claude "]
#expect(VoiceWakeRuntime._testMatches(text: "hello claude!", triggers: triggers))
}
@Test func matchesSkipsEmptyTriggers() {
let triggers = [" ", ""]
#expect(!VoiceWakeRuntime._testMatches(text: "hello", triggers: triggers))
}
@Test func trimsAfterTriggerKeepsPostSpeech() {
let triggers = ["claude", "clawd"]
let text = "hey Claude how are you"
@@ -34,46 +48,4 @@ import SwabbleKit
let text = "claude write a note"
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
}
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func gateAcceptsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
@@ -1,47 +1,15 @@
import Foundation
import Testing
import SwabbleKit
@testable import Clawdis
struct VoiceWakeTesterTests {
@Test func matchRespectsGapRequirement() {
let transcript = "hey claude do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
@Test func matchesIsCaseInsensitiveAndSubstring() {
let triggers = ["Claude", "wake word"]
#expect(VoiceWakeTester._testMatches(text: "hey claude are you there", triggers: triggers))
#expect(VoiceWakeTester._testMatches(text: "this has wake word inside", triggers: triggers))
}
@Test func matchReturnsCommandAfterGap() {
let transcript = "hey claude do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.8, 0.1),
("thing", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
@Test func matchesReturnsFalseWhenNoTrigger() {
let triggers = ["claude"]
#expect(!VoiceWakeTester._testMatches(text: "random text", triggers: triggers))
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
@@ -18,9 +18,9 @@ struct ClawdisChatComposer: View {
#endif
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 6) {
if self.showsToolbar {
HStack(spacing: 6) {
HStack(spacing: 8) {
self.thinkingPicker
Spacer()
self.refreshButton
@@ -41,14 +41,11 @@ struct ClawdisChatComposer: View {
.lineLimit(2)
}
}
.padding(self.composerPadding)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdisChatTheme.composerBackground)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6))
.shadow(color: .black.opacity(0.08), radius: 10, y: 4))
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
@@ -137,9 +134,9 @@ struct ClawdisChatComposer: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.composerField))
.overlay {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 6) {
self.editorOverlay
HStack(alignment: .bottom, spacing: 6) {
HStack(alignment: .bottom, spacing: 8) {
if self.showsConnectionPill {
self.connectionPill
}
@@ -147,12 +144,9 @@ struct ClawdisChatComposer: View {
self.sendButton
}
}
.padding(8)
}
.padding(self.editorPadding)
.frame(
minHeight: self.editorMinHeight,
idealHeight: self.editorMinHeight,
maxHeight: self.editorMaxHeight)
.frame(minHeight: 44, idealHeight: 44, maxHeight: 96)
}
private var connectionPill: some View {
@@ -166,8 +160,8 @@ struct ClawdisChatComposer: View {
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(ClawdisChatTheme.subtleCard)
.clipShape(Capsule())
}
@@ -177,23 +171,23 @@ struct ClawdisChatComposer: View {
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Message Clawd…")
.foregroundStyle(.tertiary)
.padding(.horizontal, 4)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 5)
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input) {
self.viewModel.send()
}
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 3)
.frame(minHeight: 32, idealHeight: 32, maxHeight: 72)
.padding(.horizontal, 6)
.padding(.vertical, 4)
#else
TextEditor(text: self.$viewModel.input)
.font(.system(size: 15))
.scrollContentBackground(.hidden)
.padding(.horizontal, 4)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 6)
.focused(self.$isFocused)
#endif
}
@@ -214,7 +208,7 @@ struct ClawdisChatComposer: View {
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.padding(8)
.background(Circle().fill(Color.red))
.disabled(self.viewModel.isAborting)
} else {
@@ -230,7 +224,7 @@ struct ClawdisChatComposer: View {
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.padding(8)
.background(Circle().fill(Color.accentColor))
.disabled(!self.viewModel.canSend)
}
@@ -260,30 +254,6 @@ struct ClawdisChatComposer: View {
self.style == .standard
}
private var composerPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var editorPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var editorMinHeight: CGFloat {
self.style == .onboarding ? 34 : 40
}
private var editorMaxHeight: CGFloat {
self.style == .onboarding ? 60 : 84
}
private var textMinHeight: CGFloat {
self.style == .onboarding ? 24 : 28
}
private var textMaxHeight: CGFloat {
self.style == .onboarding ? 52 : 64
}
#if os(macOS)
private func pickFilesMac() {
let panel = NSOpenPanel()
@@ -353,7 +323,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.textContainerInset = NSSize(width: 2, height: 6)
textView.focusRingType = .none
textView.minSize = .zero
@@ -6,139 +6,12 @@ private enum ChatUIConstants {
static let bubbleCorner: CGFloat = 18
}
private struct ChatBubbleShape: InsettableShape {
enum Tail {
case left
case right
case none
}
let cornerRadius: CGFloat
let tail: Tail
var insetAmount: CGFloat = 0
private let tailWidth: CGFloat = 7
private let tailBaseHeight: CGFloat = 9
func inset(by amount: CGFloat) -> ChatBubbleShape {
var copy = self
copy.insetAmount += amount
return copy
}
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
switch self.tail {
case .left:
return self.leftTailPath(in: rect, radius: self.cornerRadius)
case .right:
return self.rightTailPath(in: rect, radius: self.cornerRadius)
case .none:
return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
}
}
private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX
let bubbleMaxX = rect.maxX - self.tailWidth
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: baseTop)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
path.addCurve(
to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX + self.tailWidth
let bubbleMaxX = rect.maxX
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: baseBottom)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
path.addCurve(
to: baseTop,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
}
@MainActor
struct ChatMessageBubble: View {
let message: ClawdisChatMessage
let style: ClawdisChatView.Style
var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style)
ChatMessageBody(message: self.message, isUser: self.isUser)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
@@ -151,7 +24,6 @@ struct ChatMessageBubble: View {
private struct ChatMessageBody: View {
let message: ClawdisChatMessage
let isUser: Bool
let style: ClawdisChatView.Style
var body: some View {
let text = self.primaryText
@@ -201,11 +73,8 @@ private struct ChatMessageBody: View {
.padding(.horizontal, 12)
.foregroundStyle(textColor)
.background(self.bubbleBackground)
.clipShape(self.bubbleShape)
.overlay(self.bubbleBorder)
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
.padding(.leading, self.tailPaddingLeading)
.padding(.trailing, self.tailPaddingTrailing)
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous))
}
private var primaryText: String {
@@ -217,67 +86,16 @@ private struct ChatMessageBody: View {
self.message.content.filter { ($0.type ?? "text") != "text" }
}
private var bubbleFillColor: Color {
if self.isUser {
return ClawdisChatTheme.userBubble
}
if self.style == .onboarding {
return ClawdisChatTheme.onboardingAssistantBubble
}
return ClawdisChatTheme.assistantBubble
}
private var bubbleBackground: AnyShapeStyle {
AnyShapeStyle(self.bubbleFillColor)
}
private var bubbleBorderColor: Color {
if self.isUser {
return Color.white.opacity(0.12)
}
if self.style == .onboarding {
return ClawdisChatTheme.onboardingAssistantBorder
}
return Color.white.opacity(0.08)
}
private var bubbleBorderWidth: CGFloat {
if self.isUser { return 0.5 }
if self.style == .onboarding { return 0.8 }
return 1
let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble
return AnyShapeStyle(fill)
}
private var bubbleBorder: some View {
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
}
private var bubbleShape: ChatBubbleShape {
ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
}
private var bubbleTail: ChatBubbleShape.Tail {
guard self.style == .onboarding else { return .none }
return self.isUser ? .right : .left
}
private var tailPaddingLeading: CGFloat {
self.style == .onboarding && !self.isUser ? 8 : 0
}
private var tailPaddingTrailing: CGFloat {
self.style == .onboarding && self.isUser ? 8 : 0
}
private var bubbleShadowColor: Color {
self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
}
private var bubbleShadowRadius: CGFloat {
self.style == .onboarding && !self.isUser ? 6 : 0
}
private var bubbleShadowYOffset: CGFloat {
self.style == .onboarding && !self.isUser ? 2 : 0
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
.strokeBorder(
self.isUser ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
lineWidth: self.isUser ? 0.5 : 1)
}
}
@@ -321,7 +139,7 @@ struct ChatTypingIndicatorBubble: View {
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -340,7 +158,7 @@ struct ChatStreamingAssistantBubble: View {
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -374,7 +192,7 @@ struct ChatPendingToolsBubble: View {
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -412,18 +230,12 @@ private struct MarkdownTextView: View {
let textColor: Color
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
if let attributed = try? AttributedString(markdown: self.text) {
Text(attributed)
.font(.system(size: 14))
.foregroundStyle(self.textColor)
} else {
Text(normalized)
Text(self.text)
.font(.system(size: 14))
.foregroundStyle(self.textColor)
}
@@ -5,6 +5,38 @@ public struct ClawdisChatSessionsDefaults: Codable, Sendable {
public let contextTokens: Int?
}
public enum ClawdisChatSessionSyncing: Codable, Hashable, Sendable {
case bool(Bool)
case string(String)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let b = try? container.decode(Bool.self) {
self = .bool(b)
return
}
if let s = try? container.decode(String.self) {
self = .string(s)
return
}
throw DecodingError.typeMismatch(
ClawdisChatSessionSyncing.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected Bool or String"))
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .bool(b):
try container.encode(b)
case let .string(s):
try container.encode(s)
}
}
}
public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
@@ -24,6 +56,7 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
public let model: String?
public let contextTokens: Int?
public let syncing: ClawdisChatSessionSyncing?
}
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
@@ -15,40 +15,6 @@ enum ClawdisChatTheme {
#endif
}
@ViewBuilder
static var background: some View {
#if os(macOS)
ZStack {
LinearGradient(
colors: [
Color(nsColor: .windowBackgroundColor).opacity(0.85),
Color.black.opacity(0.92),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
RadialGradient(
colors: [
Color(nsColor: .systemOrange).opacity(0.18),
.clear,
],
center: .topLeading,
startRadius: 40,
endRadius: 320)
RadialGradient(
colors: [
Color(nsColor: .systemTeal).opacity(0.16),
.clear,
],
center: .topTrailing,
startRadius: 40,
endRadius: 280)
Color.black.opacity(0.12)
}
#else
Color(uiColor: .systemBackground)
#endif
}
static var card: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
@@ -57,11 +23,11 @@ enum ClawdisChatTheme {
#endif
}
static var subtleCard: AnyShapeStyle {
static var subtleCard: Color {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
Color(nsColor: .textBackgroundColor).opacity(0.55)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
Color(uiColor: .secondarySystemBackground).opacity(0.9)
#endif
}
@@ -75,32 +41,12 @@ enum ClawdisChatTheme {
static var assistantBubble: Color {
#if os(macOS)
let base = NSColor.controlBackgroundColor
let blended = base.blended(withFraction: 0.18, of: .white) ?? base
return Color(nsColor: blended).opacity(0.88)
Color(nsColor: .controlBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBubble: Color {
#if os(macOS)
let base = NSColor.controlBackgroundColor
let blended = base.blended(withFraction: 0.22, of: .white) ?? base
return Color(nsColor: blended)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBorder: Color {
#if os(macOS)
Color.white.opacity(0.12)
#else
Color.white.opacity(0.12)
#endif
}
static var userText: Color { .white }
static var assistantText: Color {
@@ -111,24 +57,24 @@ enum ClawdisChatTheme {
#endif
}
static var composerBackground: AnyShapeStyle {
static var composerBackground: Color {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
Color(nsColor: .windowBackgroundColor)
#else
AnyShapeStyle(Color(uiColor: .systemBackground))
Color(uiColor: .systemBackground)
#endif
}
static var composerField: AnyShapeStyle {
static var composerField: Color {
#if os(macOS)
AnyShapeStyle(.thinMaterial)
Color(nsColor: .textBackgroundColor)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
Color(uiColor: .secondarySystemBackground)
#endif
}
static var composerBorder: Color {
Color.white.opacity(0.12)
Color.secondary.opacity(0.2)
}
static var divider: Color {
@@ -10,7 +10,6 @@ public struct ClawdisChatView: View {
@State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
@State private var showSessions = false
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool
private let style: Style
@@ -44,7 +43,7 @@ public struct ClawdisChatView: View {
public var body: some View {
ZStack {
ClawdisChatTheme.background
ClawdisChatTheme.surface
.ignoresSafeArea()
VStack(spacing: Layout.stackSpacing) {
@@ -68,59 +67,44 @@ public struct ClawdisChatView: View {
private var messageList: some View {
ScrollViewReader { proxy in
ZStack {
ScrollView {
LazyVStack(spacing: Layout.messageSpacing) {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
if self.viewModel.pendingRunCount > 0 {
ChatTypingIndicatorBubble(style: self.style)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: 1)
.id(self.scrollerBottomID)
ScrollView {
LazyVStack(spacing: Layout.messageSpacing) {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
.padding(.top, Layout.messageListPaddingTop)
.padding(.bottom, Layout.messageListPaddingBottom)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
}
if self.viewModel.isLoading {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if self.viewModel.pendingRunCount > 0 {
ChatTypingIndicatorBubble(style: self.style)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: 1)
.id(self.scrollerBottomID)
}
}
.onChange(of: self.viewModel.isLoading) { _, isLoading in
guard !isLoading, !self.hasPerformedInitialScroll else { return }
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
self.hasPerformedInitialScroll = true
.padding(.top, Layout.messageListPaddingTop)
.padding(.bottom, Layout.messageListPaddingBottom)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
}
}
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
}
@@ -1,53 +1,6 @@
import Foundation
public enum ClawdisCanvasA2UIAction: Sendable {
public struct AgentMessageContext: Sendable {
public struct Session: Sendable {
public var key: String
public var surfaceId: String
public init(key: String, surfaceId: String) {
self.key = key
self.surfaceId = surfaceId
}
}
public struct Component: Sendable {
public var id: String
public var host: String
public var instanceId: String
public init(id: String, host: String, instanceId: String) {
self.id = id
self.host = host
self.instanceId = instanceId
}
}
public var actionName: String
public var session: Session
public var component: Component
public var contextJSON: String?
public init(actionName: String, session: Session, component: Component, contextJSON: String?) {
self.actionName = actionName
self.session = session
self.component = component
self.contextJSON = contextJSON
}
}
public static func extractActionName(_ userAction: [String: Any]) -> String? {
let keys = ["name", "action"]
for key in keys {
if let raw = userAction[key] as? String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
return nil
}
public static func sanitizeTagValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
@@ -66,16 +19,25 @@ public enum ClawdisCanvasA2UIAction: Sendable {
return str
}
public static func formatAgentMessage(_ context: AgentMessageContext) -> String {
let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
public static func formatAgentMessage(
actionName: String,
sessionKey: String,
surfaceId: String,
sourceComponentId: String,
host: String,
instanceId: String,
contextJSON: String?)
-> String
{
let ctxSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
return [
"CANVAS_A2UI",
"action=\(self.sanitizeTagValue(context.actionName))",
"session=\(self.sanitizeTagValue(context.session.key))",
"surface=\(self.sanitizeTagValue(context.session.surfaceId))",
"component=\(self.sanitizeTagValue(context.component.id))",
"host=\(self.sanitizeTagValue(context.component.host))",
"instance=\(self.sanitizeTagValue(context.component.instanceId))\(ctxSuffix)",
"action=\(self.sanitizeTagValue(actionName))",
"session=\(self.sanitizeTagValue(sessionKey))",
"surface=\(self.sanitizeTagValue(surfaceId))",
"component=\(self.sanitizeTagValue(sourceComponentId))",
"host=\(self.sanitizeTagValue(host))",
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
"default=update_canvas",
].joined(separator: " ")
}
@@ -27,12 +27,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
}
public static func validateV0_8(_ items: [ParsedItem]) throws {
let allowed = Set([
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
])
let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"])
for item in items {
guard let dict = item.message.value as? [String: AnyCodable] else {
throw NSError(domain: "A2UI", code: 1, userInfo: [
@@ -44,8 +39,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
throw NSError(domain: "A2UI", code: 2, userInfo: [
NSLocalizedDescriptionKey: """
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
Canvas currently supports A2UI v0.8 server→client messages
(`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
""",
])
}
@@ -80,9 +80,8 @@
#clawdis-status {
position: fixed;
inset: 0;
display: none;
place-items: start center;
padding-top: calc(20px + env(safe-area-inset-top, 0px));
display: grid;
place-items: center;
pointer-events: none;
z-index: 3;
}
@@ -124,18 +123,6 @@
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get('debugStatus') ?? params.get('debug');
if (!raw) return false;
const normalized = String(raw).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
} catch (_) {
return false;
}
})();
let debugStatusEnabled = debugStatusEnabledByQuery;
function resize() {
const dpr = window.devicePixelRatio || 1;
@@ -149,24 +136,11 @@
window.addEventListener('resize', resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = 'none';
}
window.__clawdis = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
@@ -174,14 +148,11 @@
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
// Auto-hide after 3 seconds.
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
};
})();
@@ -9,20 +9,15 @@ import Testing
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
}
@Test func extractActionNameAcceptsNameOrAction() {
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": " "]) == nil)
}
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: "Get Weather",
session: .init(key: "main", surfaceId: "main"),
component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"),
sessionKey: "main",
surfaceId: "main",
sourceComponentId: "btnWeather",
host: "Peter’s iPad",
instanceId: "ipad16,6",
contextJSON: "{\"city\":\"Vienna\"}")
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
#expect(msg.contains("CANVAS_A2UI "))
#expect(msg.contains("action=Get_Weather"))
+4 -8
View File
@@ -176,11 +176,7 @@ class ClawdisA2UIHost extends LitElement {
height: 100%;
position: relative;
box-sizing: border-box;
padding:
var(--clawdis-a2ui-inset-top, 0px)
var(--clawdis-a2ui-inset-right, 0px)
var(--clawdis-a2ui-inset-bottom, 0px)
var(--clawdis-a2ui-inset-left, 0px);
padding: 12px;
}
#surfaces {
@@ -189,14 +185,14 @@ class ClawdisA2UIHost extends LitElement {
gap: 12px;
height: 100%;
overflow: auto;
padding-bottom: var(--clawdis-a2ui-scroll-pad-bottom, 0px);
padding-bottom: 24px;
}
.status {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--clawdis-a2ui-status-top, 12px);
top: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
@@ -217,7 +213,7 @@ class ClawdisA2UIHost extends LitElement {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--clawdis-a2ui-toast-bottom, 12px);
bottom: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
+1 -2
View File
@@ -7,8 +7,7 @@
},
"files": {
"includes": [
"src/**/*.ts",
"test/**/*.ts"
"src/**/*.ts"
]
},
"linter": {
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more