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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ under the new version heading.

## [Unreleased]

### Changed
- **Runs on macOS 14 (Sonoma) and up.** The minimum was lowered from macOS 26 (Tahoe); the board and
every core feature work throughout. Tahoe-only extras — the Apple Intelligence semantic linter and
screenshot cleanup, plus the Liquid Glass look — stay gated and quietly turn themselves off below
macOS 26 or when Apple Intelligence is unavailable, so nothing shows a broken control or a
missing-glyph icon on older systems.

### Fixed
- **Japanese and other IME input no longer breaks in the canvas editor.** While composing marked text
(Japanese / Chinese / Korean, or dead-key accents), the editor was reformatting the half-composed
text and could steal the Return that confirms a candidate, so characters dropped or the wrong
reading was committed. Composition is now left untouched until it commits.

## [1.2.2] - 2026-06-30

### Added
Expand Down
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,12 @@ be rejected.** Smaller and sharper beats bigger and busier, every time.
swift test # run the test suite
```

A **Swift 6.2+ toolchain (Xcode 26)** on macOS 26 is all you need; the one external
dependency — [Sparkle](https://sparkle-project.org) (auto-update) — is fetched by
SwiftPM and bundled into the `.app` by `build_and_run.sh`. See the
A **Swift 6.2+ toolchain (Xcode 26)** is all you need to build — the Tahoe SDK is
required to compile the gated Apple Intelligence / Liquid Glass paths. The app itself
deploys to **macOS 14 (Sonoma)** and up: those Tahoe-only features are weak-linked and
guarded behind `#available(macOS 26, *)`, so the build runs unchanged on 14. The one
external dependency — [Sparkle](https://sparkle-project.org) (auto-update) — is fetched
by SwiftPM and bundled into the `.app` by `build_and_run.sh`. See the
[README](README.md) for the full set of requirements.

Releases are fully automated — pushing a `v*` tag builds and publishes the app with a
Expand Down
10 changes: 7 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import PackageDescription
let package = Package(
name: "Composer",
platforms: [
.macOS(.v26)
// macOS 14 (Sonoma) is the floor: the board's persistence layer is SwiftData (`@Model` in
// DumpStore), which requires 14. Everything Tahoe-only — Apple Intelligence (FoundationModels,
// weak-linked) and Liquid Glass — is gated behind `#available(macOS 26, *)` and degrades
// gracefully below it, so the core board runs unchanged all the way down to 14.
.macOS(.v14)
],
products: [
.executable(name: "Composer", targets: ["ComposerApp"])
Expand All @@ -23,8 +27,8 @@ let package = Package(
path: "Sources/ComposerApp",
resources: [.process("Resources")],
// Tools-version 6 defaults to the Swift 6 language mode (strict
// concurrency). Stay on Swift 5 mode — the macOS 26 bump is about the
// deployment target, not a concurrency migration.
// concurrency). Stay on Swift 5 mode — the deployment target is a
// compatibility floor, not a concurrency migration.
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ longer, it probably doesn't belong here.

## Requirements

- **macOS 26 (Tahoe)** or later.
- **Apple Intelligence** enabled, for the semantic linter — without it, the
linter quietly turns itself off and everything else works.
- **macOS 14 (Sonoma)** or later. The board and every core feature run here.
- **macOS 26 (Tahoe)** with **Apple Intelligence** enabled unlocks the on-device
extras — the semantic linter and screenshot cleanup — plus the Liquid Glass
look. Below Tahoe (or without Apple Intelligence) those quietly turn themselves
off and everything else works unchanged.

There's nothing to install or configure: download `BonsAI.dmg` from
[Releases](../../releases) and drag **BonsAI** onto **Applications**. Building from source
Expand Down
2 changes: 1 addition & 1 deletion Sources/ComposerApp/Support/EngineLogo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct AgentEngineIcon: View {
if let engine = preferredEngine {
EngineLogo(engine: engine)
} else {
Image(systemName: "apple.intelligence")
Image(systemName: "apple.intelligence", fallback: "sparkles")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
Expand Down
23 changes: 23 additions & 0 deletions Sources/ComposerApp/Support/SFSymbolCompat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import SwiftUI
import AppKit

extension Image {
/// An SF Symbol image with a graceful fallback for OS versions where `preferred` doesn't exist yet.
///
/// SF Symbol names are plain strings, so the compiler can't version-check them the way it gates a
/// real API behind `@available`. A glyph introduced in a newer OS — e.g. `apple.intelligence` and
/// `wand.and.sparkles`, both macOS 15+ — therefore compiles cleanly but renders as an empty
/// missing-glyph box on an older system, which is exactly the kind of silent, misleading
/// degradation the deployment-target floor (macOS 14) must not introduce.
///
/// This probes the *running* OS via AppKit — `NSImage(systemSymbolName:)` returns nil when the
/// symbol isn't present — and falls back to an always-available mark, so the icon degrades to a
/// sensible shape below its introduction version. `fallback` must be a symbol that exists on the
/// deployment floor.
init(systemName preferred: String, fallback: String) {
let resolved = NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil
? preferred
: fallback
self.init(systemName: resolved)
}
}
2 changes: 1 addition & 1 deletion Sources/ComposerApp/Views/AgentDock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ private struct AgentTranscriptView: View {
// One compact line per tool call: the summary never wraps — it truncates with an ellipsis,
// and the full text is available on hover.
HStack(spacing: 7) {
Image(systemName: "wand.and.sparkles").font(.system(size: 10))
Image(systemName: "wand.and.sparkles", fallback: "wand.and.stars").font(.system(size: 10))
Text(message.text).font(.caption).lineLimit(1).truncationMode(.tail)
Spacer(minLength: 0)
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/ComposerApp/Views/ComposerTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ final class ComposerTextView: NSTextView {
// a chip (no selection), one press removes the whole run instead of a single glyph.

override func deleteBackward(_ sender: Any?) {
if deleteChipRun(adjacentTo: selectedRange(), before: true) { return }
// Mid-IME-composition, Delete edits the marked candidate — let the input system own it.
if !hasMarkedText(), deleteChipRun(adjacentTo: selectedRange(), before: true) { return }
super.deleteBackward(sender)
}

override func deleteForward(_ sender: Any?) {
if deleteChipRun(adjacentTo: selectedRange(), before: false) { return }
if !hasMarkedText(), deleteChipRun(adjacentTo: selectedRange(), before: false) { return }
super.deleteForward(sender)
}

Expand Down
17 changes: 16 additions & 1 deletion Sources/ComposerApp/Views/FreeWriteEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,19 @@ extension FreeWriteEditor {
// MARK: Text change → binding + count + mention scan
func textDidChange(_ notification: Notification) {
guard let tv = textView else { return }
// IME composition (Japanese/Chinese/Korean, and dead-key accents): while marked/provisional
// text is on screen, NSTextView posts textDidChange on every keystroke. Reformatting the
// text storage (normalizeBodyRuns) or opening the @-mention menu over that provisional text
// corrupts the input session — it strips the IME's marked-text styling and can steal the
// Return that confirms a candidate — and serializing it would persist text the user may still
// cancel. Do only the cheap on-screen updates and defer the rest to the commit, which posts
// one final textDidChange with no marked text.
if tv.hasMarkedText() {
parent.onCountChange(tv.string.count)
updatePlaceholderVisibility()
reportHeight(force: true)
return
}
normalizeBodyRuns(in: tv)
let serialized = tv.attributedString().composerPlainText
if parent.text != serialized { parent.text = serialized }
Expand Down Expand Up @@ -381,7 +394,9 @@ extension FreeWriteEditor {
guard let tv = textView else { return }
// Caret moved inside the editor (click-away / arrow keys) → dismiss app search.
if parent.appSearch.isOpen { closeAppSearch() }
refreshMentionMenu(tv)
// The selection walks forward as IME marked text grows; don't re-scan for @-mentions over a
// half-composed candidate. The commit's textDidChange re-checks with settled text.
if !tv.hasMarkedText() { refreshMentionMenu(tv) }
selectionWork?.cancel()
let work = DispatchWorkItem { [weak self, weak tv] in
guard let self, let tv else { return }
Expand Down
5 changes: 3 additions & 2 deletions Sources/ComposerApp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,9 @@ private struct SettingsContent: View {
toggle: nil
) {
// The genuine Apple Intelligence mark — brand identity, the same rainbow the agent icon
// uses — not decorative color.
Image(systemName: "apple.intelligence")
// uses — not decorative color. `apple.intelligence` is macOS 15+, so below the app's
// 14 floor it falls back to `sparkles` rather than showing a missing-glyph box.
Image(systemName: "apple.intelligence", fallback: "sparkles")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(AngularGradient(
gradient: Gradient(colors: [.orange, .red, .purple, .blue, .cyan, .orange]),
Expand Down
5 changes: 4 additions & 1 deletion script/build_and_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ MODE="${1:-run}"
APP_NAME="BonsAI"
PRODUCT_NAME="Composer" # SwiftPM executable output; the staged .app binary is renamed to APP_NAME
BUNDLE_ID="dev.jow.BonsAI"
MIN_SYSTEM_VERSION="26.0"
# Keep in lockstep with the deployment target in Package.swift (.macOS(.v14)). macOS 14 (Sonoma)
# is the floor set by SwiftData; Tahoe-only features (Apple Intelligence, Liquid Glass) are gated
# at runtime, so the core board runs down to here.
MIN_SYSTEM_VERSION="14.0"
# Sparkle EdDSA public key — the public half of the pair from Sparkle's `generate_keys`. Safe to commit
# (only the private key is secret; it lives in the SPARKLE_PRIVATE_KEY CI secret). Fill this in after
# running setup; until then the updater stays idle (no SUPublicEDKey is emitted, so no insecure feed).
Expand Down