Share Vault, persistence, SSH/SFTP, forwarding, and SwiftUI components across platforms. Add mobile editors, terminal input, standalone Assistant, Metrics navigation, transfers, and a unified Live Activity with host system icons.
Validation: 35 iOS tests and 100 focused macOS tests pass; formatting and zh-Hans checks pass. The full macOS run reports 14 issues in the documented timing, Keychain, and popover suites. Remaining Assistant tool and CloudKit work is tracked in Memory/MOBILE_ROLLOUT.md.
The API Type picker named three vendors — OpenAI, Anthropic, Google — for
four wire protocols, so `.openAI` carried a Responses toggle to express the
fourth. Each kind also carried a default base URL, which the editor printed
as the Base URL placeholder, so `api.openai.com` was on screen for anyone
who had added a provider and typed nothing, and a provider with a key but no
address quietly resolved to a vendor endpoint nobody had named.
The picker now names the protocol: Automatic, Chat Completions, Responses,
Messages, generateContent. Automatic reads the protocol off the URL's own
suffix and falls back to Chat Completions when nothing matches, which is
what an endpoint that does not name itself is almost always serving.
Base URL and API Path collapse into one field. Splitting them made "does the
base URL include /v1" a question the user had to answer, and it is the
question every client of this shape gets wrong. Paste whatever the gateway's
documentation printed, at whatever depth it printed it; the two captions
underneath say what that produced — Full URL is read from the same value the
request is built from, and the line under it names the body shape, which is
how Automatic explains its own answer and how an explicit pick that
disagrees with the URL stops being a silent surprise. An explicit pick moves
the format, never the address: the person who pasted it keeps it.
Vertex AI goes with the vendor names. It stays in AgentKit, which is an
independent package and does not carry this app's constraints, but nothing
here drives it any more — the editor's project, location and service-account
import are gone, along with five persisted columns and their Keychain path.
`ProviderEndpoint` holds the two things the editor owns, the typed URL and
the picked type, because neither survives a round trip through the composed
result: a URL ending in /messages looks the same whether it was typed or
appended, and Automatic looks the same as picking the format it resolved to.
`AIConfigurationModel.setEndpoint` is the only writer of the derived
`inferenceURL`, `baseURL` and `apiFormat`.
Old rows fold back to the address they used to resolve to rather than to
Automatic, so a provider that worked before the change points at the same
place after it. That fold needs the pre-V12 columns, which is why `kind`,
`baseURL`, `apiPath` and `usesResponsesAPI` are still declared, and it needs
V11 frozen: every VersionedSchema in the plan points at live model types, so
editing a persisted model rewrites what the old version *means* and leaves
an installed store recording a version the plan no longer contains. That is
a refusal to open, not a migration. `TermindSchemaV11Models` is what gives
V11 to V12 two sides — any future attribute change needs the same freeze.
Requires AgentKit 0.5.0, where `ModelAPIFormat` replaces `ModelProviderKind`
and a provider carries `inferenceURL` and `baseURL` instead of a kind, a
base and a path.
Verified with the full test suite (994 tests, including the new
ProviderEndpointTests), swift-format lint --strict,
Scripts/check-zh-localization.sh, npm run build, and a launch of the app
against an existing V11 store.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
macOS supplies menu icons for standard AppKit actions and nothing else, so
every menu mixed system items that had one with Termind commands that did
not — Settings sat between About and Hide with a blank icon column, and the
File menu's Quick Open, Connect and New Host sat beside an iconed Close
Window.
`MenuSymbol` names one glyph per verb, in DesignSystem beside the other
presentation maps. The menu bar, the titlebar's New pull-down, the SwiftUI
context menus and the AppKit `NativeMenuEntry` menus are built by four
different mechanisms; reading their glyphs from one vocabulary is what keeps
Copy the same picture in all four. Values reuse what the app already draws —
`gearshape` from `AppCommandRegistry`, `apple.terminal` and `plus.bubble`
from the command catalog, `curlybraces` and `key.fill` from the Vault pages.
`NativeMenuEntry` gained a `symbol`; `WindowToolbarCreationAction` gained a
required one, since a new verb is drawn both as a SwiftUI item and as a
native entry and would otherwise lose its glyph in one of the two. Icons are
deliberately withheld where a menu's icon column reads as a group: the
trailing items of same-verb runs, and the tag toggles in the Hosts filter,
which are data rather than verbs.
Destructive items lose their hand-painted red, on both the SwiftUI and the
AppKit side. It was ours rather than the system's, so it did not follow
appearance, selection highlight or a disabled item, and it made Delete the
loudest thing in menus it merely happens to end. `ButtonRole.destructive`
says what the item is; AppKit had no equivalent, which left
`NativeMenuEntry.isDestructive` doing nothing, so it is gone.
Submenu children now read on from their parent — "Connect > with SSH",
"Duplicate > in Current Workspace" — and the status bar's Open with menus
join them rather than spelling the placement their own way, which retires
the "Current Workspace" and "New Workspace" catalog keys. Assistant's rule
moves above Archive: Archive and Delete both take a chat off the list, while
Pin and Rename only change how it is listed.
Verified with the full test suite (985 tests), swift-format lint --strict
and Scripts/check-zh-localization.sh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Settings › App › Startup gains an Open at Login switch backed by
SMAppService.mainApp, and an Open Hidden at Login switch that keeps the
login launch from taking the screen. Both are off by default.
The registration is not mirrored into preferences. SMAppService is the
store for whether Termind opens at login: the user can revoke it in
System Settings without telling the app, so the switch re-reads the
system status on appear and on every window activation. requiresApproval
counts as on, with a row saying what is outstanding and a button into
Login Items, rather than a switch that quietly refuses to hold.
Hidden means NSApp.hide(nil) rather than a suppressed window, because
Termind terminates with its last window. LoginLaunch latches the launch
Apple Event during applicationWillFinishLaunching, the only moment
currentAppleEvent can still be read; the hide is armed a second time for
the window SwiftUI orders front just afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split Right held ⌥⌘D, which macOS reserves system-wide for Turn Dock
Hiding On/Off as symbolic hotkey 52. The system consumed the event
before the menu saw it, so the command was unreachable from the
keyboard and pressing it only hid the Dock. It and Split Down move to
⌥⌘→ and ⌥⌘↓, the directions they name. ⌥ still marks the pair that only
rearranges the window, against Duplicate's ⌘D, which opens a
connection.
Next Workspace and Previous Workspace held ⌃⇥ and ⌃⇧⇥. The app never
sets `allowsAutomaticWindowTabbing` to false, so AppKit installs its own
Show Next Tab ⌃⇥ and Show Previous Tab ⌃⇧⇥ into the same Window menu
and shadows both.
Those two were also exact duplicates. `Show Next Workspace` invoked the
same `selectNext()` as `Next Workspace`, and the Previous pair likewise
— four buttons for two actions, where the shadowed pair held the better
titles. The Window menu keeps one pair on ⇧⌘] / ⇧⌘[, which is what
Safari, Terminal and Finder use, under the names Next Workspace and
Previous Workspace. Both already carried finished zh-Hans, so no new
translation was needed and the two orphaned catalog entries are gone.
`AppShortcutKey` gains `.rightArrow` and `.downArrow`. They match on key
code rather than character, as `.tab` and `.escape` do, because that is
what lets `AppShortcutRegistry.routesToApplication` recognise ⌥⌘→ inside
the terminal's `performKeyEquivalent` and decline it — otherwise Ghostty
encodes the arrow and writes an escape sequence to the shell.
`registeredApplicationShortcutsFallThroughTheTerminal` now covers both.
Verification: build succeeds, 975 of 975 tests pass, and
`swift-format lint --strict` and check-zh-localization.sh are clean. The
menu bar itself was not driven: osascript has no assistive access here,
so rendering of ⌥⌘→ and a live keypress inside a focused terminal remain
unchecked by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`swift-format lint --strict --recursive Termind TermindTests
TermindMCPBridge` is in the contribution instructions but had never been
run to completion: 4133 violations across 141 files, most of them
indentation and line breaking the pretty-printer would have settled on
its own.
`swift-format format -i` accounts for all but five. The rest are
lint-only rules it will not rewrite for you:
- `SSHKeyMaterial` declared its libssh2 entry point in the C symbol's
own spelling. `@_silgen_name` already carries the linkage name, so the
Swift side is free to be `libssh2PublicKeyFromPrivateMemory`.
- Four `.forEach { ... }` calls become for-in loops, two in
`PortForwardingController` and two in tests.
Formatting only, and checked as such rather than assumed: all 5409
string literals under `Termind` are byte-identical across the change, so
no localization key moved. Comparing whitespace-stripped token streams
leaves 43 files with any real change, and each is one of the rules
above, a dropped semicolon, a `case a, b` split onto separate lines with
its raw values kept explicit, or a file-scope `private` respelled
`fileprivate` — which is the same access level in that position.
Builds, and the suite stays at 975 of 975.
The suite never finished. A SIGSEGV around 272 of 975 tests killed the
runner, so xcodebuild only ever reported one shard and roughly seventy
percent of the tests were not being executed at all. Underneath it sat
four failures, two of which had been red since the day they were
written, and three tests whose outcome was decided by state they did not
own.
The crash: `RemoteWindowTitleTests` closed a window whose
`isReleasedWhenClosed` still defaults to true, releasing one ARC also
owned. The second release landed on freed memory during an autorelease
pool drain, surfacing as `-[_NSWindowTransformAnimation dealloc]` and
charged to whichever test happened to be running. Closing it also risked
terminating the test host, which is the real application and whose
delegate quits once the last window closes. Ordering the window out
instead settles both.
Never green since written:
- `conversationOwnedImportHonorsAttachmentLimitBeforeStartingTasks`
staged twenty chips that differed only by label, and `attach`
deduplicates on kind and content, so one went in and the limit under
test was never reached.
- `failedCompactionSaveDoesNotChangeTheInMemoryContext` asked for a cut
keeping two tokens after a last turn worth one, so the search walked
past it and `compactionSpan` returned nil. Both turns are now sized in
the unit the cut is chosen in, sharing `filler(tokens:)`.
Stale or ambient:
- `ColorSystemTests` read the domain value `colorTag: .blue` as a
display colour. The scan predates tag colours by a day.
- The Chinese text for a finished run changed in 0b67497; the assertion
did not follow.
- `conversationOwnedFileImportFinishesAndKeepsFilename` yielded rather
than slept, which does not wait for a read off the main actor.
- `shiftedPunctuationMatchesItsUnshiftedShortcutKey` rests on AppKit
re-deriving a character through the current input source, which it
declines for a synthesized event about a third of the time. It now
asserts when AppKit answers and stands down when it does not.
- `FileURLDropTargetTests` reached `hitTest`, which reads
`NSApp.currentEvent` and the machine-wide drag pasteboard for itself.
That decision moves to `participates(at:currentEventType:pasteboard:)`
with both inputs passed in; `hitTest` keeps its behaviour and the
tests keep their own pasteboard.
Verified with eight consecutive full runs at 975 of 975.
The private key and certificate fields kept TextEditor's own opaque
control background, which inside a grouped Form reads as a white card
sitting on the section rather than as part of it. Hiding it puts them on
the same footing as the skill editor's fields.
Fix the prompt alignment that removing the box exposed. The overlay
carried `.padding(.top, 8)`, but TextEditor insets its text container by
its line fragment padding horizontally and by nothing vertically, so the
prompt stood a line below the caret it was standing in for. Measured
offscreen: leading 5, top 0 — the same inset the composer prompt in
ConversationViews uses, now a named constant.
Both sections repeated their own title in a row above the editor next to
the Choose button. With no box left for that row to attach to it read as
a second heading, so the button moves to the trailing edge of the section
header, matching the Targets section in the snippet editor. The
certificate editor gains the accessibility label its removed label row
had been standing in for, and the private key editor gains a prompt,
since an empty unboxed field is otherwise an unmarked gap in the form.
Verification: build succeeds; check-zh-localization.sh passes;
swift-format reports only the findings this file already had on main.
The failing suites in the test run (ChatComposerStateTests,
ColorSystemTests, WindowStateTests) fail identically on a clean HEAD and
are untouched by this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>