Rebuild the AI Providers panel: a list with pages, a say over models, and per-tool launch settings (#1206)

* Show AI providers as a list beside the selected provider's detail

The Providers panel was one long form: every installed tool and API
connection stacked as rows, with status, quota and actions squeezed into
each. It now follows Mail's Accounts: the on-device model, the installed
commands and the API connections sit in a list, each with a one-line
state and, for installed tools, its switch. The selected provider shows
its status, account, usage, command path and models beside it, with a
filter once a list runs long. API connections are added from the list's
plus menu, removed with its minus, and still edited in the existing
connection editor.

* Choose which of a provider's models the model picker lists

A route like OpenCode offers hundreds of models, and every one of them
landed in the chat's model picker. Each provider's model list is now a
set of checkboxes, with Show All and Hide All above it. A provider
nobody has trimmed keeps listing everything, including models it adds
later; once trimmed, it lists only what was ticked. The default model
always stays listed, since the picker has to show what it holds, and a
removed connection forgets its choices. The choice is per Mac and stays
out of settings backups.

The provider switch moves from the list into the selected provider's
header, so it no longer sits on the list's blue selection, and model rows
drop their effort levels, which the chat's own picker already shows.

* Give Grok, Cursor and OpenCode their own marks

Grok borrowed X's mark, Cursor a pointer symbol and OpenCode a terminal
symbol. Each now has its own, in the Providers list and in the chat's
model picker. OpenCode's inner block is drawn with `opacity`: the asset
compiler drops `fill-opacity` without a warning, which filled the block
solid and turned the mark into a window with a title bar.

* Name ChatGPT's newer plans

Pro now comes in two sizes, which the account reports as "pro" and
"prolite" and ChatGPT sells as Pro 20x and Pro 5x. Business, Enterprise
and Edu each gained plan types that read as "Account".

* List a provider's models in a table

A Form realizes every row it holds, and OpenCode offers over four hundred
models, so its list stuttered while scrolling. The checkboxes now sit in
an NSTableView inside one Form row, which builds only the rows on screen.

* Switch the on-device model or an API connection off in place

Only an installed tool could be switched off; an API connection had to be
removed, key and all, to leave the model picker. The on-device model and
every API connection now have the same switch. A route that is off stays
configured but leaves every picker, the default model moves to a route
still on, and asking for it anyway is refused with a message. Removing a
connection forgets that it was off, and the choice stays out of backups.

* Launch an installed tool from a set path, with set variables

Tinycast finds each installed tool by asking a login shell, which is
right until a Mac has two copies or a shell it cannot read. A tool can
now carry a command path that replaces the lookup, and variables set for
that tool each time it starts. Nothing sets them yet; the next commit
adds the fields.

A path with nothing to run fails the check and the turn, never falling
back to the lookup, since that would hide the mistake the path was set to
fix. The reader's variables lie over the app's own, but never over one
Tinycast sets to keep a tool inside the chat. Names are stored in
settings and values in the login Keychain; neither travels in a backup,
which must not decide what this Mac launches. Only the tool that was
edited is checked again.

* Add an Advanced section to an installed tool's detail

The command path has a field and a Choose… button whose open panel shows
hidden folders, since `~/.local/bin` is one. Variables are rows of a name
and a hidden value, added and removed one at a time. A name Tinycast sets
itself says its value is not used, and one that is not a variable name
says why. Each field saves as it is left.

* Name a Claude model by its display name when the description has no version

Claude Code used to lead a model's description with its version, "Opus 5.5
· Best for everyday…", and Tinycast named the model from that. A newer
CLI puts the version in the display name and leaves only the blurb in the
description, so every model in the picker read "Claude Best for everyday,
complex tasks". The version is now taken from the description only when
it is there, and from the display name otherwise.

* End an edit in the Providers panel with a click outside the field

A field holds the keyboard until something else takes it, and blank form
space takes nothing. The panel now releases it the way every other pane
does, which is also what saves a field that is saved as it is left.

* Show Claude's account and Codex's command, and keep Advanced while a tool is off

Claude's detail now names the account it is signed in with and its plan,
the address hidden until clicked as Codex's is. Both come from the answer
that already lists Claude's models, so nothing more is asked of the CLI.
Codex's detail names the command it ran, which the other tools already
did, and the empty command path field shows it too.

A tool that is switched off keeps its Advanced section, so a wrong path
can be fixed before it is switched on.

* Give a provider's detail its own pages

A provider's status, its models and its advanced settings shared one
scrolling form, so OpenCode's four hundred models buried everything under
them. The detail now has Overview, Models and Advanced pages behind a
segmented control, as Mail's Accounts has. An API connection has the first
two, and the on-device model, with one page, shows no control. The chosen
page is kept from one provider to the next.

* Keep the accent colour on the providers panel's page control

An editor panel wraps its content in Liquid Glass, and a segmented control
drawn on glass goes grey and bordered. The divider beside the selected
page is always hidden, so in grey the control read as missing one. The
providers panel now draws its glass behind the content, which leaves the
control its accent colour; every other editor panel is unchanged.

* Keep the MCP editor's connection control from resizing

The system segmented control opens tight around its labels and widens the
first time its selection changes, so the HTTP and Command control grew
under the pointer and stayed that wide. The editor now uses the AppKit
control with each segment pinned to its label plus the inset the control
settles at, and reports its own size from those widths, so it opens at the
size it would have ended up.

* Say Automatic in an empty command path field

The empty field showed the path the lookup had found, which read as a
path already set, and repeated the Command row on Overview. It now says
Automatic; the found path is on Overview alone.

* Credit the three new marks, and take two from the sources already credited

Cursor's mark now comes from Simple Icons and Grok's from Lobe Icons, the
two sources NOTICE already names; both look as they did. OpenCode's is
drawn after the one in its own repository, since the libraries carry the
frame without the block inside it, and its licence travels with it.

* Document the Providers panel, its pages and a tool's overrides

The feature doc gains the rules that are now invariants: a route that is
off is off everywhere, a picker lists what was ticked, a set command path
wins or fails, and a reader's variable never replaces one Tinycast sets.
It describes the panel's list and pages, and says why three more AI keys
stay out of backups and settings.json. The UI doc records the panel's
sizes and the two things learned about controls in an editor panel. The
website explains the pages, the switch, and the Advanced page.

* Keep a tool's variables when the Keychain cannot be read

A failed read loaded the variables' names with blank values, and leaving
any field in Advanced then saved the blanks over the stored values. The
read now throws, the page says it could not read them and saves only the
command path, and a save whose check read fails is refused.
This commit is contained in:
notsoshu
2026-09-29 00:57:58 +06:00
committed by GitHub
parent 3bd703837b
commit 850a519d42
44 changed files with 2428 additions and 562 deletions
+37 -6
View File
@@ -5,16 +5,17 @@ also redistributes the third-party material recorded below, under the terms stat
## Brand marks — `Tinycast/Assets.xcassets/AIBrand*.imageset`
Thirteen monochrome template SVGs, ~300 B–2 KB each, drawn beside a model's name in the model
picker and the chat header so a route is recognisable at a glance.
Sixteen monochrome template SVGs, ~300 B–2 KB each, drawn beside a model's name in the model
picker and the chat header, and beside a provider in Settings, so a route is recognisable at a
glance.
Every mark is the trademark of the company it identifies. Tinycast uses them only to name that
company's own models inside its own UI. No affiliation, sponsorship or endorsement is implied, and
none of these companies has reviewed or approved Tinycast.
### Simple Icons — twelve marks
### Simple Icons — thirteen marks
`claude`, `deepseek`, `googlegemini`, `kimi`, `meta`, `minimax`, `mistralai`, `openai`,
`claude`, `cursor`, `deepseek`, `googlegemini`, `kimi`, `meta`, `minimax`, `mistralai`, `openai`,
`openrouter`, `perplexity`, `qwen` and `x`, from <https://github.com/simple-icons/simple-icons>.
The Simple Icons **project** is released under CC0 1.0 Universal. Its own disclaimer is explicit
@@ -24,9 +25,9 @@ imply the icon is unlicensed. Anyone redistributing Tinycast, or reusing these f
should read the disclaimer and satisfy themselves about the brands involved:
<https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md>.
### Lobe Icons — one mark
### Lobe Icons — two marks
`zai`, from <https://github.com/lobehub/lobe-icons>, which is MIT licensed. Its licence requires
`grok` and `zai`, from <https://github.com/lobehub/lobe-icons>, which is MIT licensed. Its licence requires
this notice to travel with the work:
```
@@ -53,6 +54,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### OpenCode — one mark
`opencode`, drawn after the mark in <https://github.com/sst/opencode>, which is MIT licensed: the
frame and the dimmer block inside it, as its own favicon has them. Its licence requires this notice
to travel with the work:
```
MIT License
Copyright (c) 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Rooms — window rooms, their layouts and the gliding layout preview
Window Management's rooms adapt code from **Rooms** by Sara Gordić,
+2
View File
@@ -194,6 +194,8 @@ const prompt = promptFile >= 0 && args[promptFile + 1]
record(command + "-prompt.log", prompt);
record(command + "-environment.log", process.env.OPENCODE_CONFIG_CONTENT ?? "");
record(command + "-grok-environment.log", process.env.GROK_DISABLE_AUTOUPDATER ?? "");
record(command + "-reader-environment.log", process.env.TC_READER_PROBE ?? "");
record(command + "-path.log", process.argv[1]);
const modelIndex = args.indexOf("--model");
const model = modelIndex >= 0 ? args[modelIndex + 1] : "";
+199
View File
@@ -94,6 +94,11 @@ struct AIProviderTests {
installedCLIStreamsDecode()
settingsPersistAndRepairSelections()
installedModelLoadingPreferencePersists()
shownModelsFilterThePicker()
switchedOffRoutesLeaveTheDefault()
installedOverridesPersistAndResolve()
aFailedKeychainReadIsNeverSavedOver()
aLaunchInheritsTheReadersVariablesNotTinycastsOwn()
subscriptionSelectionsReconcile()
onDeviceSelectionsRoundTripAndLead()
conversationSettingsPersistAndDecide()
@@ -964,6 +969,193 @@ struct AIProviderTests {
"a provider toggle survives a restart")
}
static func installedOverridesPersistAndResolve() {
let suite = "AIProviderTests.installedOverrides"
let defaults = isolatedDefaults(suite)
defer { discardSuite(suite, defaults) }
let kept = KeptVariables()
let environmentStore = InstalledAIEnvironmentStore(
values: { kept.values[$0] ?? [:] }, save: { kept.values[$1] = $0 })
let store = AISettingsStore(defaults: defaults, environmentStore: environmentStore)
expect(store.launch(for: .codex) == InstalledAILaunch(), "a tool left alone has no override")
store.setCommandPath(" ~/bin/codex \n", for: .codex)
expect(store.override(for: .codex).commandPath == "~/bin/codex", "a path is kept trimmed")
expect(store.launchRevisions[.codex] == 1, "and setting it counts as a change to the launch")
store.setCommandPath("~/bin/codex", for: .codex)
expect(store.launchRevisions[.codex] == 1, "the same path again is no change")
try? store.setEnvironment(
[
InstalledAIVariable(name: "HTTPS_PROXY", value: "http://127.0.0.1:9"),
InstalledAIVariable(name: "HTTPS_PROXY", value: "a second one"),
InstalledAIVariable(name: "not a name", value: "x"),
InstalledAIVariable(name: "CODEX_HOME", value: "/tmp/home")
], for: .codex)
expect(
store.override(for: .codex).environmentNames == ["HTTPS_PROXY", "CODEX_HOME"],
"variables keep their order, without a repeated or an unusable name")
expect(
defaults.data(forKey: AppSettingsKey.aiInstalledOverrides.rawValue)
.map { String(decoding: $0, as: UTF8.self) }?.contains("127.0.0.1") == false,
"and no value is written to settings")
let reopened = AISettingsStore(defaults: defaults, environmentStore: environmentStore)
expect(
reopened.launch(for: .codex)
== InstalledAILaunch(
commandPath: "~/bin/codex",
environment: ["HTTPS_PROXY": "http://127.0.0.1:9", "CODEX_HOME": "/tmp/home"]),
"a launch after a restart has the path and the values")
expect(
(try? reopened.environment(for: .codex))?.map(\.name) == ["HTTPS_PROXY", "CODEX_HOME"],
"and the editor lists them in the order they were entered")
reopened.setCommandPath("", for: .codex)
try? reopened.setEnvironment([], for: .codex)
expect(
defaults.object(forKey: AppSettingsKey.aiInstalledOverrides.rawValue) == nil
&& kept.values[.codex]?.isEmpty == true,
"clearing both leaves nothing stored")
let home = NSHomeDirectory()
let present: (String) -> Bool = { $0 == home + "/bin/codex" }
expect(InstalledAILaunch().command(isExecutable: present) == .automatic, "no path looks up")
expect(
InstalledAILaunch(commandPath: "~/bin/codex").command(isExecutable: present)
== .executable(URL(fileURLWithPath: home + "/bin/codex")),
"a path under the home folder may be written with a tilde")
expect(
InstalledAILaunch(commandPath: "/opt/none/codex").command(isExecutable: present)
== .missing(path: "/opt/none/codex"),
"a path with nothing to run is reported, never replaced by a lookup")
expect(
InstalledAILaunch(commandPath: "codex").command(isExecutable: { _ in true })
== .missing(path: "codex"),
"a bare name is not a path")
}
static func aFailedKeychainReadIsNeverSavedOver() {
let suite = "AIProviderTests.failedKeychainRead"
let defaults = isolatedDefaults(suite)
defer { discardSuite(suite, defaults) }
let kept = KeptVariables()
kept.values[.claude] = ["HTTPS_PROXY": "http://127.0.0.1:9"]
let working = InstalledAIEnvironmentStore(
values: { kept.values[$0] ?? [:] }, save: { kept.values[$1] = $0 })
try? AISettingsStore(defaults: defaults, environmentStore: working).setEnvironment(
[InstalledAIVariable(name: "HTTPS_PROXY", value: "http://127.0.0.1:9")], for: .claude)
let locked = InstalledAIEnvironmentStore(
values: { _ in throw KeychainUnreadable() }, save: { kept.values[$1] = $0 })
let store = AISettingsStore(defaults: defaults, environmentStore: locked)
expect(
(try? store.environment(for: .claude)) == nil,
"a read that fails is an error, never a list of names without values")
expect(
(try? store.setEnvironment(
[InstalledAIVariable(name: "HTTPS_PROXY", value: "")], for: .claude)) == nil,
"and saving over values that could not be read fails")
expect(
kept.values[.claude] == ["HTTPS_PROXY": "http://127.0.0.1:9"],
"so the stored value survives")
expect(
store.launch(for: .claude).environment.isEmpty,
"a launch that cannot read the values starts without them")
}
static func aLaunchInheritsTheReadersVariablesNotTinycastsOwn() {
let launch = InstalledAILaunch(
environment: [
"PATH": "/reader/bin", "HTTPS_PROXY": "proxy", "NO_COLOR": "0",
"OPENCODE_CONFIG_CONTENT": "{}", "TC_MCP_0_0": "stolen", "1BAD": "x"
])
let inherited = launch.inherited(
for: .openCode, base: ["PATH": "/usr/bin", "HOME": "/Users/reader", "NO_COLOR": "1"])
expect(
inherited["PATH"] == "/reader/bin" && inherited["HTTPS_PROXY"] == "proxy"
&& inherited["HOME"] == "/Users/reader",
"the reader's variables lie over the app's own")
expect(
inherited["OPENCODE_CONFIG_CONTENT"] == nil && inherited["TC_MCP_0_0"] == nil
&& inherited["NO_COLOR"] == "1" && inherited["1BAD"] == nil,
"but never one Tinycast sets itself, nor one that is not a variable name")
expect(
launch.inherited(for: .claude, base: [:])["OPENCODE_CONFIG_CONTENT"] == "{}",
"a name only another tool reserves is an ordinary variable here")
expect(
InstalledAIKind.allCases.allSatisfy { kind in
kind.managedEnvironment.keys.allSatisfy(kind.isManagedVariable)
},
"every variable a tool is launched with is one the reader cannot replace")
}
static func switchedOffRoutesLeaveTheDefault() {
let suite = "AIProviderTests.disabledRoutes"
let defaults = isolatedDefaults(suite)
defer { discardSuite(suite, defaults) }
let store = AISettingsStore(defaults: defaults)
let first = AIConnection(provider: .openRouter, models: ["first-model"])
let second = AIConnection(provider: .gemini, models: ["second-model"])
store.save(first)
store.save(second)
expect(store.defaultModel?.source == .api(first.id), "the first connection is the default")
store.setRoute(.api(first.id), enabled: false)
expect(
store.defaultModel?.source == .api(second.id),
"switching the default's connection off moves the default to one still on")
let reopened = AISettingsStore(defaults: defaults)
expect(
!reopened.isRouteEnabled(.api(first.id)) && reopened.isRouteEnabled(.api(second.id)),
"a switched-off connection stays off after a restart")
reopened.setRoute(.appleIntelligence, enabled: false)
expect(
!reopened.isRouteEnabled(.appleIntelligence),
"the on-device model can be switched off like a connection")
reopened.setRoute(.api(first.id), enabled: true)
reopened.removeConnection(id: second.id)
expect(
reopened.defaultModel?.source == .api(first.id),
"with the other one gone, the connection switched back on becomes the default")
reopened.removeConnection(id: first.id)
expect(
!reopened.disabledRoutes.contains(AIModelSource.api(first.id).storageKey),
"removing a connection forgets that it was switched off")
}
static func shownModelsFilterThePicker() {
let suite = "AIProviderTests.shownModels"
let defaults = isolatedDefaults(suite)
defer { discardSuite(suite, defaults) }
let store = AISettingsStore(defaults: defaults)
let available = ["a", "b", "c"]
expect(
available.allSatisfy { store.isModelShown($0, in: .openCode) },
"a route nobody has trimmed lists every model")
store.setModel("b", shown: false, in: .openCode, available: available)
let reopened = AISettingsStore(defaults: defaults)
expect(
reopened.isModelShown("a", in: .openCode) && !reopened.isModelShown("b", in: .openCode),
"an unticked model stays out of the picker after a restart")
expect(
!reopened.isModelShown("d", in: .openCode),
"a trimmed route does not list a model it adds later")
expect(
reopened.isModelShown("b", in: .claude),
"trimming one route leaves the others listing everything")
reopened.setModel("b", shown: true, in: .openCode, available: available)
expect(
reopened.shownModels[AIModelSource.openCode.storageKey] == nil
&& reopened.isModelShown("d", in: .openCode),
"ticking every model back returns the route to listing all, later ones included")
reopened.hideAllModels(in: .openCode)
expect(
available.allSatisfy { !reopened.isModelShown($0, in: .openCode) },
"Hide All leaves nothing but the default listed")
let connection = AIConnection(provider: .openRouter, models: ["x", "y"])
reopened.save(connection)
reopened.setModel("y", shown: false, in: .api(connection.id), available: connection.models)
reopened.removeConnection(id: connection.id)
expect(
reopened.shownModels[AIModelSource.api(connection.id).storageKey] == nil,
"removing a connection forgets which of its models were shown")
}
static func subscriptionSelectionsReconcile() {
let suite = "AIProviderTests.subscription"
let defaults = isolatedDefaults(suite)
@@ -1531,3 +1723,10 @@ private func isolatedDefaults(_ name: String) -> UserDefaults {
defaults.removePersistentDomain(forName: name)
return defaults
}
/// Stands in for the Keychain: what a harness saved, read back the way a launch would.
private final class KeptVariables: @unchecked Sendable {
var values: [InstalledAIKind: [String: String]] = [:]
}
private struct KeychainUnreadable: Error {}
+91 -2
View File
@@ -127,6 +127,9 @@ struct InstalledAITests {
await concurrentCallsAreAskedOneAtATime(fixture)
await aCrashedTurnsFilesAreRemovedAtLaunch(fixture)
await aManagedMCPPolicyLeavesBothFlagsOff(fixture)
await aReadersVariablesReachTheToolButNeverItsIsolation(fixture)
await aSetCommandPathIsWhatRuns(fixture)
await aCommandPathWithNothingThereFailsTheTurn(fixture)
await aChildThatNeverReadsCannotKillTheApp()
await aFishLoginShellFindsWhatConfigFishAdds(fixture)
await anRcFileThatPrintsStillAnswers(fixture)
@@ -277,6 +280,34 @@ struct InstalledAITests {
expect(
models.first?.efforts.map(\.id) == ["low", "high"],
"a Claude model carries only the efforts the CLI says it supports")
let newer = """
{"type":"control_response","response":{"subtype":"success","request_id":"x","response":\
{"models":[{"value":"opus","displayName":"Opus 5.5","resolvedModel":"claude-opus-5-5",\
"description":"Best for everyday, complex tasks"},{"value":"claude-opus-4-8",\
"displayName":"Opus 4.8","resolvedModel":"claude-opus-4-8",\
"description":"Best for everyday, complex tasks"}]}}}
"""
let signedIn = """
{"type":"control_response","response":{"subtype":"success","request_id":"x","response":\
{"models":[],"account":{"email":"reader@example.com","subscriptionType":"max",\
"apiProvider":"firstParty"}}}}
"""
expect(
InstalledAIModel.claudeAccount(signedIn)
== InstalledAIAccount(email: "reader@example.com", plan: "max")
&& InstalledAIModel.claudeAccount(signedIn)?.planTitle == "Max",
"the answer that lists Claude's models also names the account and its plan")
expect(
InstalledAIModel.claudeAccount(newer) == nil,
"and an answer that names no account shows none")
expect(
InstalledAIAccount(email: nil, plan: "Claude Max").planTitle == "Max"
&& InstalledAIAccount(email: nil, plan: "team").planTitle == "Team"
&& InstalledAIAccount(email: nil, plan: " ").planTitle == nil,
"a plan reads the same whether or not the CLI prefixed it with the tool's name")
expect(
InstalledAIModel.claudeCatalog(newer).map(\.name) == ["Claude Opus 5.5", "Claude Opus 4.8"],
"a CLI that describes a model without its version is named by its display name")
let frame = InstalledAIStreamDecoder.decode(
Data(
#"{"type":"stream_event","event":{"delta":{"type":"thinking_delta","thinking":"Plan"}}}"#.utf8
@@ -649,6 +680,56 @@ struct InstalledAITests {
"while one on Unlimited with no server to run is still a single request")
}
private static func aReadersVariablesReachTheToolButNeverItsIsolation(
_ fixture: Fixture
) async {
let launch = InstalledAILaunch(
environment: [
"TC_READER_PROBE": "from-the-reader", "OPENCODE_CONFIG_CONTENT": "{}",
"not a name": "dropped"
])
let events = await fixture.events(
kind: .openCode, model: "provider/model", effort: nil, launch: launch)
expect(events.last == .finished, "a tool launched with the reader's variables still runs")
expect(
fixture.lastLine("opencode-reader-environment.log") == "from-the-reader",
"a variable the reader set reaches the tool")
expect(
fixture.lastLine("opencode-environment.log").contains("\"permission\":\"deny\""),
"one Tinycast sets to keep the tool inside the chat keeps Tinycast's value")
}
private static func aSetCommandPathIsWhatRuns(_ fixture: Fixture) async {
guard let stub = fixture.executables[.openCode] else { return }
let elsewhere = fixture.root.appending(path: "elsewhere", directoryHint: .isDirectory)
let copy = elsewhere.appending(path: "opencode")
do {
try FileManager.default.createDirectory(
at: elsewhere, withIntermediateDirectories: true)
try FileManager.default.copyItem(at: stub, to: copy)
} catch {
expect(false, "the second copy of the command is written")
return
}
let events = await fixture.events(
kind: .openCode, model: "provider/model", effort: nil,
launch: InstalledAILaunch(commandPath: copy.path))
expect(events.last == .finished, "a turn runs from a command path the reader set")
expect(
fixture.lastLine("opencode-path.log").hasSuffix("elsewhere/opencode"),
"and it is that command, not the one a lookup finds first")
}
private static func aCommandPathWithNothingThereFailsTheTurn(_ fixture: Fixture) async {
let missing = fixture.root.appending(path: "nowhere/claude").path
let error = await fixture.streamError(
kind: .claude, model: "sonnet", effort: nil,
launch: InstalledAILaunch(commandPath: missing))
expect(
error?.contains("Nothing can be run at") == true,
"a command path with nothing to run fails the turn instead of running another copy")
}
/// An admin's policy makes the CLI reject both flags, so the route passes neither.
private static func aManagedMCPPolicyLeavesBothFlagsOff(_ fixture: Fixture) async {
let policy = fixture.root.appending(path: "managed-mcp.json")
@@ -749,12 +830,14 @@ private final class Fixture {
func events(
kind: InstalledAIKind, model: String, effort: String?,
launch: InstalledAILaunch = InstalledAILaunch(),
toolServers: AIToolServerSession? = nil
) async -> [AIStreamEvent] {
guard let executable = executables[kind] else { return [] }
let provider = InstalledCLIProvider(
kind: kind, executable: kind == .openCode ? nil : executable,
model: model, effort: effort, workspace: workspace, toolServers: toolServers)
model: model, effort: effort, workspace: workspace, launch: launch,
toolServers: toolServers)
do {
var events: [AIStreamEvent] = []
for try await event in provider.stream(request) { events.append(event) }
@@ -767,12 +850,14 @@ private final class Fixture {
func streamError(
kind: InstalledAIKind, model: String, effort: String?,
launch: InstalledAILaunch = InstalledAILaunch(),
toolServers: AIToolServerSession? = nil
) async -> String? {
guard let executable = executables[kind] else { return nil }
let provider = InstalledCLIProvider(
kind: kind, executable: kind == .openCode ? nil : executable,
model: model, effort: effort, workspace: workspace, toolServers: toolServers)
model: model, effort: effort, workspace: workspace, launch: launch,
toolServers: toolServers)
do {
for try await _ in provider.stream(request) {}
return nil
@@ -850,6 +935,10 @@ private final class Fixture {
return isSatisfied()
}
func lastLine(_ name: String) -> String {
read(name).split(separator: "\n").last.map(String.init) ?? ""
}
func read(_ name: String) -> String {
(try? String(contentsOf: root.appending(path: name), encoding: .utf8)) ?? ""
}
+28
View File
@@ -33,6 +33,7 @@
07059C7E22C28A9E1E99F1DE /* NoteHeadingMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31569E535614FE2F82880C09 /* NoteHeadingMenuView.swift */; };
07726F26DC68689F8984F467 /* Scrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D0784E49BC5899A585F285 /* Scrypt.swift */; };
0820C7539E3328338C3C8FBD /* BarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395BACF833C810961A248F0 /* BarButton.swift */; };
08402EEA92AECAF10CBAF2EA /* AIProviderAdvancedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4DB2A5435AD37E95789304B /* AIProviderAdvancedSection.swift */; };
0976BBB954D48DE55C6B4263 /* MenuSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8A186797267935E09816C9 /* MenuSearchScreen.swift */; };
0A086C5452FD9FCE68AF4E46 /* InputSourceSwitcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCD070CDE64691E5F6ED28D /* InputSourceSwitcher.swift */; };
0A238E823704B904EFFB4F22 /* BundleLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3085C57A94FFF35FF74EC86 /* BundleLocalization.swift */; };
@@ -48,6 +49,7 @@
0D509E0E71CBCBB72EF0BDFE /* CustomWindowSizeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE31A7F41EDA33E7E574F62D /* CustomWindowSizeStore.swift */; };
0D8A65A0A7A2188F56D576FF /* Quarantine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4984C964DC144E213DF4F63 /* Quarantine.swift */; };
0DD808F80666846F4E74BAD8 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23591B301A183579098AA019 /* OnboardingView.swift */; };
0DD8A17796AE752342B7C26C /* AIProvidersPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 574A658F2503AA5BAED39903 /* AIProvidersPanel.swift */; };
0E2C10EDA6444D3ACF8F6F71 /* Fallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0437C802C1F3E33E8532A7E3 /* Fallback.swift */; };
0F667981DF990EF41626F052 /* CalcDateFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955FBE410967A64A05A2DA3D /* CalcDateFormatters.swift */; };
0F6F56C49B3D2CBA233F12F3 /* CodexAppServerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7355ACD6D1BE7BC7FCA26BD2 /* CodexAppServerClient.swift */; };
@@ -290,6 +292,7 @@
606809BC14D6613B847036D0 /* ExtensionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */; };
607D38D86E7C4763DD28C8D0 /* ReleaseNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31757FAE5F3E9E06D9B77DCA /* ReleaseNotes.swift */; };
60A1949A691168B535B562C8 /* MeetingCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E475F373557FD3524086CE19 /* MeetingCalendar.swift */; };
60E4AA375F0424AAD55799FD /* SteadySegmentedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBE8149D018E44AD3E13F642 /* SteadySegmentedPicker.swift */; };
613827876C48EF88F2162C10 /* NotificationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FB75182A60F65BA1FB9BA8 /* NotificationToken.swift */; };
614DAAC9331F9C1DE4D76F0D /* EmojiScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B6CA2ED2DDD9A89C7EE874 /* EmojiScreen.swift */; };
61D9B055A8A27DBAFA5CA2EB /* SettingsFileIssue.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB68DFBD782D32B5633EC4A7 /* SettingsFileIssue.swift */; };
@@ -328,6 +331,7 @@
6C915A60C3C2F2682645C32B /* UpdateInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9188FA8A06EAE3E55FFCBC70 /* UpdateInstaller.swift */; };
6CDA82FD63F87D07E30E3F82 /* Zlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = F097C9983DF27A99694C8BC5 /* Zlib.swift */; };
6D241F01F4BB98AAE1720330 /* InstalledAI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76D9592F0809DEC9993684A /* InstalledAI.swift */; };
6D61C5110CA0B20BADA72002 /* InstalledAILaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F7F40DAD48109685138513E /* InstalledAILaunch.swift */; };
6F076223EA47A68C1490F674 /* FolderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236934966557373B61455B3C /* FolderPicker.swift */; };
6F644313A467DB269FDBAED6 /* FilePreviewThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC0CEDE9677C9C2485586B3 /* FilePreviewThumbnail.swift */; };
6FC9F74D9119AC25D178D748 /* ExtensionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 810AA3AD31C7E11932E34F28 /* ExtensionRegistry.swift */; };
@@ -449,6 +453,7 @@
9DB09F96E6F8C226BD9AB1C9 /* SymbolCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387BE675F580496656E72559 /* SymbolCatalog.swift */; };
9DB4319C835DCF8C0723356D /* MenuBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2902B904C7BD2FC23856EDB9 /* MenuBarItem.swift */; };
9DB73F12214137B60AE00828 /* RaycastScriptImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CF6A57ECFDA17248393EA8 /* RaycastScriptImport.swift */; };
9DFBDD9863EB307D4F102524 /* AIModelChecklist.swift in Sources */ = {isa = PBXBuildFile; fileRef = A022043AE60501848E0C5026 /* AIModelChecklist.swift */; };
9DFE7A312D079C7125DB43DE /* AppleShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072DA32B7365FAAB7C38F25C /* AppleShortcutsSettingsView.swift */; };
9E4497ECAD183E5C2B49475B /* MeetingDay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0599199132DAFBE12C59FBD /* MeetingDay.swift */; };
9E514EA8EB0408653AC3D1D9 /* BundleSignature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E30EFA3197FC5EF1E85C21 /* BundleSignature.swift */; };
@@ -688,6 +693,7 @@
EEBE3490872F3A8D0400AC56 /* MenuSearchList.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2967B06D3E059BFB9F30DF /* MenuSearchList.swift */; };
F03DB7A7898F0388E821FDBB /* AppleIntelligenceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B5C0B83C3645AC90639B85 /* AppleIntelligenceProvider.swift */; };
F13789E3732E844EE7584561 /* ColorValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9287418DC6C04437FB4BAF /* ColorValue.swift */; };
F244E0064508C29F74363A8C /* ExecutablePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE505A8B3E665C2C1382B48 /* ExecutablePicker.swift */; };
F289967F8DE039B6E1C3BE12 /* InterfaceSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C9F1A20CBB32EBB9629A4F /* InterfaceSize.swift */; };
F37AC0478A0A96243E833900 /* MCPCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFFF4B2F2BF476AD9B111B9 /* MCPCoordinator.swift */; };
F3E0C5D483CC854D63448A4B /* UpdateReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76460C2D60828CC79F0CE972 /* UpdateReadiness.swift */; };
@@ -727,6 +733,7 @@
FD4DDDCDC32AF921CDBCFB18 /* Paster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3B9645DB2293FA144B5AA2 /* Paster.swift */; };
FD630A1F9A0D81A1340D4DBD /* FileSearchMediaPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4942C19E48EF6DECD2D303D /* FileSearchMediaPlayer.swift */; };
FD8DE9FDC0B6D5D94B59C4DB /* ExtensionAppearancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C8B2F6592B65CB6576D247 /* ExtensionAppearancePicker.swift */; };
FD9797BBEB6ADB0661AC4177 /* InstalledAIEnvironmentKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77484C4FCED518882AC8FEC6 /* InstalledAIEnvironmentKeychain.swift */; };
FDBA08E1FF8874D06FE6ACB8 /* AXMenuAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6812FB13FA56C5D0F2511A45 /* AXMenuAccess.swift */; };
FDF43BE032A1CBFE76526E6F /* ExtensionRefreshState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582A70CF432F5E0144E57369 /* ExtensionRefreshState.swift */; };
FE0AC2B2E1EF5A50FE8D33B8 /* SettingsFileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984A2A4E2CE02BC3281E90B7 /* SettingsFileMonitor.swift */; };
@@ -801,6 +808,7 @@
0CB8DC0BB8BFB2AC298B1DCF /* ExtensionRegistriesPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRegistriesPanel.swift; sourceTree = "<group>"; };
0CFA4726A74C5CCA6E301665 /* DialogChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogChip.swift; sourceTree = "<group>"; };
0D4F22647E4E1BA19F0FC520 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; };
0DE505A8B3E665C2C1382B48 /* ExecutablePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutablePicker.swift; sourceTree = "<group>"; };
0DFEBE220187043060F7F286 /* ToolRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolRunner.swift; sourceTree = "<group>"; };
0E099C50FF0B6A0069CAC5BF /* CommandOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandOutputView.swift; sourceTree = "<group>"; };
0E73A01A5FEA2B4CA68AE6A6 /* CustomWindowSizeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWindowSizeCoordinator.swift; sourceTree = "<group>"; };
@@ -808,6 +816,7 @@
0F1A3A9CEDBEBC132FC4A552 /* WindowSwitchSweep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSwitchSweep.swift; sourceTree = "<group>"; };
0F5730EA67D6E91ABAD8F446 /* AIEmptyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIEmptyState.swift; sourceTree = "<group>"; };
0F5AFB680B55BBAFF2207F99 /* NotesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesCoordinator.swift; sourceTree = "<group>"; };
0F7F40DAD48109685138513E /* InstalledAILaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledAILaunch.swift; sourceTree = "<group>"; };
102B53830D247FAE19479BCA /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = "<group>"; };
106F9FEF3F0C36E0C5686DA7 /* AIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequest.swift; sourceTree = "<group>"; };
1095A5EC0F5CE50ADB8C5D03 /* RoomParking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomParking.swift; sourceTree = "<group>"; };
@@ -1021,6 +1030,7 @@
552EF9B38ABA9443D4FDE5E4 /* AIToolRounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIToolRounds.swift; sourceTree = "<group>"; };
56D6EE5144AEF889C95B3758 /* QuickActionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionCoordinator.swift; sourceTree = "<group>"; };
570CE37C7133E28F7495ED91 /* MarkdownCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCodeView.swift; sourceTree = "<group>"; };
574A658F2503AA5BAED39903 /* AIProvidersPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIProvidersPanel.swift; sourceTree = "<group>"; };
57640B0DA3F71E66CECA871C /* ExtensionFormKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFormKey.swift; sourceTree = "<group>"; };
57694B58B0A17CAC1D697FAA /* BundleNameCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleNameCache.swift; sourceTree = "<group>"; };
5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWebSocketBridge.swift; sourceTree = "<group>"; };
@@ -1111,6 +1121,7 @@
76460C2D60828CC79F0CE972 /* UpdateReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateReadiness.swift; sourceTree = "<group>"; };
76C9F1A20CBB32EBB9629A4F /* InterfaceSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceSize.swift; sourceTree = "<group>"; };
7732FB0ED92C8FBFE22599CE /* ExtensionStoreResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionStoreResponse.swift; sourceTree = "<group>"; };
77484C4FCED518882AC8FEC6 /* InstalledAIEnvironmentKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledAIEnvironmentKeychain.swift; sourceTree = "<group>"; };
77A29ED24604BE67E562B07D /* WindowLayoutInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutInspector.swift; sourceTree = "<group>"; };
780B32AF98BA3A51D2F0E8B4 /* BackupBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupBundle.swift; sourceTree = "<group>"; };
7817B7A8760B36FDA43F48FC /* CalcEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcEngine.swift; sourceTree = "<group>"; };
@@ -1238,6 +1249,7 @@
9EB4EA7F440DD25F425110E8 /* WindowLayoutDisplayTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutDisplayTabs.swift; sourceTree = "<group>"; };
9F1798C6CF803D5E1BB2FFE4 /* ChatTranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTranscriptView.swift; sourceTree = "<group>"; };
9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionNodeShims.swift; sourceTree = "<group>"; };
A022043AE60501848E0C5026 /* AIModelChecklist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelChecklist.swift; sourceTree = "<group>"; };
A061D1B64684905BA0E998C5 /* ChatHistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryScreen.swift; sourceTree = "<group>"; };
A0BAA0471DD8CE4352F45FED /* ExtensionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCoordinator.swift; sourceTree = "<group>"; };
A1306524F57602D90FCF153C /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
@@ -1311,6 +1323,7 @@
BA90C7C0B97C4745B8F94C4B /* ExtensionListKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionListKey.swift; sourceTree = "<group>"; };
BA9F3FA9925873D25EAF7D03 /* PalettePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PalettePanel.swift; sourceTree = "<group>"; };
BB68DFBD782D32B5633EC4A7 /* SettingsFileIssue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFileIssue.swift; sourceTree = "<group>"; };
BBE8149D018E44AD3E13F642 /* SteadySegmentedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteadySegmentedPicker.swift; sourceTree = "<group>"; };
BC0AA95CA13E86370661D01C /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = "<group>"; };
BC5A51755634710649DC27B1 /* NoteFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteFormatting.swift; sourceTree = "<group>"; };
BD4B27ECEB1C09B14C403FC8 /* AIModelDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelDiscoveryService.swift; sourceTree = "<group>"; };
@@ -1341,6 +1354,7 @@
C4568F4465F3556178B98DB6 /* RoomMinimumSizeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMinimumSizeStore.swift; sourceTree = "<group>"; };
C464FF06F4AD0883F035290D /* CustomQuickActionEditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomQuickActionEditorPanel.swift; sourceTree = "<group>"; };
C49AE1E917BF6EA7783F4CAD /* CodexAppServerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexAppServerProtocol.swift; sourceTree = "<group>"; };
C4DB2A5435AD37E95789304B /* AIProviderAdvancedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIProviderAdvancedSection.swift; sourceTree = "<group>"; };
C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRuntime.swift; sourceTree = "<group>"; };
C5941CE8F08FD5986FED12E9 /* QuickActionsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionsSettingsView.swift; sourceTree = "<group>"; };
C5BA06E4CC7E8D40E3285F16 /* KeyCapChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapChip.swift; sourceTree = "<group>"; };
@@ -1539,6 +1553,7 @@
D3F82845FDA307DF71676352 /* CalendarAccess.swift */,
5DD86B3ABBB71BC6EA116D85 /* CameraAccess.swift */,
2438C33DEAF16B26E4F195EB /* ExecutableLocator.swift */,
0DE505A8B3E665C2C1382B48 /* ExecutablePicker.swift */,
236934966557373B61455B3C /* FolderPicker.swift */,
BFB023D167225228757CC4B2 /* HealthTicker.swift */,
2FCD070CDE64691E5F6ED28D /* InputSourceSwitcher.swift */,
@@ -1767,6 +1782,7 @@
4BEA1E122A8139A21E646FE2 /* RedactedText.swift */,
36DEEF228EF1398B421C89B2 /* RevealableSecureField.swift */,
BEDDA8820165672AF8464E25 /* SettingsComponents.swift */,
BBE8149D018E44AD3E13F642 /* SteadySegmentedPicker.swift */,
64EC07E163CE4F3D11FE7225 /* SymbolImage.swift */,
A9F256977D955CC53C1D914E /* SymbolPicker.swift */,
A1306524F57602D90FCF153C /* Theme.swift */,
@@ -2204,6 +2220,7 @@
C49AE1E917BF6EA7783F4CAD /* CodexAppServerProtocol.swift */,
F31FB61E6F6969851D3874FA /* CodexMCPLaunch.swift */,
A76D9592F0809DEC9993684A /* InstalledAI.swift */,
0F7F40DAD48109685138513E /* InstalledAILaunch.swift */,
D817AF0E23DDCC95147B5AFB /* InstalledAIStream.swift */,
EB0E63A5CDC274306555E3A8 /* JSONValue.swift */,
DDE8B48420B83D612E01603A /* MarkdownBlock.swift */,
@@ -3222,6 +3239,7 @@
7355ACD6D1BE7BC7FCA26BD2 /* CodexAppServerClient.swift */,
1E82DD04042376EBDEF3E84C /* CodexTurnRunner.swift */,
096F955B26BAEE5AED65B559 /* HTTPAIProvider.swift */,
77484C4FCED518882AC8FEC6 /* InstalledAIEnvironmentKeychain.swift */,
AD0BCEEE45732BE94136EEA8 /* InstalledAIManager.swift */,
1C8F318681FA5313677A14DA /* InstalledAIProbe.swift */,
E08F20B0B823443E176F1566 /* InstalledCLIProvider.swift */,
@@ -3313,7 +3331,10 @@
isa = PBXGroup;
children = (
87C6479121B0371B3491FA8A /* AIConnectionEditorPanel.swift */,
A022043AE60501848E0C5026 /* AIModelChecklist.swift */,
94D1EA2460590C49B4A66C64 /* AIModelSelectionRows.swift */,
C4DB2A5435AD37E95789304B /* AIProviderAdvancedSection.swift */,
574A658F2503AA5BAED39903 /* AIProvidersPanel.swift */,
C975F928132D3DAB1BB569F0 /* AISettingsStore.swift */,
8DA2EC0303BFCB1212E69B54 /* AISettingsView.swift */,
BF36BDC9ACE867DCC2CCF746 /* SystemPromptEditor.swift */,
@@ -3500,6 +3521,7 @@
EEB7F6A6AC8B53FDD607ED03 /* AIConversationOpenPolicy.swift in Sources */,
4D78F1E14023B34AF8E9D140 /* AIEmptyState.swift in Sources */,
ADC43E68C1B617D098C71D6E /* AIInstructions.swift in Sources */,
9DFBDD9863EB307D4F102524 /* AIModelChecklist.swift in Sources */,
36A6FC9C41CDBD4ADA1F8E87 /* AIModelDiscovery.swift in Sources */,
F8E1B044D84E96FB455155CD /* AIModelDiscoveryService.swift in Sources */,
D929CEAC227F3C88BBC134BF /* AIModelMenu.swift in Sources */,
@@ -3507,7 +3529,9 @@
73C3912866D29C26E4CED1D2 /* AIModelSelectionRows.swift in Sources */,
B72F17539405522E63D266F3 /* AIPreamble.swift in Sources */,
C7468FBE50BAEC5084D4DB17 /* AIProvider.swift in Sources */,
08402EEA92AECAF10CBAF2EA /* AIProviderAdvancedSection.swift in Sources */,
50300E827CBD0F54F0360111 /* AIProviderFactory.swift in Sources */,
0DD8A17796AE752342B7C26C /* AIProvidersPanel.swift in Sources */,
914402CB38C5D09C611AA8F4 /* AIRequest.swift in Sources */,
3B0E1DFB220BCBB7EADF65B7 /* AIRequestBody.swift in Sources */,
C33FF829408F4B2218416743 /* AIRetention.swift in Sources */,
@@ -3715,6 +3739,7 @@
7D027F24478DAB019076CA10 /* EventDraft.swift in Sources */,
A33EA048BC22D7313F4D480C /* EventDraftFields.swift in Sources */,
A6200866E3416744A6E8B6E5 /* ExecutableLocator.swift in Sources */,
F244E0064508C29F74363A8C /* ExecutablePicker.swift in Sources */,
DA781EE329E4D88B9C72788C /* ExtensionActionsPanel.swift in Sources */,
DCAC5BBB64C93F559B25E6FD /* ExtensionAnimatedImage.swift in Sources */,
A7D7F8F64B0937D20D97F428 /* ExtensionAppearance.swift in Sources */,
@@ -3842,6 +3867,8 @@
9E70D827FD1745DDAA2BC921 /* InlineArgumentFields.swift in Sources */,
0A086C5452FD9FCE68AF4E46 /* InputSourceSwitcher.swift in Sources */,
6D241F01F4BB98AAE1720330 /* InstalledAI.swift in Sources */,
FD9797BBEB6ADB0661AC4177 /* InstalledAIEnvironmentKeychain.swift in Sources */,
6D61C5110CA0B20BADA72002 /* InstalledAILaunch.swift in Sources */,
EAFC40D8851C50EB62198C7E /* InstalledAIManager.swift in Sources */,
544328603EE64420A24A128C /* InstalledAIProbe.swift in Sources */,
2E9CDC24A175FE8AAE4F03D9 /* InstalledAIStream.swift in Sources */,
@@ -4117,6 +4144,7 @@
7BDB7CF7F1352015F6AC96C3 /* SnippetsStore.swift in Sources */,
9A7BBA93D54AB1638F1EBE6F /* SpaceGesture.swift in Sources */,
7C2AA1D827EC87533021BD74 /* SpaceSwitcher.swift in Sources */,
60E4AA375F0424AAD55799FD /* SteadySegmentedPicker.swift in Sources */,
E29056264B57A3F0EC5F5698 /* SupportCoordinator.swift in Sources */,
B589C3ECFF03C790102361D4 /* SupportReminderSchedule.swift in Sources */,
E29CF7293742DDCAE030682C /* SupportReminderStore.swift in Sources */,
+23
View File
@@ -63,6 +63,7 @@ final class AppCore {
let chatHistory: ChatHistoryStore
let aiChats: AIChatSurfacesState
let aiSettings = AISettingsStore(
environmentStore: .keychain,
isAppleIntelligenceAvailable: { AppleIntelligenceProvider.status().isAvailable })
let mcpSettings = MCPSettingsStore()
let mcpOAuth = MCPOAuthManager()
@@ -71,6 +72,7 @@ final class AppCore {
let customQuickActions = CustomQuickActionStore()
let chatGPTSubscription = ChatGPTSubscriptionManager()
let installedAI = InstalledAIManager()
@ObservationIgnored private var appliedLaunchRevisions: [InstalledAIKind: Int] = [:]
/// Set when a quicklink editor should open with Settings; the pane consumes it.
var pendingQuicklinkEdit: QuicklinkEditRequest?
@@ -271,6 +273,8 @@ final class AppCore {
menuSearchCoordinator.applyEnabled()
fileSearchCoordinator.applyPolicy()
notesCoordinator.applyEnabled()
installedAI.launchSettings = { [aiSettings] in aiSettings.launch(for: $0) }
chatGPTSubscription.launchSettings = { [aiSettings] in aiSettings.launch(for: .codex) }
aiChatCoordinator.applyEnabled()
mcpCoordinator.applyEnabled()
customQuickActions.onChange = { [weak self] _ in
@@ -523,6 +527,24 @@ final class AppCore {
installedAI.stop()
}
/// Only the tool whose own path or variables changed is checked again; the rest keep running.
private func applyInstalledLaunches() {
let revisions = aiSettings.launchRevisions
let enabled =
settings.aiEnabled || settings.quickActionsEnabled
? aiSettings.enabledInstalledProviders : []
for kind in InstalledAIKind.allCases where appliedLaunchRevisions[kind] != revisions[kind] {
guard enabled.contains(kind) else { continue }
if kind == .codex {
chatGPTSubscription.stop()
chatGPTSubscription.refresh()
} else {
installedAI.refresh(kind: kind)
}
}
appliedLaunchRevisions = revisions
}
@discardableResult
func applyInstalledAILifecycle() -> Task<Void, Never> {
let enabledKinds =
@@ -647,6 +669,7 @@ final class AppCore {
{ _ = $0.clipboardRetention },
reproject: { $0.clipboardCoordinator.applyRetention($0.settings.clipboardRetention) })
track(aiSettings, { _ = $0.retention }, reproject: { $0.aiChatCoordinator.applyRetention() })
track(aiSettings, { _ = $0.launchRevisions }, reproject: { $0.applyInstalledLaunches() })
track(
{ _ = $0.extensionsShowInLauncher },
reproject: { $0.extensionCoordinator.applyExtensionsLauncherPresence() })
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "cursor.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"/></svg>

After

Width:  |  Height:  |  Size: 434 B

@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "grok.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1 @@
<svg fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 661 B

@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "opencode.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 32 40" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M32 40H0V0H32V40ZM24 8H8V32H24V8Z"/><path opacity="0.3" d="M24 32H8V16H24V32Z"/></svg>

After

Width:  |  Height:  |  Size: 187 B

+15 -5
View File
@@ -185,8 +185,9 @@ extension View {
modifier(SettingsEditorTextArea(height: height))
}
func settingsEditorPanelSurface() -> some View {
modifier(SettingsEditorPanelSurface())
/// `controlsOnGlass: false` draws the glass behind, so a control keeps its accent colour.
func settingsEditorPanelSurface(controlsOnGlass: Bool = true) -> some View {
modifier(SettingsEditorPanelSurface(controlsOnGlass: controlsOnGlass))
}
/// The one place that says hiding a row from the launcher never unbinds its shortcut.
@@ -266,11 +267,20 @@ private struct SettingsEditorTextArea: ViewModifier {
}
private struct SettingsEditorPanelSurface: ViewModifier {
let controlsOnGlass: Bool
@ViewBuilder
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
content
.background(Theme.Colors.panelScrim, in: shape)
.glassEffect(.regular, in: shape)
if controlsOnGlass {
content
.background(Theme.Colors.panelScrim, in: shape)
.glassEffect(.regular, in: shape)
} else {
content.background {
shape.fill(Theme.Colors.panelScrim).glassEffect(.regular, in: shape)
}
}
}
}
@@ -0,0 +1,60 @@
import AppKit
import SwiftUI
/// The system segmented control at one size for life, each segment as wide as its label.
///
/// Left to size itself, the control opens tight around its labels and widens the first time the
/// selection changes, under the pointer; stating each segment's width opens it already settled.
struct SteadySegmentedPicker<Value: Hashable>: NSViewRepresentable {
struct Option {
let value: Value
let title: String
}
let title: String
let options: [Option]
@Binding var selection: Value
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSSegmentedControl {
let control = NSSegmentedControl(
labels: options.map(\.title), trackingMode: .selectOne,
target: context.coordinator, action: #selector(Coordinator.changed(_:)))
control.setAccessibilityLabel(title)
let font = control.font ?? .systemFont(ofSize: NSFont.systemFontSize)
for (index, option) in options.enumerated() {
let label = (option.title as NSString).size(withAttributes: [.font: font]).width
control.setWidth(
(label + Theme.Size.segmentLabelInset * 2).rounded(.up), forSegment: index)
}
return control
}
func updateNSView(_ control: NSSegmentedControl, context: Context) {
context.coordinator.parent = self
control.selectedSegment = options.firstIndex { $0.value == selection } ?? -1
}
/// The control's own idea of its width also shrinks after the first switch, so it is not asked.
func sizeThatFits(
_ proposal: ProposedViewSize, nsView: NSSegmentedControl, context: Context
) -> CGSize? {
let width = (0..<nsView.segmentCount).reduce(0) { $0 + nsView.width(forSegment: $1) }
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
}
@MainActor
final class Coordinator: NSObject {
var parent: SteadySegmentedPicker
init(_ parent: SteadySegmentedPicker) {
self.parent = parent
}
@objc func changed(_ sender: NSSegmentedControl) {
guard parent.options.indices.contains(sender.selectedSegment) else { return }
parent.selection = parent.options[sender.selectedSegment].value
}
}
}
+8
View File
@@ -245,6 +245,14 @@ enum Theme {
static let layoutPositionStroke: CGFloat = 1.5
/// A position cell's clickable row; the glyph floats inside it, so the whole cell hits.
static let layoutPositionCell: CGFloat = 34
/// AI Providers: Mail's Accounts shape, a provider list beside the selected one's detail.
static let aiProvidersPanel = CGSize(width: 840, height: 520)
static let aiProvidersList: CGFloat = 262
/// What the system leaves either side of a segment's label once the control has settled.
static let segmentLabelInset: CGFloat = 13
static let aiVariableName: CGFloat = 170
/// A Codex usage window's meter, beside its "72% left" readout.
static let aiUsageBar: CGFloat = 110
/// Settings editor modals (Custom Commands, Snippets): fixed width, intrinsic height.
static let editorSheetWidth: CGFloat = 480
/// The multi-line box inside those modals; it scrolls rather than grows the panel.
+5
View File
@@ -4,6 +4,8 @@ import Foundation
enum AIBrand: String, CaseIterable, Sendable {
case openAI, claude, gemini, openRouter, x, deepSeek, qwen, mistral, meta, kimi, miniMax
case perplexity, zai
/// The marks of three installed routes; no model id resolves to them.
case grok, cursor, openCode
var assetName: String {
switch self {
@@ -20,6 +22,9 @@ enum AIBrand: String, CaseIterable, Sendable {
case .miniMax: return "AIBrandMiniMax"
case .perplexity: return "AIBrandPerplexity"
case .zai: return "AIBrandZAI"
case .grok: return "AIBrandGrok"
case .cursor: return "AIBrandCursor"
case .openCode: return "AIBrandOpenCode"
}
}
@@ -134,6 +134,21 @@ enum AIModelSource: Codable, Equatable, Hashable, Sendable {
case api(UUID)
}
extension AIModelSource {
/// A stable name for settings keyed by route, since a connection is only known by its id.
var storageKey: String {
switch self {
case .appleIntelligence: return "appleIntelligence"
case .codex: return "codex"
case .claude: return "claude"
case .grok: return "grok"
case .openCode: return "openCode"
case .cursor: return "cursor"
case .api(let id): return "api:" + id.uuidString
}
}
}
enum AIModelSelection: Codable, Equatable, Hashable, Sendable {
case appleIntelligence
case codex(model: String, effort: String?)
@@ -24,12 +24,14 @@ enum ChatGPTSubscription {
case "free": return "Free"
case "go": return "Go"
case "plus": return "Plus"
case "pro": return "Pro"
case "prolite": return "Pro Lite"
case "pro": return "Pro 20x"
case "prolite": return "Pro 5x"
case "team": return "Team"
case "self_serve_business_usage_based", "business": return "Business"
case "enterprise_cbp_usage_based", "enterprise": return "Enterprise"
case "edu": return "Edu"
case "self_serve_business_prolite", "self_serve_business_usage_based", "business":
return "Business"
case "ent26", "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise":
return "Enterprise"
case "edu", "edu_plus", "edu_pro": return "Edu"
case "apiKey", "api_key": return "API key"
default: return "Account"
}
+38 -7
View File
@@ -179,14 +179,29 @@ struct InstalledAIModel: Equatable, Identifiable, Sendable {
return []
}
/// "Opus 5.5 · Best for everyday…" names the version the alias points at today.
/// The same answer that lists the models names the account; nothing more is asked for it.
static func claudeAccount(_ output: String) -> InstalledAIAccount? {
for line in output.split(whereSeparator: \.isNewline) {
guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
object["type"] as? String == "control_response",
let response = object["response"] as? [String: Any],
let payload = response["response"] as? [String: Any],
let account = payload["account"] as? [String: Any]
else { continue }
let email = account["email"] as? String
let plan = account["subscriptionType"] as? String
guard email != nil || plan != nil else { return nil }
return InstalledAIAccount(email: email, plan: plan)
}
return nil
}
/// An older CLI leads `description` with the version, "Opus 5.5 · Best…"; a newer one names it.
private static func claudeName(_ entry: [String: Any], fallback: String) -> String {
let described = (entry["description"] as? String)?
.components(separatedBy: " · ").first?
.trimmingCharacters(in: .whitespaces)
let name =
described.flatMap { $0.isEmpty ? nil : $0 }
?? entry["displayName"] as? String ?? fallback
let parts = (entry["description"] as? String ?? "").components(separatedBy: " · ")
let versioned = parts.count > 1 ? parts[0].trimmingCharacters(in: .whitespaces) : ""
let displayed = entry["displayName"] as? String ?? ""
let name = [versioned, displayed].first { !$0.isEmpty } ?? fallback
return name.hasPrefix("Claude") ? name : "Claude " + name
}
@@ -281,6 +296,21 @@ struct InstalledAIModel: Equatable, Identifiable, Sendable {
}
}
/// Who a tool is signed in as, when it says; shown so the reader knows which account is billed.
struct InstalledAIAccount: Equatable, Sendable {
let email: String?
let plan: String?
/// The CLI says "max" in one answer and "Claude Max" in another; the row adds the tool's name.
var planTitle: String? {
guard var title = plan?.trimmingCharacters(in: .whitespaces), !title.isEmpty else {
return nil
}
if title.lowercased().hasPrefix("claude ") { title = String(title.dropFirst(7)) }
return title.prefix(1).uppercased() + title.dropFirst()
}
}
struct InstalledAIStatus: Equatable, Sendable {
enum Phase: Equatable, Sendable {
case idle
@@ -295,6 +325,7 @@ struct InstalledAIStatus: Equatable, Sendable {
var version: String?
var executable: URL?
var models: [InstalledAIModel] = []
var account: InstalledAIAccount?
var isReady: Bool { phase == .ready && executable != nil && !models.isEmpty }
}
@@ -0,0 +1,111 @@
import Foundation
/// What the reader set for one installed tool: where its command is and what it is launched with.
struct InstalledAIOverride: Codable, Equatable, Sendable {
/// Empty leaves finding the command to `ExecutableLocator`.
var commandPath = ""
/// Names only, in the reader's order; a value is often a key, so values live in the Keychain.
var environmentNames: [String] = []
var isEmpty: Bool { commandPath.isEmpty && environmentNames.isEmpty }
}
/// One variable as the reader edits it: the name from settings, the value from the Keychain.
struct InstalledAIVariable: Equatable, Sendable {
var name: String
var value: String
}
/// Where the variables' values are kept, behind closures so a harness needs no Keychain.
struct InstalledAIEnvironmentStore: Sendable {
/// Throws when the values cannot be read, so an editor never takes that for none being set.
var values: @Sendable (InstalledAIKind) throws -> [String: String]
var save: @Sendable ([String: String], InstalledAIKind) throws -> Void
/// Holds nothing and keeps nothing, for a store built without one.
static let none = InstalledAIEnvironmentStore(values: { _ in [:] }, save: { _, _ in })
}
/// An override resolved for one launch: the path checked, the variables' values read.
struct InstalledAILaunch: Equatable, Sendable {
enum Command: Equatable, Sendable {
/// No path was set, so the command is looked up the way a Terminal would find it.
case automatic
case executable(URL)
/// A path was set and nothing runnable is there; the lookup is not a fallback for it.
case missing(path: String)
}
var commandPath = ""
var environment: [String: String] = [:]
init(commandPath: String = "", environment: [String: String] = [:]) {
self.commandPath = commandPath
self.environment = environment
}
/// The set path wins or fails: falling back would hide the mistake the path was set to fix.
func command(
isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }
) -> Command {
let trimmed = commandPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .automatic }
let expanded = (trimmed as NSString).expandingTildeInPath
guard expanded.hasPrefix("/"), isExecutable(expanded) else {
return .missing(path: trimmed)
}
return .executable(URL(fileURLWithPath: expanded))
}
/// What a launch inherits: the app's variables under the reader's, minus those Tinycast sets.
func inherited(
for kind: InstalledAIKind,
base: [String: String] = ProcessInfo.processInfo.environment
) -> [String: String] {
let mine = environment.filter {
Self.isVariableName($0.key) && !kind.isManagedVariable($0.key)
}
return base.merging(mine) { _, new in new }
}
static func missingCommandMessage(_ path: String) -> String {
"Nothing can be run at \(path). Check the command path."
}
nonisolated static func isVariableName(_ name: String) -> Bool {
guard let first = name.first, first == "_" || (first.isASCII && first.isLetter) else {
return false
}
return name.allSatisfy { $0 == "_" || ($0.isASCII && ($0.isLetter || $0.isNumber)) }
}
}
extension InstalledAIKind {
/// Set on every chat launch to keep the tool inside the chat; a reader's value never wins.
var managedEnvironment: [String: String] {
switch self {
case .claude:
return ["CLAUDE_CODE_SKIP_PROMPT_HISTORY": "1", "ENABLE_CLAUDEAI_MCP_SERVERS": "false"]
case .openCode:
return [
"OPENCODE_CONFIG_CONTENT": Self.openCodeConfiguration,
"OPENCODE_AUTO_SHARE": "false",
"OPENCODE_DISABLE_AUTOUPDATE": "true"
]
case .grok:
return ["GROK_DISABLE_AUTOUPDATER": "1", "GROK_AGENT_DASHBOARD": "0"]
case .cursor, .codex:
return [:]
}
}
/// `NO_COLOR` keeps output parseable; `TC_MCP_` names carry Tinycast's MCP secrets to Codex.
func isManagedVariable(_ name: String) -> Bool {
name == "NO_COLOR" || name.hasPrefix("TC_MCP_") || managedEnvironment[name] != nil
}
private static let openCodeConfiguration = """
{"permission":"deny","share":"disabled","agent":{"build":{"permission":"deny"},\
"plan":{"permission":"deny"}}}
"""
}
@@ -15,6 +15,9 @@ enum AIProviderFactory {
) throws -> any AIProvider {
switch selection {
case .appleIntelligence:
guard settings.isRouteEnabled(.appleIntelligence) else {
throw AIProviderError.unavailable("Apple Intelligence is disabled in AI Settings.")
}
if let message = AppleIntelligenceProvider.status().message {
throw AIProviderError.unavailable(message)
}
@@ -51,6 +54,9 @@ enum AIProviderFactory {
guard let connection = settings.connection(id: connectionID) else {
throw AIProviderError.unavailable("Choose an API connection in Settings.")
}
guard settings.isRouteEnabled(.api(connectionID)) else {
throw AIProviderError.unavailable("\(connection.title) is disabled in AI Settings.")
}
let baseURL: URL
do {
baseURL = try AIEndpointPolicy.validate(connection.baseURL)
@@ -10,11 +10,18 @@ final class ChatGPTSubscriptionManager {
private let client: CodexAppServerClient
let turns: CodexTurnRunner
/// Forwarded to the app-server's launch; a change takes effect at its next start.
@ObservationIgnored var launchSettings: () -> InstalledAILaunch {
get { client.launchSettings }
set { client.launchSettings = newValue }
}
private(set) var phase = ChatGPTSubscription.Phase.idle
private(set) var account: ChatGPTSubscription.Account?
private(set) var models: [ChatGPTSubscription.Model] = []
private(set) var rateLimits: ChatGPTSubscription.RateLimits?
/// Copied from the client at each check: the client is not observed, and Settings shows this.
private(set) var executable: URL?
@ObservationIgnored private var operationTask: Task<Void, Never>?
@ObservationIgnored private var idleTask: Task<Void, Never>?
@@ -104,6 +111,7 @@ final class ChatGPTSubscriptionManager {
phase = .starting
do {
try await client.startForCheck()
executable = client.executable
guard try await restoreAccount() else {
phase = .signedOut
client.stop()
@@ -31,9 +31,13 @@ final class CodexAppServerClient {
var onElicitation: ((CodexElicitation) async -> Bool)?
/// A launch is about to replace a running process, whose threads go with it.
var onRelaunch: (() -> Void)?
/// The reader's command path and variables, asked at each launch so an edit takes the next one.
var launchSettings: () -> InstalledAILaunch = { InstalledAILaunch() }
private let codexHome: URL?
let workspace: URL
/// The command the last launch ran, kept after it stops so Settings can still name it.
private(set) var executable: URL?
private var process: Process?
private var processID: UUID?
private var input: FileHandle?
@@ -114,9 +118,21 @@ final class CodexAppServerClient {
private func launch(_ toolServers: [AIToolServer]) async throws {
let generation = self.generation
guard let executable = await ExecutableLocator.locate("codex") else {
throw ClientError.executableMissing
let settings = launchSettings()
let executable: URL
switch settings.command() {
case .executable(let url):
executable = url
case .missing(let path):
throw ClientError.launchFailed(InstalledAILaunch.missingCommandMessage(path))
case .automatic:
guard let found = await ExecutableLocator.locate("codex") else {
throw ClientError.executableMissing
}
executable = found
}
self.executable = executable
let inherited = settings.inherited(for: .codex)
try checkNotStopped(since: generation)
// The list is only readable at launch, so the old process cannot be talked into it.
if isRunning {
@@ -144,7 +160,8 @@ final class CodexAppServerClient {
// Unread, the reader's servers would start inside the chat; so Codex does not start either.
guard
let foreign = await Self.foreignServerNames(
executable: executable, workspace: workspace, codexHome: codexHome)
executable: executable, workspace: workspace, codexHome: codexHome,
inherited: inherited)
else {
throw ClientError.launchFailed(
"Tinycast could not read which MCP servers your Codex configuration runs, so it "
@@ -173,7 +190,8 @@ final class CodexAppServerClient {
+ CodexMCPLaunch.arguments(servers: toolServers, disabling: foreign)
+ ["app-server"]
process.currentDirectoryURL = workspace
var environment = ExecutableLocator.environment(running: executable, adding: secrets)
var environment = ExecutableLocator.environment(
running: executable, adding: secrets, inherited: inherited)
// Tests can isolate app-server state; production deliberately inherits the user's Codex home.
if let codexHome { environment["CODEX_HOME"] = codexHome.path }
process.environment = environment
@@ -0,0 +1,42 @@
import Foundation
extension InstalledAIEnvironmentStore {
/// One Keychain item per tool, as JSON; a failed read throws, a garbled item reads as none.
static let keychain = InstalledAIEnvironmentStore(
values: { kind in
guard
let stored = try KeychainSecretStore.installedAIEnvironment.secret(
for: kind.keychainAccount)
else { return [:] }
let values = try? JSONDecoder().decode([String: String].self, from: Data(stored.utf8))
return values ?? [:]
},
save: { values, kind in
guard !values.isEmpty else {
try KeychainSecretStore.installedAIEnvironment.removeSecret(
for: kind.keychainAccount)
return
}
let data = try JSONEncoder().encode(values)
guard let encoded = String(bytes: data, encoding: .utf8) else {
throw KeychainSecretStore.StoreError.invalidEncoding
}
try KeychainSecretStore.installedAIEnvironment.setSecret(
encoded, for: kind.keychainAccount)
})
}
extension InstalledAIKind {
/// The Keychain store names an item by UUID, so each tool has one that never changes.
fileprivate var keychainAccount: UUID {
let value =
switch self {
case .codex: "559A2B35-A0EE-45DF-8E97-A87052450350"
case .claude: "06C43DBE-F304-4E64-BBC5-F4A2AB00264B"
case .grok: "83C70D38-66BE-4D64-8F1F-BC54FBF83E6B"
case .openCode: "AF4DC1E6-7D9C-4C65-B278-CDB141C89DD3"
case .cursor: "76B444D9-4617-45C8-A3E1-71A977C49D8D"
}
return UUID(uuidString: value) ?? UUID()
}
}
@@ -9,6 +9,10 @@ final class InstalledAIManager {
@ObservationIgnored private let workspace: URL
@ObservationIgnored private var refreshTasks: [InstalledAIKind: Task<Void, Never>] = [:]
/// The reader's command path and variables, asked at each launch so an edit takes the next one.
@ObservationIgnored var launchSettings: (InstalledAIKind) -> InstalledAILaunch = { _ in
InstalledAILaunch()
}
/// An admin's MCP policy makes Claude reject both MCP flags; a harness points this elsewhere.
nonisolated static var hasManagedMCPPolicy: Bool {
@@ -92,9 +96,10 @@ final class InstalledAIManager {
refreshTasks[kind]?.cancel()
statuses[kind] = InstalledAIStatus(phase: .checking)
let workspace = workspace
let launch = launchSettings(kind)
let task = Task { [weak self] in
guard let self else { return }
let result = await Self.probe(kind, workspace: workspace)
let result = await Self.probe(kind, launch: launch, workspace: workspace)
guard !Task.isCancelled else { return }
self.statuses[result.0] = result.1
}
@@ -141,7 +146,10 @@ final class InstalledAIManager {
else { return nil }
let output = await InstalledAIProbe.request(
executable: executable, arguments: Self.claudeControlArguments,
workspace: workspace, input: Data(request.utf8),
workspace: workspace,
environment: ExecutableLocator.environment(
running: executable, inherited: launchSettings(.claude).inherited(for: .claude)),
input: Data(request.utf8),
until: { output in
// A whole line: the ID arrives before the title, so a chunk may split between them.
output.split(separator: "\n", omittingEmptySubsequences: false).dropLast()
@@ -166,20 +174,41 @@ final class InstalledAIManager {
}
return InstalledCLIProvider(
kind: kind, executable: status.executable, model: model, effort: effort,
workspace: workspace, toolServers: toolServers)
workspace: workspace, launch: launchSettings(kind), toolServers: toolServers)
}
private enum Command {
case found(URL)
case unavailable(InstalledAIStatus.Phase)
}
nonisolated private static func command(
for kind: InstalledAIKind, launch: InstalledAILaunch
) async -> Command {
switch launch.command() {
case .executable(let url): return .found(url)
case .missing(let path):
return .unavailable(.failed(InstalledAILaunch.missingCommandMessage(path)))
case .automatic:
let found = await ExecutableLocator.locate(
kind.command, extraHomePaths: kind.extraExecutablePaths)
return found.map(Command.found) ?? .unavailable(.notInstalled)
}
}
nonisolated private static func probe(
_ kind: InstalledAIKind, workspace: URL
_ kind: InstalledAIKind, launch: InstalledAILaunch, workspace: URL
) async -> (InstalledAIKind, InstalledAIStatus) {
guard
let executable = await ExecutableLocator.locate(
kind.command, extraHomePaths: kind.extraExecutablePaths)
else {
return (kind, InstalledAIStatus(phase: .notInstalled))
let executable: URL
switch await command(for: kind, launch: launch) {
case .found(let url): executable = url
case .unavailable(let phase): return (kind, InstalledAIStatus(phase: phase))
}
let environment = ExecutableLocator.environment(
running: executable, inherited: launch.inherited(for: kind))
let versionResult = await InstalledAIProbe.run(
executable: executable, arguments: ["--version"], workspace: workspace)
executable: executable, arguments: ["--version"], workspace: workspace,
environment: environment)
guard versionResult.status == 0 else {
return (
kind,
@@ -193,7 +222,7 @@ final class InstalledAIManager {
case .claude:
let auth = await InstalledAIProbe.run(
executable: executable, arguments: ["auth", "status", "--json"],
workspace: workspace)
workspace: workspace, environment: environment)
let loggedIn = InstalledAIProbe.loggedIn(inStatusJSON: auth.output)
guard auth.status == 0, loggedIn else {
return (
@@ -205,6 +234,7 @@ final class InstalledAIManager {
// No prompt follows the request, so the CLI answers and exits without calling a model.
let catalog = await InstalledAIProbe.run(
executable: executable, arguments: claudeControlArguments, workspace: workspace,
environment: environment,
input: Data(InstalledAIModel.claudeInitializeRequest.utf8),
// The reader's SessionStart hooks run before the CLI answers, however slow they are.
timeout: .seconds(30))
@@ -215,12 +245,13 @@ final class InstalledAIManager {
phase: models.isEmpty
? .failed("Claude listed no models. Update Claude Code, then Check Again.")
: .ready,
version: version, executable: executable, models: models)
version: version, executable: executable, models: models,
account: InstalledAIModel.claudeAccount(catalog.output))
)
case .openCode:
let models = await InstalledAIProbe.run(
executable: executable, arguments: ["models", "--pure", "--verbose"],
workspace: workspace)
workspace: workspace, environment: environment)
let catalog = InstalledAIModel.openCodeCatalog(models.output)
return (
kind,
@@ -230,7 +261,7 @@ final class InstalledAIManager {
)
case .grok:
let models = await InstalledAIProbe.run(
executable: executable, arguments: ["models"], workspace: workspace)
executable: executable, arguments: ["models"], workspace: workspace, environment: environment)
let catalog = InstalledAIModel.grokCatalog(models.output)
let signedIn = models.status == 0 && InstalledAIModel.grokSignedIn(models.output)
return (
@@ -243,7 +274,7 @@ final class InstalledAIManager {
case .cursor:
let auth = await InstalledAIProbe.run(
executable: executable, arguments: ["status", "--format", "json"],
workspace: workspace)
workspace: workspace, environment: environment)
let loggedIn = InstalledAIProbe.loggedIn(inStatusJSON: auth.output)
guard auth.status == 0, loggedIn else {
return (
@@ -253,7 +284,8 @@ final class InstalledAIManager {
)
}
let models = await InstalledAIProbe.run(
executable: executable, arguments: ["--list-models"], workspace: workspace)
executable: executable, arguments: ["--list-models"], workspace: workspace,
environment: environment)
let catalog = InstalledAIModel.cursorCatalog(models.output)
return (
kind,
@@ -90,7 +90,8 @@ enum InstalledAIProbe {
/// Holds stdin open until a line answers, since the CLI exits once its input closes.
nonisolated static func request(
executable: URL, arguments: [String], workspace: URL, input: Data,
executable: URL, arguments: [String], workspace: URL,
environment: [String: String]? = nil, input: Data,
until answered: @escaping @Sendable (String) -> Bool, timeout: Duration = .seconds(30)
) async -> String {
await Task.detached {
@@ -102,7 +103,8 @@ enum InstalledAIProbe {
process.executableURL = executable
process.arguments = arguments
process.currentDirectoryURL = workspace
process.environment = ExecutableLocator.environment(running: executable)
process.environment =
environment ?? ExecutableLocator.environment(running: executable)
process.standardInput = stdin
process.standardOutput = output
process.standardError = FileHandle.nullDevice
@@ -6,11 +6,11 @@ struct InstalledCLIProvider: AIProvider {
@MainActor
init(
kind: InstalledAIKind, executable: URL?, model: String, effort: String?, workspace: URL,
toolServers: AIToolServerSession? = nil
launch: InstalledAILaunch = InstalledAILaunch(), toolServers: AIToolServerSession? = nil
) {
runner = InstalledCLITurnRunner(
kind: kind, executable: executable, model: model, effort: effort,
workspace: workspace, toolServers: toolServers)
workspace: workspace, launch: launch, toolServers: toolServers)
}
func stream(_ request: AIRequest) -> AIProviderStream {
@@ -31,10 +31,6 @@ private final class InstalledCLITurnRunner {
supplied with this request. Do not read files, inspect the environment, access external \
resources, or modify anything else.
"""
private static let openCodeConfiguration = """
{"permission":"deny","share":"disabled","agent":{"build":{"permission":"deny"},\
"plan":{"permission":"deny"}}}
"""
private static var maximumPartialLineBytes: Int {
if let raw = ProcessInfo.processInfo.environment["TC_INSTALLED_MAX_LINE_BYTES"],
@@ -52,6 +48,7 @@ private final class InstalledCLITurnRunner {
private let model: String
private let effort: String?
private let workspace: URL
private let launch: InstalledAILaunch
private let toolServers: AIToolServerSession?
private var token: TurnToken?
@@ -72,9 +69,10 @@ private final class InstalledCLITurnRunner {
init(
kind: InstalledAIKind, executable: URL?, model: String, effort: String?, workspace: URL,
toolServers: AIToolServerSession? = nil
launch: InstalledAILaunch, toolServers: AIToolServerSession? = nil
) {
self.kind = kind
self.launch = launch
configuredExecutable = executable
self.model = model
self.effort = effort
@@ -115,11 +113,21 @@ private final class InstalledCLITurnRunner {
return
}
let resolvedExecutable: URL?
if let configuredExecutable {
resolvedExecutable = configuredExecutable
} else {
resolvedExecutable = await ExecutableLocator.locate(
kind.command, extraHomePaths: kind.extraExecutablePaths)
switch launch.command() {
case .executable(let url):
resolvedExecutable = url
case .missing(let path):
continuation.finish(
throwing: AIProviderError.unavailable(
InstalledAILaunch.missingCommandMessage(path)))
return
case .automatic:
if let configuredExecutable {
resolvedExecutable = configuredExecutable
} else {
resolvedExecutable = await ExecutableLocator.locate(
kind.command, extraHomePaths: kind.extraExecutablePaths)
}
}
guard let executable = resolvedExecutable else {
continuation.finish(
@@ -366,22 +374,9 @@ private final class InstalledCLITurnRunner {
}
private func environment(for executable: URL) -> [String: String] {
var result = ExecutableLocator.environment(running: executable)
switch kind {
case .claude:
result["CLAUDE_CODE_SKIP_PROMPT_HISTORY"] = "1"
result["ENABLE_CLAUDEAI_MCP_SERVERS"] = "false"
case .openCode:
result["OPENCODE_CONFIG_CONTENT"] = Self.openCodeConfiguration
result["OPENCODE_AUTO_SHARE"] = "false"
result["OPENCODE_DISABLE_AUTOUPDATE"] = "true"
case .grok:
result["GROK_DISABLE_AUTOUPDATER"] = "1"
result["GROK_AGENT_DASHBOARD"] = "0"
case .cursor, .codex:
break
}
return result
ExecutableLocator.environment(
running: executable, inherited: launch.inherited(for: kind)
).merging(kind.managedEnvironment) { _, managed in managed }
}
private func prompt(for request: AIRequest) -> String {
@@ -0,0 +1,166 @@
import AppKit
import SwiftUI
/// Models as checkboxes in one `Form` row, since a `Form` realizes every row and OpenCode has 400.
struct AIModelChecklist: NSViewRepresentable {
struct Item: Equatable {
let id: String
let title: String
let isOn: Bool
/// The default model: always listed, so its box cannot be cleared.
let isLocked: Bool
}
let items: [Item]
let onToggle: (String, Bool) -> Void
/// A grouped `Form` row's own vertical padding, which the first and last rows already carry.
static let rowPadding: CGFloat = 9
static let rowHeight: CGFloat = 18 + 2 * rowPadding
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSView {
let table = NSTableView()
table.headerView = nil
table.style = .plain
table.rowHeight = Self.rowHeight
table.intercellSpacing = .zero
table.backgroundColor = .clear
table.selectionHighlightStyle = .none
table.focusRingType = .none
table.refusesFirstResponder = true
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("model"))
column.resizingMask = .autoresizingMask
table.addTableColumn(column)
table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
table.dataSource = context.coordinator
table.delegate = context.coordinator
context.coordinator.table = table
return ChecklistContainer(table: table, overhang: Self.rowPadding)
}
func updateNSView(_ container: NSView, context: Context) {
context.coordinator.show(self)
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? {
let rows = CGFloat(items.count) * Self.rowHeight
return CGSize(width: proposal.width ?? nsView.frame.width, height: rows - 2 * Self.rowPadding)
}
@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
fileprivate weak var table: NSTableView?
private var list: AIModelChecklist?
private var shownIDs: [String] = []
fileprivate func show(_ list: AIModelChecklist) {
self.list = list
guard let table else { return }
let ids = list.items.map(\.id)
guard ids == shownIDs else {
shownIDs = ids
table.reloadData()
return
}
let visible = table.rows(in: table.visibleRect)
for row in visible.location..<visible.location + visible.length {
let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false)
(cell as? ChecklistCellView)?.show(list.items[row], showsDivider: row > 0)
}
}
func numberOfRows(in tableView: NSTableView) -> Int {
list?.items.count ?? 0
}
func tableView(
_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int
) -> NSView? {
guard let list else { return nil }
let reused = tableView.makeView(withIdentifier: ChecklistCellView.reuseID, owner: nil)
let cell = reused as? ChecklistCellView ?? ChecklistCellView()
cell.onToggle = { [weak self] id, isOn in self?.list?.onToggle(id, isOn) }
cell.show(list.items[row], showsDivider: row > 0)
return cell
}
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false }
}
}
/// Hangs the table into the `Form` row's padding: negative padding doesn't move an AppKit view.
private final class ChecklistContainer: NSView {
private let table: NSTableView
private let overhang: CGFloat
init(table: NSTableView, overhang: CGFloat) {
self.table = table
self.overhang = overhang
super.init(frame: .zero)
addSubview(table)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var isFlipped: Bool { true }
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
table.frame = bounds.insetBy(dx: 0, dy: -overhang)
}
}
/// A reused row: a stock checkbox, the default's tag, and the hairline a `Form` row would draw.
private final class ChecklistCellView: NSTableCellView {
static let reuseID = NSUserInterfaceItemIdentifier("modelChecklistRow")
var onToggle: (String, Bool) -> Void = { _, _ in }
private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
private let defaultTag = NSTextField(labelWithString: "Default")
private let divider = NSBox()
private var itemID = ""
init() {
super.init(frame: .zero)
identifier = Self.reuseID
checkbox.target = self
checkbox.action = #selector(toggled)
checkbox.lineBreakMode = .byTruncatingMiddle
defaultTag.textColor = .secondaryLabelColor
divider.boxType = .separator
for view in [checkbox, defaultTag, divider] as [NSView] {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
NSLayoutConstraint.activate([
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor),
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.trailingAnchor.constraint(
lessThanOrEqualTo: defaultTag.leadingAnchor, constant: -Theme.Spacing.lg),
defaultTag.trailingAnchor.constraint(equalTo: trailingAnchor),
defaultTag.centerYAnchor.constraint(equalTo: centerYAnchor),
divider.leadingAnchor.constraint(equalTo: leadingAnchor),
divider.trailingAnchor.constraint(equalTo: trailingAnchor),
divider.topAnchor.constraint(equalTo: topAnchor)
])
defaultTag.setContentCompressionResistancePriority(.required, for: .horizontal)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func show(_ item: AIModelChecklist.Item, showsDivider: Bool) {
itemID = item.id
checkbox.title = item.title
checkbox.state = item.isOn || item.isLocked ? .on : .off
checkbox.isEnabled = !item.isLocked
defaultTag.isHidden = !item.isLocked
divider.isHidden = !showsDivider
}
@objc private func toggled() {
onToggle(itemID, checkbox.state == .on)
}
}
@@ -0,0 +1,183 @@
import SwiftUI
/// An installed tool's command path and launch variables, saved as each field is left.
struct AIProviderAdvancedSection: View {
@Environment(AISettingsStore.self) private var settings
let kind: InstalledAIKind
/// What the lookup found, where Choose… opens.
let detected: URL?
@State private var path = ""
@State private var variables: [Draft] = []
@State private var saveFailed = false
@State private var readFailed = false
@FocusState private var focus: Field?
private struct Draft: Identifiable, Equatable {
let id = UUID()
var name = ""
var value = ""
}
private enum Field: Hashable {
case path
case name(UUID)
case value(UUID)
}
var body: some View {
Section {
LabeledContent {
HStack(spacing: Theme.Spacing.sm) {
TextField("Command path", text: $path, prompt: Text("Automatic"))
.labelsHidden()
.font(.callout.monospaced())
.autocorrectionDisabled()
.focused($focus, equals: .path)
.onSubmit(save)
Button("Choose…", action: choose)
.fixedSize()
}
} label: {
Text("Command path")
Text("Empty finds \(kind.command) the way Terminal does.")
}
.task(id: kind) { load() }
.onChange(of: focus) { old, new in
if old != nil, old != new { save() }
}
} header: {
Text("Command")
}
Section {
LabeledContent {
Button("Add Variable", action: addVariable)
.fixedSize()
.disabled(readFailed)
} label: {
Text("Variables")
Text("Set for \(kind.title) only, each time it starts.")
}
ForEach($variables) { $variable in
variableRow($variable)
}
} header: {
Text("Environment")
} footer: {
Text(footer)
.font(.caption)
.foregroundStyle(
saveFailed || readFailed
? AnyShapeStyle(Theme.Colors.destructive) : AnyShapeStyle(.secondary))
}
}
private func variableRow(_ variable: Binding<Draft>) -> some View {
let draft = variable.wrappedValue
return VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
HStack(spacing: Theme.Spacing.sm) {
TextField("Name", text: variable.name, prompt: Text("NAME"))
.labelsHidden()
.font(.callout.monospaced())
.autocorrectionDisabled()
.frame(width: Theme.Size.aiVariableName)
.focused($focus, equals: .name(draft.id))
.onSubmit(save)
RevealableSecureField(
title: "Value of \(draft.name)", text: variable.value, prompt: Text("Value")
)
.labelsHidden()
.font(.callout.monospaced())
.focused($focus, equals: .value(draft.id))
.onSubmit(save)
Button {
remove(draft.id)
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Remove")
.accessibilityLabel("Remove \(draft.name)")
}
if let note = note(for: draft.name) {
Text(note)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var footer: String {
if readFailed { return "The variables could not be read from your login Keychain." }
return saveFailed
? "The variables could not be saved to your login Keychain."
: "Values stay in your login Keychain. A change applies the next time \(kind.title) starts."
}
private func note(for name: String) -> String? {
guard !name.isEmpty else { return nil }
if !InstalledAILaunch.isVariableName(name) {
return "A name is letters, digits and underscores, and does not start with a digit."
}
if kind.isManagedVariable(name) {
return "Tinycast sets \(name) itself, so this value is not used."
}
return nil
}
private func load() {
path = settings.override(for: kind).commandPath
do {
variables = try settings.environment(for: kind).map {
Draft(name: $0.name, value: $0.value)
}
readFailed = false
} catch {
variables = []
readFailed = true
}
saveFailed = false
}
private func save() {
settings.setCommandPath(path, for: kind)
// Drafts from a failed read have no values, and saving them would blank the stored ones.
guard !readFailed else { return }
do {
try settings.setEnvironment(
variables.map {
InstalledAIVariable(
name: $0.name.trimmingCharacters(in: .whitespaces), value: $0.value)
}, for: kind)
saveFailed = false
} catch {
saveFailed = true
}
}
private func addVariable() {
let draft = Draft()
variables.append(draft)
focus = .name(draft.id)
}
private func remove(_ id: UUID) {
variables.removeAll { $0.id == id }
save()
}
private func choose() {
let start =
detected?.deletingLastPathComponent()
?? FileManager.default.homeDirectoryForCurrentUser
guard
let url = ExecutablePicker.choose(
message: "Choose the \(kind.command) command Tinycast should run.",
startingAt: start)
else { return }
path = (url.path as NSString).abbreviatingWithTildeInPath
save()
}
}
@@ -0,0 +1,910 @@
import AppKit
import SwiftUI
/// One entry of the Providers list: the on-device model, an installed command, or an API connection.
enum AIProviderRoute: Hashable {
case appleIntelligence
case installed(InstalledAIKind)
case api(UUID)
var source: AIModelSource {
switch self {
case .appleIntelligence: return .appleIntelligence
case .installed(let kind): return kind.source
case .api(let id): return .api(id)
}
}
}
/// One page of a provider's detail; a route lists only the pages it has something to put on.
enum AIProviderTab: String, CaseIterable, Identifiable {
case overview
case models
case advanced
var id: String { rawValue }
var title: String {
switch self {
case .overview: return "Overview"
case .models: return "Models"
case .advanced: return "Advanced"
}
}
static func tabs(for route: AIProviderRoute) -> [AIProviderTab] {
switch route {
case .appleIntelligence: return [.overview]
case .installed: return [.overview, .models, .advanced]
case .api: return [.overview, .models]
}
}
}
/// Settings → AI → Providers: every route in a list, the selected one's pages beside it.
struct AIProvidersPanel: View {
@Environment(AppCore.self) private var core
@Environment(AISettingsStore.self) private var settings
@Environment(ChatGPTSubscriptionManager.self) private var subscription
@Environment(InstalledAIManager.self) private var installedAI
let onDone: () -> Void
@State private var selection: AIProviderRoute?
@State private var keyStatuses: [UUID: Bool] = [:]
@State private var keyError = false
@State private var editor: AIConnectionEditorTarget?
@State private var pendingRemoval: AIConnection?
@State private var modelQuery = ""
/// Kept across providers, as Mail keeps its tab across accounts; one without it shows Overview.
@State private var tab = AIProviderTab.overview
private let keyStore = KeychainSecretStore.aiAPIKeys
var body: some View {
VStack(spacing: 0) {
SettingsEditorHeader(
title: "AI Providers",
subtitle: "Installed tools, API connections, and the models each one offers."
)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Theme.Spacing.dialogInset)
.padding(.top, Theme.Spacing.dialogInset)
.padding(.bottom, Theme.Spacing.xl)
Divider()
HStack(spacing: 0) {
list
.frame(width: Theme.Size.aiProvidersList)
Divider()
detail
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Stated, never intrinsic: selecting a longer provider must not resize the panel.
.frame(height: Theme.Size.aiProvidersPanel.height)
Divider()
HStack {
Spacer(minLength: 0)
Button("Done", action: onDone)
.buttonStyle(.modalAction(.primary, fillsWidth: false))
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, Theme.Spacing.xxl)
.padding(.vertical, Theme.Spacing.xl)
}
.frame(width: Theme.Size.aiProvidersPanel.width)
.settingsEditorPanelSurface(controlsOnGlass: false)
.releasesFocusOnOutsideClick()
.settingsEditorPanel(item: $editor) { target in
AIConnectionEditorPanel(
target: target,
onSave: saveConnection,
onCancel: { editor = nil })
}
.confirmationDialog(
pendingRemoval.map { "Remove “\($0.title)”?" } ?? "Remove connection?",
isPresented: removalPresented,
titleVisibility: .visible
) {
Button("Remove Connection", role: .destructive) {
if let pendingRemoval { removeConnection(pendingRemoval) }
}
Button("Cancel", role: .cancel) { pendingRemoval = nil }
} message: {
Text("Its saved API key will also be deleted from Keychain.")
}
.onAppear {
selection = selection ?? initialSelection
loadKeyStatuses()
core.applyInstalledAILifecycle()
}
.onChange(of: selection) { modelQuery = "" }
.onChange(of: settings.connections.map(\.id)) { _, ids in
if case .api(let id) = selection, !ids.contains(id) { selection = .installed(.codex) }
}
}
// MARK: - List
private var list: some View {
VStack(spacing: 0) {
List(selection: $selection) {
Section("On This Mac") {
listRow(.appleIntelligence)
}
Section("Installed") {
ForEach(InstalledAIKind.allCases) { listRow(.installed($0)) }
}
Section("API Connections") {
if settings.connections.isEmpty {
Text("None yet")
.foregroundStyle(.secondary)
.selectionDisabled()
}
ForEach(settings.connections) { listRow(.api($0.id)) }
}
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
Divider()
listActions
}
}
private func listRow(_ route: AIProviderRoute) -> some View {
HStack(spacing: Theme.Spacing.lg) {
AIProviderTile(icon: icon(for: route))
VStack(alignment: .leading, spacing: Theme.Spacing.xxs) {
Text(title(for: route))
.lineLimit(1)
Text(caption(for: route))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.tag(route)
}
/// Mail's Accounts list: add below the list, and remove whatever it has selected.
private var listActions: some View {
HStack(spacing: Theme.Spacing.xs) {
Menu {
ForEach(AIProviderKind.allCases) { provider in
Button(provider.title) {
editor = AIConnectionEditorTarget(
connection: AIConnection(provider: provider),
hasStoredKey: false, isNew: true)
}
}
} label: {
Image(systemName: "plus")
}
.menuStyle(.button)
.buttonStyle(.borderless)
.menuIndicator(.hidden)
.fixedSize()
.help("Add API Connection")
.accessibilityLabel("Add API Connection")
Button {
pendingRemoval = selectedConnection
} label: {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
.disabled(selectedConnection == nil)
.help("Remove API Connection")
.accessibilityLabel("Remove API Connection")
Spacer(minLength: 0)
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.vertical, Theme.Spacing.md)
}
// MARK: - Detail
@ViewBuilder
private var detail: some View {
switch selection {
case .appleIntelligence?:
detailForm(.appleIntelligence) { _ in appleIntelligenceSections }
case .installed(let kind)?:
detailForm(.installed(kind)) { installedSections(kind, tab: $0) }
case .api(let id)?:
if let connection = settings.connection(id: id) {
detailForm(.api(id)) { connectionSections(connection, tab: $0) }
}
case nil:
ContentUnavailableView("Select a provider", systemImage: "sparkles")
}
}
private func detailForm<Content: View>(
_ route: AIProviderRoute, @ViewBuilder content: (AIProviderTab) -> Content
) -> some View {
let tabs = AIProviderTab.tabs(for: route)
let shown = tabs.contains(tab) ? tab : .overview
return VStack(spacing: 0) {
detailHeader(route)
.padding(.horizontal, Theme.Spacing.xxl)
.padding(.top, Theme.Spacing.xl)
if tabs.count > 1 {
Picker("Page", selection: $tab) {
ForEach(tabs) { Text($0.title).tag($0) }
}
.labelsHidden()
.pickerStyle(.segmented)
.fixedSize()
.padding(.top, Theme.Spacing.xl)
}
Form { content(shown) }
.formStyle(.grouped)
.scrollContentBackground(.hidden)
}
.id(route)
}
private func detailHeader(_ route: AIProviderRoute) -> some View {
HStack(spacing: Theme.Spacing.xl) {
AIProviderTile(icon: icon(for: route), size: Theme.Size.settingsRowIcon * 1.5)
VStack(alignment: .leading, spacing: Theme.Spacing.xxs) {
Text(title(for: route))
.font(Theme.Typography.panelTitle)
.lineLimit(1)
Text(kindCaption(for: route))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: Theme.Spacing.lg)
switch route {
case .installed(let kind):
if let version = installedAI.status(for: kind).version,
settings.enabledInstalledProviders.contains(kind)
{
Text("v" + version)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
case .api(let id):
if let connection = settings.connection(id: id) {
Button("Edit…") { edit(connection) }
}
case .appleIntelligence:
EmptyView()
}
routeToggle(route)
}
}
// MARK: On this Mac
@ViewBuilder
private var appleIntelligenceSections: some View {
let available = settings.isAppleIntelligenceAvailable()
if !settings.isRouteEnabled(.appleIntelligence) { turnedOffSection() }
Section {
LabeledContent {
Text(available ? "Ready" : "Unavailable")
.foregroundStyle(.secondary)
} label: {
Text(AppleIntelligence.title)
Text(
available
? "Runs on this Mac. Nothing leaves it."
: AppleIntelligenceProvider.status().message ?? "Not available on this Mac.")
}
} header: {
Text("Status")
} footer: {
Text("Choose the default model on the AI pane.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: Installed commands
@ViewBuilder
private func installedSections(_ kind: InstalledAIKind, tab: AIProviderTab) -> some View {
let isOn = settings.enabledInstalledProviders.contains(kind)
switch tab {
case .advanced:
AIProviderAdvancedSection(kind: kind, detected: isOn ? executable(for: kind) : nil)
case .overview, .models:
if !isOn {
turnedOffSection(footer: installedFooter(kind))
} else if tab == .models {
modelsSection(route: .installed(kind), models: installedModels(kind))
} else {
Section {
if kind == .codex {
codexStatusRows
} else {
installedStatusRows(kind)
}
} header: {
Text("Status")
} footer: {
Text(installedFooter(kind))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func turnedOffSection(footer: String? = nil) -> some View {
Section {
LabeledContent {
EmptyView()
} label: {
Label("Turned off", systemImage: "pause.circle")
Text("Tinycast leaves it alone, and its models stay out of every model picker.")
}
} footer: {
if let footer {
Text(footer)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var codexStatusRows: some View {
switch subscription.phase {
case .idle, .starting:
checkingRow("Codex")
case .signedOut:
signInRow(.codex, check: { subscription.refresh() })
case .connected:
LabeledContent {
Button("Refresh") { subscription.refresh() }
} label: {
Text("Ready")
Text(modelCount(subscription.models.count) + " available")
}
if let account = subscription.account {
LabeledContent {
Text(account.planTitle == "API key" ? "Codex API key" : "ChatGPT \(account.planTitle)")
.foregroundStyle(.secondary)
} label: {
Text("Account")
if let email = account.email {
RedactedText(
value: email,
revealHelp: "Click to reveal the signed-in account",
hideHelp: "Click to hide the signed-in account")
}
}
}
if let limits = subscription.rateLimits {
if let primary = limits.primary { usageRow(primary, fallbackTitle: "Primary window") }
if let secondary = limits.secondary {
usageRow(secondary, fallbackTitle: "Secondary window")
}
}
if let executable = subscription.executable { commandRow(executable) }
case .unavailable(let message):
LabeledContent {
HStack(spacing: Theme.Spacing.sm) {
Button("Install Codex CLI…") { NSWorkspace.shared.open(InstalledAIKind.codex.installURL) }
Button("Check Again") { subscription.refresh() }
}
.fixedSize()
} label: {
Text("Not installed")
Text(message)
}
case .failed(let message):
failedRow(message, retry: { subscription.refresh() })
}
}
@ViewBuilder
private func installedStatusRows(_ kind: InstalledAIKind) -> some View {
let status = installedAI.status(for: kind)
switch status.phase {
case .idle, .checking:
checkingRow(kind.title)
case .ready:
LabeledContent {
Button("Refresh") { installedAI.refresh(kind: kind) }
} label: {
Text("Ready")
Text(modelCount(status.models.count) + " available")
}
if let account = status.account { accountRow(account, kind: kind) }
case .signInRequired:
signInRow(kind, check: { installedAI.refresh(kind: kind) })
case .notInstalled:
LabeledContent {
HStack(spacing: Theme.Spacing.sm) {
Button("Install…") { NSWorkspace.shared.open(kind.installURL) }
Button("Check Again") { installedAI.refresh(kind: kind) }
}
.fixedSize()
} label: {
Text("Not installed")
Text("Tinycast could not find the \(kind.command) command.")
}
case .failed(let message):
failedRow(message, retry: { installedAI.refresh(kind: kind) })
}
if let executable = status.executable { commandRow(executable) }
}
private func commandRow(_ executable: URL) -> some View {
LabeledContent("Command") {
Text((executable.path as NSString).abbreviatingWithTildeInPath)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}
private func accountRow(_ account: InstalledAIAccount, kind: InstalledAIKind) -> some View {
LabeledContent {
if let plan = account.planTitle {
Text("\(kind.title) \(plan)").foregroundStyle(.secondary)
}
} label: {
Text("Account")
if let email = account.email {
RedactedText(
value: email,
revealHelp: "Click to reveal the signed-in account",
hideHelp: "Click to hide the signed-in account")
}
}
}
private func executable(for kind: InstalledAIKind) -> URL? {
kind == .codex ? subscription.executable : installedAI.status(for: kind).executable
}
private func checkingRow(_ title: String) -> some View {
HStack(spacing: Theme.Spacing.md) {
ProgressView().controlSize(.small)
Text("Checking \(title)…").foregroundStyle(.secondary)
}
}
private func signInRow(_ kind: InstalledAIKind, check: @escaping () -> Void) -> some View {
LabeledContent {
HStack(spacing: Theme.Spacing.sm) {
Button("Copy Sign-In Command") { copySignInCommand(kind) }
Button("Check Again", action: check)
}
.fixedSize()
} label: {
Text("Sign in required")
Text("Run \(kind.signInCommand) in Terminal, then check again.")
}
}
private func failedRow(_ message: String, retry: @escaping () -> Void) -> some View {
LabeledContent {
Button("Try Again", action: retry)
} label: {
Label("Check failed", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
}
}
private func usageRow(
_ window: ChatGPTSubscription.UsageWindow, fallbackTitle: String
) -> some View {
LabeledContent {
HStack(spacing: Theme.Spacing.md) {
ProgressView(value: Double(window.remainingPercent), total: 100)
.frame(width: Theme.Size.aiUsageBar)
Text("\(window.remainingPercent)% left")
.monospacedDigit()
}
} label: {
Text(usageTitle(window, fallback: fallbackTitle))
if let reset = window.resetsAt {
Text("Resets \(reset, style: .relative)")
}
}
}
private func installedFooter(_ kind: InstalledAIKind) -> String {
kind.isolationCaveat(hasManagedMCPPolicy: InstalledAIManager.hasManagedMCPPolicy)
?? "Uses the \(kind.command) command signed in on this Mac. Its keys are never stored."
}
private func installedModels(_ kind: InstalledAIKind) -> [ProviderModel] {
if kind == .codex {
guard subscription.isConnected else { return [] }
return subscription.models.map { ProviderModel(id: $0.id, name: $0.name) }
}
let status = installedAI.status(for: kind)
guard status.isReady else { return [] }
return status.models.map { ProviderModel(id: $0.id, name: $0.name) }
}
// MARK: API connections
@ViewBuilder
private func connectionSections(
_ connection: AIConnection, tab: AIProviderTab
) -> some View {
if !settings.isRouteEnabled(.api(connection.id)) { turnedOffSection() }
if tab == .models {
modelsSection(
route: .api(connection.id),
models: connection.models.map { ProviderModel(id: $0, name: $0) })
} else {
connectionSection(connection)
}
}
private func connectionSection(_ connection: AIConnection) -> some View {
Section {
LabeledContent("Provider", value: connection.provider.title)
LabeledContent("Base URL") {
Text(connection.baseURL)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
LabeledContent("API key") {
Text(keyStatus(connection))
.foregroundStyle(
keyIsMissing(connection) ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary))
}
if keyError {
Label("The login Keychain could not be accessed.", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} header: {
Text("Connection")
} footer: {
Text("Keys stay in your login Keychain, tied to the endpoint they were saved for.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: Models
@ViewBuilder
private func modelsSection(route: AIProviderRoute, models: [ProviderModel]) -> some View {
if models.isEmpty {
Section {
Text("Models are listed here once \(title(for: route)) is ready.")
.foregroundStyle(.secondary)
}
} else {
let source = route.source
let shownSet = settings.shownModels[source.storageKey].map(Set.init)
let shownCount = shownSet.map { set in models.count { set.contains($0.id) } } ?? models.count
let matches = filtered(models)
Section {
LabeledContent {
HStack(spacing: Theme.Spacing.sm) {
Button("Show All") { settings.showAllModels(in: source) }
.disabled(shownCount == models.count)
Button("Hide All") { settings.hideAllModels(in: source) }
}
.fixedSize()
} label: {
Text("\(shownCount) of \(modelCount(models.count)) in the model picker")
}
if models.count > Self.filterThreshold {
SettingsFilterField(prompt: "Filter models", query: $modelQuery)
}
if matches.isEmpty {
Text("No model matches “\(modelQuery)”.")
.foregroundStyle(.secondary)
}
if !matches.isEmpty {
AIModelChecklist(
items: matches.map { model in
AIModelChecklist.Item(
id: model.id, title: model.name,
isOn: shownSet?.contains(model.id) ?? true,
isLocked: isDefault(model.id, route: route))
},
onToggle: { id, isOn in
settings.setModel(
id, shown: isOn, in: source, available: models.map(\.id))
})
}
} footer: {
Text(
"Ticked models appear in the model picker. The default model always does; "
+ "choose it on the AI pane."
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// Past a screenful, OpenCode alone lists hundreds, so the list gets the Settings filter row.
private static let filterThreshold = 8
private func filtered(_ models: [ProviderModel]) -> [ProviderModel] {
let query = modelQuery.trimmingCharacters(in: .whitespaces)
guard !query.isEmpty else { return models }
return models.filter {
$0.name.localizedCaseInsensitiveContains(query)
|| $0.id.localizedCaseInsensitiveContains(query)
}
}
private func isDefault(_ model: String, route: AIProviderRoute) -> Bool {
guard let selection = settings.defaultModel else { return false }
return selection.source == route.source && selection.model == model
}
// MARK: - Titles and glyphs
private func title(for route: AIProviderRoute) -> String {
switch route {
case .appleIntelligence: return AppleIntelligence.title
case .installed(let kind): return kind.title
case .api(let id): return settings.connection(id: id)?.title ?? "API Connection"
}
}
private func kindCaption(for route: AIProviderRoute) -> String {
switch route {
case .appleIntelligence: return "On this Mac · No account needed"
case .installed(let kind): return "Installed command · \(kind.command)"
case .api(let id):
return "API connection · " + (settings.connection(id: id)?.provider.title ?? "")
}
}
/// The one line a reader scans the list for: is it usable, and if not, why.
private func caption(for route: AIProviderRoute) -> String {
switch route {
case .appleIntelligence:
guard settings.isRouteEnabled(.appleIntelligence) else { return "Off" }
return settings.isAppleIntelligenceAvailable() ? "Ready · Runs on this Mac" : "Unavailable"
case .installed(let kind):
guard settings.enabledInstalledProviders.contains(kind) else { return "Off" }
return kind == .codex ? codexCaption : installedCaption(kind)
case .api(let id):
guard let connection = settings.connection(id: id) else { return "" }
guard settings.isRouteEnabled(.api(id)) else { return "Off" }
if keyIsMissing(connection) { return "Key missing" }
let count = modelCount(connection.models.count)
return connection.name.isEmpty ? count : "\(connection.provider.title) · \(count)"
}
}
private var codexCaption: String {
switch subscription.phase {
case .idle, .starting: return "Checking…"
case .signedOut: return "Sign in required"
case .unavailable: return "Not installed"
case .failed: return "Check failed"
case .connected:
let count = modelCount(subscription.models.count)
guard let account = subscription.account else { return count }
let plan = account.planTitle == "API key" ? "API key" : "ChatGPT \(account.planTitle)"
return "\(plan) · \(count)"
}
}
private func installedCaption(_ kind: InstalledAIKind) -> String {
let status = installedAI.status(for: kind)
switch status.phase {
case .idle, .checking: return "Checking…"
case .ready: return "Ready · " + modelCount(status.models.count)
case .signInRequired: return "Sign in required"
case .notInstalled: return "Not installed"
case .failed: return "Check failed"
}
}
private func icon(for route: AIProviderRoute) -> PopoverMenuIcon {
switch route {
case .appleIntelligence: return AIModelOption.appleIntelligenceIcon
case .installed(.codex): return .asset(AIBrand.openAI.assetName)
case .installed(.claude): return .asset(AIBrand.claude.assetName)
case .installed(.grok): return .asset(AIBrand.grok.assetName)
case .installed(.openCode): return .asset(AIBrand.openCode.assetName)
case .installed(.cursor): return AIModelOption.cursorIcon
case .api(let id):
guard let connection = settings.connection(id: id) else { return .symbol("sparkles") }
// OpenRouter is its own brand; resolving by model would show whichever vendor came first.
if connection.provider == .openRouter { return .asset(AIBrand.openRouter.assetName) }
return AIModelOption.icon(
AIBrand.resolve(provider: connection.provider, model: connection.models.first ?? ""))
}
}
private func modelCount(_ count: Int) -> String {
count == 1 ? "1 model" : "\(count) models"
}
// MARK: - Actions
/// Opens on whichever route the default model uses, since that is the one most often checked.
private var initialSelection: AIProviderRoute {
switch settings.defaultModel?.source {
case .appleIntelligence?: return .appleIntelligence
case .api(let id)?: return .api(id)
case let source?:
return source.installedKind.map(AIProviderRoute.installed) ?? .installed(.codex)
case nil: return .installed(.codex)
}
}
private var selectedConnection: AIConnection? {
guard case .api(let id) = selection else { return nil }
return settings.connection(id: id)
}
private func routeToggle(_ route: AIProviderRoute) -> some View {
Toggle(
"Enable \(title(for: route))",
isOn: Binding(
get: { settings.isRouteEnabled(route.source) },
set: { settings.setRoute(route.source, enabled: $0) })
)
.labelsHidden()
.toggleStyle(.switch)
}
private var removalPresented: Binding<Bool> {
Binding(
get: { pendingRemoval != nil },
set: { if !$0 { pendingRemoval = nil } })
}
private func keyIsMissing(_ connection: AIConnection) -> Bool {
keyStatuses[connection.id] != true && !AIEndpointPolicy.isLoopback(connection.baseURL)
}
private func keyStatus(_ connection: AIConnection) -> String {
if keyStatuses[connection.id] == true { return "Stored in Keychain" }
return AIEndpointPolicy.isLoopback(connection.baseURL) ? "None needed locally" : "Missing"
}
private func edit(_ connection: AIConnection) {
editor = AIConnectionEditorTarget(
connection: connection,
hasStoredKey: keyStatuses[connection.id] == true,
isNew: false)
}
private func saveConnection(
_ connection: AIConnection, key: String, isNew: Bool
) -> String? {
let outcome = AIConnectionKeyPolicy.resolve(
enteredKey: key, connection: connection, saved: settings.connection(id: connection.id),
hasStoredKey: keyStatuses[connection.id] == true)
do {
switch outcome {
case .store(let key): try keyStore.setSecret(key, for: connection.id)
case .removeStored: try keyStore.removeSecret(for: connection.id)
case .keep: break
case .reject(let message): return message
}
settings.save(connection)
editor = nil
selection = .api(connection.id)
loadKeyStatuses()
settings.reconcile(subscription: subscription, installedAI: installedAI)
return nil
} catch {
keyError = true
return isNew
? "The key could not be saved to Keychain."
: "The saved key could not be updated in Keychain."
}
}
private func removeConnection(_ connection: AIConnection) {
do {
try keyStore.removeSecret(for: connection.id)
settings.removeConnection(id: connection.id)
pendingRemoval = nil
loadKeyStatuses()
} catch {
keyError = true
}
}
private func copySignInCommand(_ kind: InstalledAIKind) {
Paster.copyPlainText(kind.signInCommand)
core.showMessage("Copied \(kind.signInCommand)")
}
private func usageTitle(
_ window: ChatGPTSubscription.UsageWindow, fallback: String
) -> String {
guard let minutes = window.durationMinutes else { return fallback }
if minutes >= 1_440 { return "\(minutes / 1_440)-day window" }
if minutes >= 60 { return "\(minutes / 60)-hour window" }
return "\(minutes)-minute window"
}
private func loadKeyStatuses() {
var statuses: [UUID: Bool] = [:]
do {
for connection in settings.connections {
statuses[connection.id] = try keyStore.hasSecret(for: connection.id)
}
keyStatuses = statuses
keyError = false
} catch {
keyStatuses = statuses
keyError = true
}
}
}
/// A model row's content, whichever of the three catalogue shapes it came from.
private struct ProviderModel: Identifiable {
let id: String
let name: String
}
/// The list and header glyph: a brand mark or symbol on the tile `SettingsTabIcon` draws.
private struct AIProviderTile: View {
let icon: PopoverMenuIcon
var size = Theme.Size.settingsSidebarGlyph + Theme.Spacing.xs * 2
var body: some View {
let scale = size / (Theme.Size.settingsSidebarGlyph + Theme.Spacing.xs * 2)
glyph
.frame(
width: Theme.Size.settingsSidebarGlyph * scale,
height: Theme.Size.settingsSidebarGlyph * scale
)
.foregroundStyle(.primary)
.padding(Theme.Spacing.xs * scale)
.background(
Theme.Colors.controlSurface,
in: RoundedRectangle(
cornerRadius: Theme.Radius.thumbnail * scale, style: .continuous)
)
.accessibilityHidden(true)
}
@ViewBuilder
private var glyph: some View {
switch icon {
case .asset(let name):
Image(name).resizable().renderingMode(.template).scaledToFit()
case .symbol(let name):
Image(systemName: name).resizable().scaledToFit()
case .file, .thumbnail, .blank:
Image(systemName: "sparkles").resizable().scaledToFit()
}
}
}
extension AISettingsStore {
/// Moves the default model off any route that just went away, or onto its current catalogue.
func reconcile(subscription: ChatGPTSubscriptionManager, installedAI: InstalledAIManager) {
let enabled = enabledInstalledProviders
reconcile(
codexModels: enabled.contains(.codex) ? subscription.models : [],
isUnavailable: !enabled.contains(.codex) || subscription.phase == .signedOut
|| subscription.phase.isUnavailable)
for kind in InstalledAIKind.managedCLIKinds {
let status = installedAI.status(for: kind)
reconcile(
installed: kind,
models: enabled.contains(kind) ? status.models : [],
isUnavailable: !enabled.contains(kind) || status.phase == .signInRequired
|| status.phase == .notInstalled)
}
}
}
@@ -41,6 +41,16 @@ final class AISettingsStore {
var toolRounds: AIToolRounds {
didSet { defaults.set(toolRounds.rawValue, forKey: AppSettingsKey.aiToolRounds.rawValue) }
}
/// Per route, the models its picker lists; no entry lists all it offers, later ones too.
private(set) var shownModels: [String: [String]] {
didSet { defaults.set(shownModels, forKey: AppSettingsKey.aiShownModels.rawValue) }
}
/// The on-device model and API connections switched off without being removed.
private(set) var disabledRoutes: Set<String> {
didSet {
defaults.set(disabledRoutes.sorted(), forKey: AppSettingsKey.aiDisabledRoutes.rawValue)
}
}
var enabledInstalledProviders: Set<InstalledAIKind> {
didSet {
guard
@@ -52,16 +62,27 @@ final class AISettingsStore {
defaults.set(data, forKey: AppSettingsKey.aiInstalledProviders.rawValue)
}
}
/// Per installed tool, a command path and variable names; a tool left alone has no entry.
private(set) var installedOverrides: [InstalledAIKind: InstalledAIOverride] {
didSet { persistInstalledOverrides() }
}
/// Bumped by every edit to a tool's launch, a changed value included, which no name shows.
private(set) var launchRevisions: [InstalledAIKind: Int] = [:]
/// Asked each time: the model lands mid-session, and a flag read at launch would never notice.
@ObservationIgnored let isAppleIntelligenceAvailable: @Sendable () -> Bool
@ObservationIgnored private let environmentStore: InstalledAIEnvironmentStore
init(
defaults: UserDefaults = .standard,
environmentStore: InstalledAIEnvironmentStore = .none,
isAppleIntelligenceAvailable: @escaping @Sendable () -> Bool = { false }
) {
self.defaults = defaults
self.environmentStore = environmentStore
self.isAppleIntelligenceAvailable = isAppleIntelligenceAvailable
installedOverrides = Self.decodeInstalledOverrides(
defaults.data(forKey: AppSettingsKey.aiInstalledOverrides.rawValue))
connections = Self.decodeConnections(
defaults.data(forKey: AppSettingsKey.aiConnections.rawValue))
defaultModel = Self.decodeDefaultModel(
@@ -85,6 +106,11 @@ final class AISettingsStore {
toolRounds =
AIToolRounds(rawValue: defaults.integer(forKey: AppSettingsKey.aiToolRounds.rawValue))
?? .twentyFive
shownModels =
defaults.dictionary(forKey: AppSettingsKey.aiShownModels.rawValue) as? [String: [String]]
?? [:]
disabledRoutes = Set(
defaults.stringArray(forKey: AppSettingsKey.aiDisabledRoutes.rawValue) ?? [])
enabledInstalledProviders = Self.decodeEnabledInstalledProviders(
defaults.data(forKey: AppSettingsKey.aiInstalledProviders.rawValue))
if case .api(let connection, let model, _) = defaultModel,
@@ -92,6 +118,9 @@ final class AISettingsStore {
{
defaultModel = firstAvailableSelection()
}
if let source = defaultModel?.source, !isRouteEnabled(source), source.installedKind == nil {
defaultModel = firstAvailableSelection()
}
if defaultModel == nil {
defaultModel = firstAvailableSelection()
}
@@ -137,6 +166,8 @@ final class AISettingsStore {
func removeConnection(id: UUID) {
connections.removeAll { $0.id == id }
shownModels[AIModelSource.api(id).storageKey] = nil
disabledRoutes.remove(AIModelSource.api(id).storageKey)
guard case .api(id, _, _) = defaultModel else { return }
defaultModel = firstAvailableSelection()
}
@@ -203,6 +234,53 @@ final class AISettingsStore {
defaultModel = selection
}
func isRouteEnabled(_ source: AIModelSource) -> Bool {
if let kind = source.installedKind { return enabledInstalledProviders.contains(kind) }
return !disabledRoutes.contains(source.storageKey)
}
/// An installed route keeps its own switch; the others move the default off when switched off.
func setRoute(_ source: AIModelSource, enabled: Bool) {
if let kind = source.installedKind {
setInstalledProviderEnabled(enabled, for: kind)
return
}
if enabled {
disabledRoutes.remove(source.storageKey)
} else {
disabledRoutes.insert(source.storageKey)
if defaultModel?.source == source { defaultModel = firstAvailableSelection() }
}
if defaultModel == nil { defaultModel = firstAvailableSelection() }
}
func isModelShown(_ model: String, in source: AIModelSource) -> Bool {
shownModels[source.storageKey]?.contains(model) ?? true
}
/// `available` is the route's whole list, needed the first time one model is hidden from it.
func setModel(
_ model: String, shown: Bool, in source: AIModelSource, available: [String]
) {
var shownList = shownModels[source.storageKey] ?? available
shownList.removeAll { $0 == model }
if shown { shownList.append(model) }
// All shown again drops the entry, so a model the route adds later appears too.
let everything = Set(available)
shownModels[source.storageKey] =
everything.isSubset(of: shownList) ? nil : shownList.filter(everything.contains)
}
func showAllModels(in source: AIModelSource) {
shownModels[source.storageKey] = nil
}
/// The default model stays listed, since the picker must be able to show what is selected.
func hideAllModels(in source: AIModelSource) {
let kept = defaultModel.flatMap { $0.source == source ? [$0.model] : nil } ?? []
shownModels[source.storageKey] = kept
}
func setInstalledProviderEnabled(_ enabled: Bool, for kind: InstalledAIKind) {
var providers = enabledInstalledProviders
if enabled {
@@ -213,6 +291,53 @@ final class AISettingsStore {
enabledInstalledProviders = providers
}
func override(for kind: InstalledAIKind) -> InstalledAIOverride {
installedOverrides[kind] ?? InstalledAIOverride()
}
func setCommandPath(_ path: String, for kind: InstalledAIKind) {
var override = override(for: kind)
override.commandPath = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard override != self.override(for: kind) else { return }
installedOverrides[kind] = override.isEmpty ? nil : override
launchRevisions[kind, default: 0] += 1
}
func environment(for kind: InstalledAIKind) throws -> [InstalledAIVariable] {
let values = try environmentStore.values(kind)
return override(for: kind).environmentNames.map {
InstalledAIVariable(name: $0, value: values[$0] ?? "")
}
}
/// Values are saved first: names without their values would launch the tool half configured.
func setEnvironment(_ variables: [InstalledAIVariable], for kind: InstalledAIKind) throws {
var seen = Set<String>()
let kept = variables.filter {
InstalledAILaunch.isVariableName($0.name) && seen.insert($0.name).inserted
}
guard try kept != environment(for: kind) else { return }
try environmentStore.save(
Dictionary(uniqueKeysWithValues: kept.map { ($0.name, $0.value) }), kind)
var override = override(for: kind)
override.environmentNames = kept.map(\.name)
installedOverrides[kind] = override.isEmpty ? nil : override
launchRevisions[kind, default: 0] += 1
}
/// Reads the Keychain only for a tool that has variables, so most launches never touch it.
func launch(for kind: InstalledAIKind) -> InstalledAILaunch {
let override = override(for: kind)
guard !override.environmentNames.isEmpty else {
return InstalledAILaunch(commandPath: override.commandPath)
}
let names = Set(override.environmentNames)
let values = (try? environmentStore.values(kind)) ?? [:]
return InstalledAILaunch(
commandPath: override.commandPath,
environment: values.filter { names.contains($0.key) })
}
func disableInstalledModelSelection(for kind: InstalledAIKind) {
guard let source = defaultModel?.source else { return }
let matches =
@@ -228,8 +353,10 @@ final class AISettingsStore {
/// The on-device model leads: free, private, always configured, so never a surprising landing.
private func firstAvailableSelection() -> AIModelSelection? {
if isAppleIntelligenceAvailable() { return .appleIntelligence }
for connection in connections {
if isAppleIntelligenceAvailable(), isRouteEnabled(.appleIntelligence) {
return .appleIntelligence
}
for connection in connections where isRouteEnabled(.api(connection.id)) {
if let model = connection.models.first {
return .api(
connection: connection.id, model: model,
@@ -244,6 +371,28 @@ final class AISettingsStore {
defaults.set(data, forKey: AppSettingsKey.aiConnections.rawValue)
}
private func persistInstalledOverrides() {
let keyed = Dictionary(
uniqueKeysWithValues: installedOverrides.map { ($0.key.rawValue, $0.value) })
guard !keyed.isEmpty, let data = try? JSONEncoder().encode(keyed) else {
defaults.removeObject(forKey: AppSettingsKey.aiInstalledOverrides.rawValue)
return
}
defaults.set(data, forKey: AppSettingsKey.aiInstalledOverrides.rawValue)
}
private static func decodeInstalledOverrides(
_ data: Data?
) -> [InstalledAIKind: InstalledAIOverride] {
guard let data,
let keyed = try? JSONDecoder().decode([String: InstalledAIOverride].self, from: data)
else { return [:] }
return Dictionary(
uniqueKeysWithValues: keyed.compactMap { key, value in
InstalledAIKind(rawValue: key).map { ($0, value) }
})
}
private func persistDefaultModel() {
guard let defaultModel, let data = try? JSONEncoder().encode(defaultModel) else {
defaults.removeObject(forKey: AppSettingsKey.aiDefaultModel.rawValue)
@@ -1,4 +1,3 @@
import AppKit
import SwiftUI
struct AISettingsView: View {
@@ -9,12 +8,6 @@ struct AISettingsView: View {
@Environment(InstalledAIManager.self) private var installedAI
@State private var providersPresented = false
@State private var keyStatuses: [UUID: Bool] = [:]
@State private var keyError = false
@State private var editor: AIConnectionEditorTarget?
@State private var pendingRemoval: AIConnection?
private let keyStore = KeychainSecretStore.aiAPIKeys
var body: some View {
@Bindable var appSettings = appSettings
@@ -50,7 +43,7 @@ struct AISettingsView: View {
.formStyle(.grouped)
.settingsScrollTarget(.ai)
.settingsEditorPanel(isPresented: $providersPresented) {
providersPanel
AIProvidersPanel(onDone: { providersPresented = false })
}
.onAppear {
core.applyInstalledAILifecycle()
@@ -120,6 +113,10 @@ struct AISettingsView: View {
return providers.isEmpty ? "No external providers ready" : providers.joined(separator: ", ")
}
private func syncSelection() {
settings.reconcile(subscription: subscription, installedAI: installedAI)
}
private var chatSection: some View {
@Bindable var settings = settings
return Section {
@@ -187,453 +184,4 @@ struct AISettingsView: View {
.foregroundStyle(.secondary)
}
}
private var providersPanel: some View {
@Bindable var settings = settings
return VStack(alignment: .leading, spacing: 0) {
SettingsEditorHeader(
title: "AI Providers",
subtitle: "Use an installed account or connect an API endpoint."
)
.padding(.horizontal, Theme.Spacing.dialogInset)
.padding(.top, Theme.Spacing.dialogInset)
Form {
installedAISection
apiConnectionsSection
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
HStack(spacing: Theme.Spacing.md) {
Button("Done") { providersPresented = false }
.buttonStyle(.modalAction(.primary))
.keyboardShortcut(.defaultAction)
}
.padding(Theme.Spacing.dialogInset)
}
.frame(width: Theme.Size.editorSheetWidth, height: 600)
.settingsEditorPanelSurface()
.settingsEditorPanel(item: $editor) { target in
AIConnectionEditorPanel(
target: target,
onSave: saveConnection,
onCancel: { editor = nil })
}
.confirmationDialog(
pendingRemoval.map { "Remove “\($0.title)”?" } ?? "Remove connection?",
isPresented: removalPresented,
titleVisibility: .visible
) {
Button("Remove Connection", role: .destructive) {
if let pendingRemoval { removeConnection(pendingRemoval) }
}
Button("Cancel", role: .cancel) { pendingRemoval = nil }
} message: {
Text("Its saved API key will also be deleted from Keychain.")
}
.onAppear {
loadKeyStatuses()
core.applyInstalledAILifecycle()
}
}
private var installedAISection: some View {
Section {
codexConnection
if let limits = subscription.rateLimits, subscription.isConnected {
if let primary = limits.primary {
quotaRow(primary, fallbackTitle: "Primary window")
}
if let secondary = limits.secondary {
quotaRow(secondary, fallbackTitle: "Secondary window")
}
}
installedConnection(.claude)
installedConnection(.grok)
installedConnection(.openCode)
installedConnection(.cursor)
} header: {
SettingsSectionHeader(.aiInstalledAI)
} footer: {
Text("Uses the command-line tools signed in on this Mac. Their keys are never stored.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var codexConnection: some View {
if settings.enabledInstalledProviders.contains(.codex) {
switch subscription.phase {
case .starting:
LabeledContent {
providerActions { providerToggle(.codex) }
} label: {
HStack {
ProgressView().controlSize(.small)
Text("Checking Codex…").foregroundStyle(.secondary)
}
}
case .idle, .signedOut:
LabeledContent {
providerActions {
Button("Copy Sign-In Command") { copySignInCommand(.codex) }
Button("Check Again") { subscription.refresh() }
providerToggle(.codex)
}
} label: {
Text("Codex · Sign in required")
Text("Run codex login in Terminal, then check again.")
}
case .connected:
if let account = subscription.account {
LabeledContent {
providerActions {
Button("Refresh") { subscription.refresh() }
providerToggle(.codex)
}
} label: {
if let email = account.email {
RedactedText(
value: email,
revealHelp: "Click to reveal the signed-in account",
hideHelp: "Click to hide the signed-in account")
} else {
Text("Codex · Ready")
}
Text(
account.planTitle == "API key" ? "Codex API key" : "ChatGPT \(account.planTitle)")
}
}
case .unavailable(let message):
LabeledContent {
providerActions {
Button("Install Codex CLI…") {
if let url = URL(string: "https://developers.openai.com/codex/cli") {
NSWorkspace.shared.open(url)
}
}
Button("Check Again") { subscription.refresh() }
providerToggle(.codex)
}
} label: {
Text("Codex · Not installed")
Text(message)
}
case .failed(let message):
LabeledContent {
providerActions {
Button("Try Again") { subscription.refresh() }
providerToggle(.codex)
}
} label: {
Label("Codex check failed", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
}
}
} else {
disabledProvider(.codex)
}
}
@ViewBuilder
private func installedConnection(_ kind: InstalledAIKind) -> some View {
let status = installedAI.status(for: kind)
if settings.enabledInstalledProviders.contains(kind) {
switch status.phase {
case .idle, .checking:
LabeledContent {
providerActions { providerToggle(kind) }
} label: {
HStack {
ProgressView().controlSize(.small)
Text("Checking \(kind.title)…").foregroundStyle(.secondary)
}
}
case .ready:
LabeledContent {
providerActions {
Button("Refresh") {
installedAI.refresh(kind: kind)
}
providerToggle(kind)
}
} label: {
Text("\(kind.title) · Ready")
Text(readyDetail(kind, status))
}
case .signInRequired:
LabeledContent {
providerActions {
Button("Copy Sign-In Command") { copySignInCommand(kind) }
Button("Check Again") {
installedAI.refresh(kind: kind)
}
providerToggle(kind)
}
} label: {
Text("\(kind.title) · Sign in required")
Text("Run \(kind.signInCommand) in Terminal, then check again.")
}
case .notInstalled:
LabeledContent {
providerActions {
Button("Install…") { NSWorkspace.shared.open(kind.installURL) }
Button("Check Again") {
installedAI.refresh(kind: kind)
}
providerToggle(kind)
}
} label: {
Text("\(kind.title) · Not installed")
Text("Tinycast could not find the \(kind.command) command.")
}
case .failed(let message):
LabeledContent {
providerActions {
Button("Try Again") {
installedAI.refresh(kind: kind)
}
providerToggle(kind)
}
} label: {
Label("\(kind.title) check failed", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
}
}
} else {
disabledProvider(kind)
}
}
private func disabledProvider(_ kind: InstalledAIKind) -> some View {
LabeledContent {
providerActions { providerToggle(kind) }
} label: {
Text(kind.title)
Text("Disabled")
}
}
private func providerActions<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
HStack(spacing: Theme.Spacing.sm) {
content()
}
.fixedSize(horizontal: true, vertical: false)
}
private func providerToggle(_ kind: InstalledAIKind) -> some View {
Toggle(
"Enable \(kind.title)",
isOn: Binding(
get: { settings.enabledInstalledProviders.contains(kind) },
set: { settings.setInstalledProviderEnabled($0, for: kind) })
)
.labelsHidden()
.toggleStyle(.switch)
}
private var apiConnectionsSection: some View {
Section {
if settings.connections.isEmpty {
Text("No API connections yet.")
.foregroundStyle(.secondary)
} else {
ForEach(settings.connections) { connection in
AIConnectionRow(
connection: connection,
hasStoredKey: keyStatuses[connection.id] == true,
onEdit: { edit(connection) },
onRemove: { pendingRemoval = connection })
}
}
Button {
editor = AIConnectionEditorTarget(
connection: AIConnection(), hasStoredKey: false, isNew: true)
} label: {
Label {
SettingsRowTitle(.aiAPIConnections, "Add API Connection")
} icon: {
Image(systemName: "plus")
}
}
if keyError {
Label("The login Keychain could not be accessed.", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} header: {
SettingsSectionHeader(.aiAPIConnections)
} footer: {
Text("Presets or any OpenAI-compatible endpoint. Keys stay in your login Keychain.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var removalPresented: Binding<Bool> {
Binding(
get: { pendingRemoval != nil },
set: { if !$0 { pendingRemoval = nil } })
}
private func syncSelection() {
let enabledProviders = settings.enabledInstalledProviders
settings.reconcile(
codexModels: enabledProviders.contains(.codex) ? subscription.models : [],
isUnavailable: !enabledProviders.contains(.codex) || subscription.phase == .signedOut
|| subscription.phase.isUnavailable)
for kind in InstalledAIKind.managedCLIKinds {
let status = installedAI.status(for: kind)
settings.reconcile(
installed: kind,
models: enabledProviders.contains(kind) ? status.models : [],
isUnavailable: !enabledProviders.contains(kind) || status.phase == .signInRequired
|| status.phase == .notInstalled)
}
}
private func quotaRow(
_ window: ChatGPTSubscription.UsageWindow, fallbackTitle: String
) -> some View {
LabeledContent(quotaTitle(window, fallback: fallbackTitle)) {
VStack(alignment: .trailing, spacing: Theme.Spacing.xxs) {
Text("\(window.remainingPercent)% left")
if let reset = window.resetsAt {
Text("Resets \(reset, style: .relative)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func quotaTitle(
_ window: ChatGPTSubscription.UsageWindow, fallback: String
) -> String {
guard let minutes = window.durationMinutes else { return fallback }
if minutes >= 1_440 { return "\(minutes / 1_440)-day window" }
if minutes >= 60 { return "\(minutes / 60)-hour window" }
return "\(minutes)-minute window"
}
private func edit(_ connection: AIConnection) {
editor = AIConnectionEditorTarget(
connection: connection,
hasStoredKey: keyStatuses[connection.id] == true,
isNew: false)
}
private func saveConnection(
_ connection: AIConnection, key: String, isNew: Bool
) -> String? {
let outcome = AIConnectionKeyPolicy.resolve(
enteredKey: key, connection: connection, saved: settings.connection(id: connection.id),
hasStoredKey: keyStatuses[connection.id] == true)
do {
switch outcome {
case .store(let key): try keyStore.setSecret(key, for: connection.id)
case .removeStored: try keyStore.removeSecret(for: connection.id)
case .keep: break
case .reject(let message): return message
}
settings.save(connection)
editor = nil
loadKeyStatuses()
syncSelection()
return nil
} catch {
keyError = true
return isNew
? "The key could not be saved to Keychain."
: "The saved key could not be updated in Keychain."
}
}
private func removeConnection(_ connection: AIConnection) {
do {
try keyStore.removeSecret(for: connection.id)
settings.removeConnection(id: connection.id)
pendingRemoval = nil
loadKeyStatuses()
} catch {
keyError = true
}
}
private func copySignInCommand(_ kind: InstalledAIKind) {
Paster.copyPlainText(kind.signInCommand)
core.showMessage("Copied \(kind.signInCommand)")
}
private func readyDetail(_ kind: InstalledAIKind, _ status: InstalledAIStatus) -> String {
var parts: [String] = []
if let version = status.version { parts.append("Version " + version) }
parts.append(modelCount(status.models))
if let caveat = kind.isolationCaveat(
hasManagedMCPPolicy: InstalledAIManager.hasManagedMCPPolicy)
{
parts.append(caveat)
}
return parts.joined(separator: " · ")
}
private func modelCount(_ models: [InstalledAIModel]) -> String {
models.count == 1 ? "1 model" : "\(models.count) models"
}
private func loadKeyStatuses() {
var statuses: [UUID: Bool] = [:]
do {
for connection in settings.connections {
statuses[connection.id] = try keyStore.hasSecret(for: connection.id)
}
keyStatuses = statuses
keyError = false
} catch {
keyStatuses = statuses
keyError = true
}
}
}
private struct AIConnectionRow: View {
let connection: AIConnection
let hasStoredKey: Bool
let onEdit: () -> Void
let onRemove: () -> Void
var body: some View {
SettingsRow(
title: connection.title,
subtitle: "\(connection.provider.title) · \(keyStatus) · \(modelCount)"
) {
Image(systemName: "sparkles")
.foregroundStyle(.secondary)
} trailing: {
Button(action: onEdit) { Image(systemName: "pencil") }
.buttonStyle(.plain)
.help("Edit \(connection.title)")
.accessibilityLabel("Edit \(connection.title)")
Button(action: onRemove) {
Image(systemName: "trash").foregroundStyle(.red)
}
.buttonStyle(.plain)
.help("Remove \(connection.title)")
.accessibilityLabel("Remove \(connection.title)")
}
}
private var keyStatus: String {
if AIEndpointPolicy.isLoopback(connection.baseURL), !hasStoredKey { return "No key" }
return hasStoredKey ? "Keychain" : "Key missing"
}
private var modelCount: String {
connection.models.count == 1 ? "1 model" : "\(connection.models.count) models"
}
}
@@ -598,7 +598,7 @@ final class AIChatCoordinator {
case .appleIntelligence?: return AIModelOption.appleIntelligenceIcon
case .codex?: return .asset(AIBrand.openAI.assetName)
case .claude?: return .asset(AIBrand.claude.assetName)
case .grok?: return .asset(AIBrand.x.assetName)
case .grok?: return .asset(AIBrand.grok.assetName)
case .cursor?: return AIModelOption.cursorIcon
case .openCode(let model, _)?: return AIModelOption.icon(AIBrand.resolve(model: model))
case .api(let connection, let model, _)?:
+24 -9
View File
@@ -18,15 +18,30 @@ struct AIModelOption: Identifiable {
let grok = installedAI.status(for: .grok)
let openCode = installedAI.status(for: .openCode)
let cursor = installedAI.status(for: .cursor)
// The default stays listed even when unticked, or the picker could not show what it holds.
func shown(_ model: String, _ source: AIModelSource) -> Bool {
settings.isModelShown(model, in: source)
|| (settings.defaultModel?.source == source && settings.defaultModel?.model == model)
}
func shown(_ models: [InstalledAIModel], _ source: AIModelSource) -> [InstalledAIModel] {
models.filter { shown($0.id, source) }
}
return groupedCatalog(
appleIntelligence: settings.isAppleIntelligenceAvailable(),
appleIntelligence: settings.isAppleIntelligenceAvailable()
&& settings.isRouteEnabled(.appleIntelligence),
codex: enabled.contains(.codex) && subscription.isConnected
? subscription.models : [],
claude: enabled.contains(.claude) && claude.isReady ? claude.models : [],
grok: enabled.contains(.grok) && grok.isReady ? grok.models : [],
openCode: enabled.contains(.openCode) && openCode.isReady ? openCode.models : [],
cursor: enabled.contains(.cursor) && cursor.isReady ? cursor.models : [],
connections: settings.connections)
? subscription.models.filter { shown($0.id, .codex) } : [],
claude: enabled.contains(.claude) && claude.isReady ? shown(claude.models, .claude) : [],
grok: enabled.contains(.grok) && grok.isReady ? shown(grok.models, .grok) : [],
openCode: enabled.contains(.openCode) && openCode.isReady
? shown(openCode.models, .openCode) : [],
cursor: enabled.contains(.cursor) && cursor.isReady ? shown(cursor.models, .cursor) : [],
connections: settings.connections.compactMap { connection in
guard settings.isRouteEnabled(.api(connection.id)) else { return nil }
var trimmed = connection
trimmed.models.removeAll { !shown($0, .api(connection.id)) }
return trimmed
})
}
/// An unrecognised model keeps the generic sparkle rather than borrowing someone's mark.
@@ -34,7 +49,7 @@ struct AIModelOption: Identifiable {
brand.map { .asset($0.assetName) } ?? .symbol("sparkles")
}
static let cursorIcon = PopoverMenuIcon.symbol("cursorarrow.rays")
static let cursorIcon = PopoverMenuIcon.asset(AIBrand.cursor.assetName)
/// Every route the Mac can reach, on-device first: it is the one an unconfigured Mac has.
private static func catalog(
@@ -68,7 +83,7 @@ struct AIModelOption: Identifiable {
let grok = grok.map { model in
AIModelOption(
selection: .grok(model: model.id, effort: nil), title: model.name,
sourceTitle: "Grok", menuIcon: .asset(AIBrand.x.assetName))
sourceTitle: "Grok", menuIcon: .asset(AIBrand.grok.assetName))
}
let openCode = openCode.map { model in
AIModelOption(
@@ -128,6 +128,14 @@ enum SettingsBackupCoverage {
AppSettingsKey.aiToolRounds.rawValue:
"Decides how much a tool-driven reply may spend on this Mac's own connections; no other "
+ "AI setting travels, and an import must not raise a spending limit unasked.",
AppSettingsKey.aiShownModels.rawValue:
"Names the models of this Mac's own installed tools and connections, which another Mac "
+ "may not have.",
AppSettingsKey.aiDisabledRoutes.rawValue:
"Names this Mac's own API connections and on-device model, which travel in no backup.",
AppSettingsKey.aiInstalledOverrides.rawValue:
"Names a command to run and the variables to run it with; an import must never decide "
+ "which program this Mac launches.",
AppSettingsKey.mcpEnabled.rawValue:
"Doubles as consent to run third-party MCP servers, one of which is a local process; a "
+ "flag that grants a capability is never carried by a backup.",
@@ -105,11 +105,10 @@ struct MCPServerEditor: View {
.foregroundStyle(.secondary)
}
field("Connection") {
Picker("Connection", selection: $kind) {
ForEach(Kind.allCases) { Text($0.title).tag($0) }
}
.labelsHidden()
.pickerStyle(.segmented)
SteadySegmentedPicker(
title: "Connection",
options: Kind.allCases.map { .init(value: $0, title: $0.title) },
selection: $kind)
}
if kind == .http {
field("URL") {
@@ -86,6 +86,9 @@ enum AppSettingsKey: String, CaseIterable {
case aiOpensTo = "aiOpensTo"
case aiNewChatAfter = "aiNewChatAfterMinutes"
case aiToolRounds = "aiToolRounds"
case aiShownModels = "aiShownModels"
case aiDisabledRoutes = "aiDisabledRoutes"
case aiInstalledOverrides = "aiInstalledOverrides"
case mcpEnabled = "mcpEnabled"
case mcpServers = "mcpServers"
case quickActionsEnabled = "quickActionsEnabled"
@@ -41,8 +41,6 @@ extension SettingsAnchor {
static let aiChat = Self(tab: .ai, title: "Chat")
static let aiConversations = Self(tab: .ai, title: "Conversations")
static let aiSystemPrompt = Self(tab: .ai, title: "System prompt")
static let aiInstalledAI = Self(tab: .ai, title: "Installed AI")
static let aiAPIConnections = Self(tab: .ai, title: "API Connections")
static let aiMCPServers = Self(tab: .ai, title: "MCP Servers")
static let aiCommands = Self(tab: .ai, title: "Commands")
@@ -275,7 +275,8 @@ enum SettingsSearchCatalog {
.aiProviders, "Providers",
keywords: [
"sign in", "connect", "codex", "claude", "grok", "xai", "opencode", "cursor", "agent",
"api key", "connection", "base url", "openai", "anthropic", "ollama"
"api key", "connection", "base url", "openai", "anthropic", "ollama", "models",
"hide models"
]),
.init(.aiDefault, "Default model", keywords: ["llm", "gpt", "claude", "grok"]),
.init(.aiDefault, "Reasoning effort", keywords: ["thinking", "effort", "deepseek"]),
+22
View File
@@ -0,0 +1,22 @@
import AppKit
/// The open panel a Settings row uses to pick a command, hidden folders shown: `~/.local/bin` is one.
@MainActor
enum ExecutablePicker {
static func choose(message: String, startingAt directory: URL) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.showsHiddenFiles = true
panel.treatsFilePackagesAsDirectories = true
panel.resolvesAliases = false
panel.prompt = "Use Command"
panel.message = message
panel.directoryURL = directory
// Tinycast is an accessory app, so the panel opens behind the frontmost app without this.
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK else { return nil }
return panel.url
}
}
@@ -13,6 +13,7 @@ struct KeychainSecretStore: Sendable {
/// Every scope the app stores under, named here so the whole keychain surface is one list.
static let aiAPIKeys = KeychainSecretStore(scope: "ai-api-keys")
static let mcpSecrets = KeychainSecretStore(scope: "mcp-secrets")
static let installedAIEnvironment = KeychainSecretStore(scope: "installed-ai-environment")
init(scope: String, bundleIdentifier: String? = Bundle.main.bundleIdentifier) {
service = "\(bundleIdentifier ?? "com.tinycast.app").\(scope)"
+70 -10
View File
@@ -24,6 +24,26 @@ bottom with the model picker. ⌘J hands a Quick AI conversation to the window.
OpenCode and Cursor visible with an individual toggle for each, all off by default. Turning one off cancels
its check, clears its catalog and releases its process; Apple Intelligence is the default route when
available, and saved API connections stay available.
- **A route that is off is off everywhere.** The on-device model and every API connection have the
same switch an installed tool has (`aiDisabledRoutes`, by `AIModelSource.storageKey`). Off leaves
the route configured — a connection keeps its key — but `AIModelOption.availableGroups` drops it
from every picker, `AIProviderFactory` refuses it with a message, and a default that pointed at it
moves to a route still on. `AISettingsStore.isRouteEnabled` is the one place that answers.
- **A picker lists what the reader ticked, and an untouched route lists everything.**
`aiShownModels` holds, per route, the models its pickers show. No entry means all of them,
including one the route starts offering later; ticking every box again drops the entry rather than
storing the full list. The default model is always listed, since a picker must be able to show what
it holds, and a removed connection takes its entry with it.
- **A set command path wins or fails.** `InstalledAILaunch.command` answers `.automatic`,
`.executable` or `.missing`, and `.missing` fails the check and the turn. The lookup is never a
fallback for a path that was set: falling back would run another copy and hide the mistake the path
was set to fix.
- **A reader's variable never replaces one Tinycast sets.** `InstalledAIKind.managedEnvironment` is
what keeps a tool inside the chat — OpenCode's deny-all configuration, Claude's account MCP switch —
and `InstalledAILaunch.inherited(for:)` drops a reader's variable of the same name, along with
`NO_COLOR` and the `TC_MCP_` names that carry MCP secrets to Codex. Names are stored in
`aiInstalledOverrides` and values in the login Keychain (`KeychainSecretStore.installedAIEnvironment`),
read only for a tool that has variables.
- **Every request carries Tinycast's own preamble, and the user's text goes after it.**
`AIInstructions.compose` builds `AIRequest.instructions`: a fixed preamble that tells the model
where it is running and what the app can do, then whatever Settings → AI holds. The preamble
@@ -290,7 +310,9 @@ and reasoning efforts from `model/list`; OpenCode gets identifiers and model-spe
stream-json `-p` run that then gets no prompt, so no model is called — with its own `/model` list;
`InstalledAIModel.claudeCatalog` keeps one row per resolved model (dropping `default`, which restates
another), names each by the version its alias points at today ("Claude Opus 5.5"), and takes each
one's `supportedEffortLevels`. A model the CLI starts offering appears without a Tinycast release. Cursor lists models
one's `supportedEffortLevels`. The version is in `description` before a " · " on an older CLI and in
`displayName` on a newer one, which describes a model without it; the name is read from whichever
has it. The same answer carries the account, which `claudeAccount` reads for the Overview page. A model the CLI starts offering appears without a Tinycast release. Cursor lists models
from `agent --list-models` after `agent status --format json` confirms a login.
Turning thinking off is a reasoning effort, not a second control: `reasoningOptions(for:)` answers with
@@ -476,7 +498,9 @@ fourth `OpenMenu` case, `.topTrailing` like the type filter, and it opens on the
row leads with the vendor's mark — `AIBrand` resolves it from a native connection's provider, or for
OpenRouter and OpenAI-compatible endpoints from the model id (`anthropic/claude-…`, `deepseek-chat`,
`o4-mini`). The marks are ~300 B–2 KB monochrome template SVGs in `Assets.xcassets` (`AIBrand*`),
twelve from Simple Icons and Z.ai from `@lobehub/icons`, so they tint with the row like a symbol; an
thirteen from Simple Icons, Grok and Z.ai from `@lobehub/icons` and OpenCode drawn after its own, so
they tint with the row like a symbol. OpenCode's inner block is the one second tone among them, and
is drawn with `opacity`: the asset compiler drops `fill-opacity` without a warning. An
unrecognised model keeps the generic sparkle. Provenance, the MIT notice and the trademark position
are recorded in [`NOTICE.md`](../../NOTICE.md) — the CC0 on the Simple Icons project does not extend
to the brands it depicts. The header's model switcher shows the selected model's mark the same way.
@@ -534,7 +558,8 @@ window, and every chat action either surface sends — is the nineteenth feature
- Collapse the sidebar with the toolbar button; ⌘N and ⌘Q (Close Window) still work, and ⌘Q with
Settings in front closes Settings instead.
- Harnesses: `ai-provider-test` (endpoints, request bodies, stream decoding, persistence repair,
Codex framing, on-device routing, the two MCP launch encodings and the two consent channels),
Codex framing, on-device routing, the two MCP launch encodings and the two consent channels, the
shown-model and switched-off-route rules, and a tool's override from settings to launch),
`ai-chat-test` (`ChatSession`, `MarkdownBlock`, `ChatHistoryStore` with renames and pins,
`AIToolLoopProvider`, regenerate, and `AIChatSurfacesState`'s one-live-place rule),
`codex-turn-test` (the Stop path, driven against a stub app-server stalled where Stop races the
@@ -542,7 +567,8 @@ window, and every chat action either surface sends — is the nineteenth feature
rows and the call cap),
`installed-ai-test` (Claude/Grok/OpenCode/Cursor flags, prompt
framing, streaming and cleanup, and Claude's private MCP configuration, control channel, round
cap and managed-policy branch) and `apple-intelligence-test` (status copy, snapshot deltas,
cap and managed-policy branch, a reader's variables against Tinycast's own, and a set command
path that runs or fails) and `apple-intelligence-test` (status copy, snapshot deltas,
transcript assembly, error mapping, plus one real generation when this Mac can run one), all in
`run-tests.sh`.
@@ -557,7 +583,16 @@ every nvm Node version, newest first — a fallback that can pick a different co
found command runs under is `ExecutableLocator.environment`: its own folder, `/opt/homebrew/bin` and
`/usr/local/bin` ahead of the inherited PATH, for every probe, turn, Codex `mcp list` read and local MCP
server — a Finder-launched app's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`, and an npm or Homebrew CLI is
`#!/usr/bin/env node`, which would find no `node` on it. The
`#!/usr/bin/env node`, which would find no `node` on it.
A reader can replace the lookup and add to that environment, per tool, on the tool's Advanced page.
The path is used as written, a leading `~` expanded, and never searched for. The variables lie over
the app's own before `ExecutableLocator.environment` builds the PATH, so a reader's `PATH` is the one
the tool's folder is put ahead of. Both reach every spawn: the probes, a CLI turn and the session it
deletes afterwards, Claude's title request, and Codex's app-server and its `mcp list` read.
`InstalledAIManager` and `CodexAppServerClient` ask for them at each launch through `launchSettings`,
so an edit takes the next one, and `AISettingsStore.launchRevisions` lets `AppCore` check again only
the tool that was edited — Codex by stopping its server, which restarts on demand. The
commands are never installed by Tinycast; Settings links to their own install docs and offers a sign-in
command to copy. `InstalledAIManager` probes Claude, Grok, OpenCode and Cursor off-main, in parallel.
Claude's auth status gates an `initialize` control request, and `InstalledAIModel.claudeCatalog` builds
@@ -737,14 +772,35 @@ width and clipped the search field well short of the button.
Settings → AI is a normal grouped `Form` inside Tinycast's existing Settings window. Its top AI
section owns the feature switch and the **Providers → Manage…** action, and **Default model** below
it picks the app-wide route and its reasoning effort. Provider management opens as a sheet, where
**Installed AI** reports Codex, Claude, Grok, OpenCode and Cursor separately as checking, ready, sign-in required,
missing or failed. It never contains a credential field: installation and sign-in happen in each
command's own flow. **API Connections** remains the explicit Keychain-backed path in that sheet. A
it picks the app-wide route and its reasoning effort. A
pick in Quick AI's header or the AI Chat composer sets that chat's model and moves this default with
it, while Quick Actions keeps its own model selection.
The signed-in Codex address is the one thing on the pane that names a person, and a Settings pane
Provider management opens as an editor panel laid out like Mail's Accounts: every route in a list on
the left — **On This Mac**, **Installed**, **API Connections** — each with its mark and a one-line
state, and the selected one's detail on the right. `+` under the list is a menu of the five
connection presets and `−` removes the selected connection; an installed tool cannot be removed,
only switched off, from the switch in its detail header. The detail has pages behind a segmented
control (`AIProviderTab`), and the chosen page is kept from one provider to the next:
- **Overview** reports an installed tool as checking, ready, sign-in required, missing or failed,
with the account, Codex's usage windows and the command that ran. It never contains a credential
field: installation and sign-in happen in each command's own flow. For a connection it shows the
provider, base URL and whether a key is stored; **Edit…** in the header opens the connection
editor, which remains the explicit Keychain-backed path.
- **Models** is the checklist behind the second invariant above, with Show All, Hide All and a
filter once a route offers more than eight. The rows are an `NSTableView` in one `Form` row
(`AIModelChecklist`): a `Form` realizes every row it holds, and OpenCode offers over four hundred.
- **Advanced**, on an installed tool only, holds the command path and the variables, saved as each
field is left. It stays while the tool is off, so a wrong path can be fixed before it is switched
on. A name Tinycast sets itself says its value is not used.
An API connection has the first two pages, and the on-device model, with one, shows no control. The
panel draws its Liquid Glass behind its content rather than around it
(`settingsEditorPanelSurface(controlsOnGlass: false)`), because the page control sits directly on the
surface and a segmented control drawn on glass loses its accent colour.
The signed-in Codex or Claude address is the one thing on the pane that names a person, and a Settings pane
is what gets screenshotted into a bug report or left on screen in a recording, so `RedactedText`
shows it scrambled and blurred until it is clicked. `RedactedPlaceholder` derives the stand-in from
the address itself — stable across redraws, same length, `@ . - _` left in place — because a blurred
@@ -776,3 +832,7 @@ answer and must not arrive on another Mac unread. `aiRetention`, `aiOpensTo` and
join them: all three are decisions about conversations that never leave the Mac that had them, and
an import must not arrive carrying an instruction to delete them. `aiToolRounds` stays behind too: it
limits what a tool-driven reply may spend, and an import must not raise that unasked.
`aiShownModels` and `aiDisabledRoutes` name this Mac's own tools, connections and their models, which
another Mac may not have. `aiInstalledOverrides` names a command to run and the variables to run it
with, and an import must never decide which program a Mac launches. None of the three has a
`settings.json` key, for the same reasons: they are machine state, and the last grants a capability.
+13
View File
@@ -167,6 +167,11 @@ AI Chat adds `aiChatWindow 960×660` (opening size), `aiChatWindowMinimum 680×4
`chatContextGauge 14` for the composer's context ring, and `chatContextCard 300` for the card it
raises on hover.
The AI Providers panel adds `aiProvidersPanel 840×520` (the height is its two columns', stated so
that selecting a longer provider never resizes the panel), `aiProvidersList 262`, `aiUsageBar 110`
and `aiVariableName 170`. `segmentLabelInset 13` is what the system leaves either side of a
segment's label once a segmented control has settled.
`keyCap` sizes the palette's keycap chips; `recorderKeyCap` (both size and radius) is the intentionally-smaller Settings shortcut-recorder chip.
### Typography (`Theme.Typography`)
@@ -645,6 +650,14 @@ system-drawn and a pane reads exactly as macOS System Settings does.
is unaffected.
- **`.settingsEnabled(_:)`, never a bare `.disabled(_:)`.** It dims as well as disables, so a
switched-off row reads as unavailable rather than merely unresponsive.
- **A segmented control that opens in a panel is a `SteadySegmentedPicker`.** The system control
opens tight around its labels and widens the first time its selection changes, under the pointer,
and its own idea of its width shifts a few points with it. The wrapper pins each segment to its
label plus `segmentLabelInset` and reports its size from those widths. A segmented `Picker` that is
redrawn right after it appears, like the Providers panel's page control, settles before it is seen.
- **A control placed directly on an editor panel's glass loses its accent colour.** Inside a grouped
`Form` row it keeps it. A panel with such a control asks for
`settingsEditorPanelSurface(controlsOnGlass: false)`, which draws the same glass behind the content.
- **A secret is a `RevealableSecureField`, never a bare `SecureField`.** One eye, one place, so an API
key, a header value and a passphrase all offer the same way to check a pasted value before saving.
It re-hides on its own once the field is cleared, and its eye is disabled while it is empty.
+22
View File
@@ -77,6 +77,19 @@ outright should not be tacked onto an unrelated conversation.
**Settings → AI → Providers → Manage…** is where models come from. **Default model** below it picks
the one chat uses, and its reasoning effort.
The panel lists every provider on the left and shows the selected one on the right, in up to three
pages:
- **Overview** says whether it is ready, which account it is signed in with, and which command it
runs. The account's address is blurred until you click it.
- **Models** lists everything the provider offers, each with a checkbox. **Ticked models appear in
the model picker.** A provider you have not touched lists all of them, including ones it adds
later. The default model always stays listed.
- **Advanced**, for installed tools, is covered below.
Every provider has a switch. **Off keeps it set up but takes its models out of every picker**, so you
can put an API connection aside without removing it or its key.
### Apple Intelligence
Runs **on your Mac**. No key, no account, and nothing leaves the machine. When your Mac supports it,
@@ -94,6 +107,15 @@ Tinycast can use them with the account you are signed in to. **Tinycast never as
Each one has its own switch, and all five ship off. The pane shows whether each is ready, missing,
or needs you to sign in. It links to the install page and can copy the sign-in command for you.
Tinycast finds each command the way your Terminal would. If it finds the wrong copy, or none, open
the tool's **Advanced** page:
- **Command path** is the command to run instead. Leave it empty to let Tinycast find it. **A path
with nothing to run is reported, never quietly replaced** by whatever Tinycast can find.
- **Variables** are set for that tool each time it starts, such as a proxy or a config folder.
Values are kept in your login Keychain. A few names are Tinycast's own, which keep the tool from
touching your files, and the row tells you when a value will not be used.
Tinycast uses them as plain chat. Claude, Grok and OpenCode run with tools, file access and shell access
switched off — unless you have added [MCP servers](/docs/ai/mcp), which Codex and Claude can call
and the other three cannot. Cursor runs in Ask mode against Tinycast's private workspace: read-only exploration,