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>
On the Assistant page, dragging a file onto the sidebar attached it to
`window.sidebar.assistantDraft` — the Workspace sidebar's new-chat draft,
which is not on screen there, so the file appeared to vanish.
`AssistantPanelView` already declared the intent with `isEnabled:
isFocusedOnWorkspace`, but the flag never reached AppKit.
`FileURLDropNSView` registered its dragged types unconditionally in `init`
and never unregistered, and only `hitTest` consulted `isEnabled` — the
three dragging callbacks did not. A view that refuses the hit test but
stays in the window's registered destination list still wins the drop.
This is the asymmetry `AssistantConversationListContainer.isInteractive`
records for conversation drags; the shared file receiver had only the
hit-test half. Give it both: follow `isEnabled` when registering and
unregistering, and gate `supportedPayload(from:)`, the one chokepoint
`hitTest` and all three callbacks pass through. The callbacks already
treat a nil payload as "not mine", so their bodies are unchanged. The
second half is what holds when `isEnabled` flips during a drag already in
flight, after AppKit has resolved its destinations.
Fixing the shared view also covers two sites with the same flaw: hidden
Workspaces, which all stay mounted at `opacity(0)` over the same frame
carrying a pane drop target, and Keychain accepting drops mid-load.
`FileURLDropTargetTests` becomes `.serialized` — every test there stages
its case on the one drag pasteboard the system shares, and the suite was
already racing with itself before these two tests made it fail.
Verification: macOS build, full test suite (remaining failures match the
clean-tree baseline and are unrelated), swift-format lint adds nothing
new, and Scripts/check-zh-localization.sh passes. The end-to-end Finder
drag is still to be confirmed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scratch_edit is now scratch_replace, and takes a regex mode and a per-entry
count — omit it to require exactly one match as before, 0 for every occurrence,
n for the first n. scratch_copy and scratch_move move a staged file or subtree
without its bytes passing through the transcript, which is what duplicating a
file before a risky change used to cost.
The app-side changes are the ones the rename and the two new tools reach:
ActionKind.copy in both the English and Chinese verb tables, the pull → copy →
replace → push recipe in the built-in config-edit skill, and the same wording in
the system prompt and the plan contract. The legacy tables are deliberately
untouched: legacyToolNames and legacyMetadata describe cards written before
presenter IDs were persisted, and scratch_edit belongs in both. AgentKit keeps
builtin.scratch_edit registered as an alias, so a transcript from before the
rename still draws its diff rather than falling back to generic fields — which
aCardWrittenAsScratchEditStillFindsItsProjection now pins.
AppLanguageTests no longer requires %lld results. That plural moved to AgentKit
with web_search's card, which is what db8796c removed from this catalog; the
test's list had been left behind and was failing.
AGENTS.md said AgentKit was referenced as a local SPM package. It is a versioned
remote dependency, so an uncommitted change in ../AgentKit is invisible to the
app and a change spanning both sides needs a tagged release in between. Said so,
because working the other way wastes a build cycle before the mistake shows.
Verified: AgentKit 0.3.0 builds and passes its 112 tests, and the six suites
this touches pass here. The five failures in the full run — ColorSystemTests,
two ChatComposerStateTests, FileURLDropTargetTests and WindowStateTests — all
reproduce on a clean worktree at HEAD and are unrelated.
The loop, the approval gate, the provider adapters, the MCP client, and the
tools with no opinion about transports now live in a package and are consumed
from it. Termind keeps everything that reaches a machine or a person: the SSH
and SFTP tools, the connection pool, the Vault resolver, scratch_pull and
scratch_push, the SwiftData store, and every view.
They meet at four seams. TermindAgentTools registers our tools alongside the
built-in groups; AgentToolServices carries the SSH credential prompt to them
without that prompt appearing in a runtime protocol; TermindAgentPolicy owns the
system prompt, the plan contract, and the ssh_exec plan-mode rule the runtime
cannot write for us; TermindAgentPresentation extends the card vocabulary with
our own semantics.
ChatPermissionMode becomes AgentPermissionMode and moves out of the conversation
model — who authorizes an action is a property of the run, not of a view.
AgentRunRequest takes the session context as rendered text rather than our
SessionContext, because the runtime does nothing with it but insert it in front
of the turn it describes.
Termind's system prompt and plan contract are byte-identical to what shipped.
Every provider cache is a prefix cache, so editing a word there would miss the
cache on every replayed conversation at once.