Files
tinycast/Tinycast/Features/AI/Model/InstalledAI.swift
T
notsoshu 850a519d42 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.
2026-09-29 00:57:58 +06:00

332 lines
13 KiB
Swift

import Foundation
enum InstalledAIKind: String, CaseIterable, Codable, Identifiable, Sendable {
case codex
case claude
case grok
case openCode
case cursor
/// Claude, Grok, OpenCode and Cursor — Codex uses its app-server instead.
static let managedCLIKinds: [InstalledAIKind] = [.claude, .grok, .openCode, .cursor]
var id: String { rawValue }
var title: String {
switch self {
case .codex: return "Codex"
case .claude: return "Claude"
case .grok: return "Grok"
case .openCode: return "OpenCode"
case .cursor: return "Cursor"
}
}
var command: String {
switch self {
case .codex: return "codex"
case .claude: return "claude"
case .grok: return "grok"
case .openCode: return "opencode"
case .cursor: return "agent"
}
}
var installURL: URL {
switch self {
case .codex: return URL(string: "https://developers.openai.com/codex/cli")!
case .claude: return URL(string: "https://code.claude.com/docs/en/setup")!
case .grok: return URL(string: "https://x.ai/cli")!
case .openCode: return URL(string: "https://opencode.ai/docs")!
case .cursor: return URL(string: "https://cursor.com/docs/cli/overview")!
}
}
/// Installs that put their command outside every shared `bin` the locator already walks.
var extraExecutablePaths: [String] {
switch self {
case .claude: return [".claude/local/claude"]
case .grok: return [".grok/bin/grok"]
case .codex, .openCode, .cursor: return []
}
}
var source: AIModelSource {
switch self {
case .codex: return .codex
case .claude: return .claude
case .grok: return .grok
case .openCode: return .openCode
case .cursor: return .cursor
}
}
/// What the Providers row says: Cursor keeps the reader's MCP; a managed policy owns Claude's.
func isolationCaveat(hasManagedMCPPolicy: Bool) -> String? {
switch self {
case .cursor: return "Ask mode · your Cursor MCP servers still apply"
case .claude:
return hasManagedMCPPolicy
? "MCP on this route is managed by your organization" : nil
case .codex, .grok, .openCode: return nil
}
}
var signInCommand: String {
switch self {
case .codex: return "codex login"
case .claude: return "claude auth login"
case .grok: return "grok login"
case .openCode: return "opencode auth login"
case .cursor: return "agent login"
}
}
}
extension AIModelSource {
/// The installed command behind this source, or `nil` for the two routes Tinycast reaches itself.
var installedKind: InstalledAIKind? {
switch self {
case .codex: return .codex
case .claude: return .claude
case .grok: return .grok
case .openCode: return .openCode
case .cursor: return .cursor
case .appleIntelligence, .api: return nil
}
}
}
struct InstalledAIModel: Equatable, Identifiable, Sendable {
let id: String
let name: String
let efforts: [ChatGPTSubscription.Effort]
init(id: String, name: String, efforts: [ChatGPTSubscription.Effort] = []) {
self.id = id
self.name = name
self.efforts = efforts
}
func resolvedEffort(_ preferred: String?) -> String? {
guard !efforts.isEmpty else { return nil }
if let preferred, efforts.contains(where: { $0.id == preferred }) { return preferred }
if efforts.contains(where: { $0.id == "high" }) { return "high" }
return efforts.first?.id
}
/// What Claude's `initialize` control request asks for; the answer is its own `/model` list.
static let claudeInitializeRequest =
#"{"type":"control_request","request_id":"tinycast-models","request":{"subtype":"initialize"}}"#
+ "\n"
static let claudeTitleRequestID = "tinycast-title"
/// Claude Code's own session namer, asked without persisting anything to its history.
static func claudeTitleRequest(_ description: String) -> String? {
let request: [String: Any] = [
"type": "control_request", "request_id": claudeTitleRequestID,
"request": [
"subtype": "generate_session_title", "description": description,
"persist": false
]
]
guard let data = try? JSONSerialization.data(withJSONObject: request),
let line = String(bytes: data, encoding: .utf8)
else { return nil }
return line + "\n"
}
static func claudeTitle(_ output: String) -> String? {
for line in output.split(whereSeparator: \.isNewline) {
guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
let response = object["response"] as? [String: Any],
response["request_id"] as? String == claudeTitleRequestID,
let payload = response["response"] as? [String: Any],
let title = payload["title"] as? String
else { continue }
return title
}
return nil
}
/// The CLI's own picker, one row per resolved model: `default` only restates another entry.
static func claudeCatalog(_ output: String) -> [InstalledAIModel] {
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 entries = payload["models"] as? [[String: Any]]
else { continue }
var models: [InstalledAIModel] = []
var resolved = Set<String>()
for entry in entries {
guard let id = entry["value"] as? String, !id.isEmpty, id != "default" else {
continue
}
let target = entry["resolvedModel"] as? String ?? id
guard resolved.insert(target).inserted else { continue }
models.append(
InstalledAIModel(
id: id, name: claudeName(entry, fallback: id),
efforts: (entry["supportedEffortLevels"] as? [String] ?? []).map {
ChatGPTSubscription.Effort(id: $0, detail: nil)
}))
}
return models
}
return []
}
/// 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 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
}
/// `/effort` advertises these four; a model only honours the ones it supports.
private static let grokEfforts = ["low", "medium", "high", "xhigh"].map {
ChatGPTSubscription.Effort(id: $0, detail: nil)
}
static func grokCatalog(_ output: String) -> [InstalledAIModel] {
let clean = output.replacingOccurrences(
of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression)
var models: [InstalledAIModel] = []
var seen = Set<String>()
for raw in clean.components(separatedBy: .newlines) {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*") || line.hasPrefix("-") else { continue }
let rest = line.drop(while: { $0 == "*" || $0 == "-" || $0.isWhitespace })
let token = rest.split(whereSeparator: { $0.isWhitespace || $0 == "(" }).first
guard let token, !token.isEmpty else { continue }
let id = String(token)
guard seen.insert(id).inserted else { continue }
models.append(InstalledAIModel(id: id, name: id, efforts: grokEfforts))
}
return models
}
/// `grok models` exits 0 and still prints the catalog when the CLI is signed out.
static func grokSignedIn(_ output: String) -> Bool {
let clean = output.replacingOccurrences(
of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression
)
.lowercased()
return !clean.contains("not authenticated") && !clean.contains("not signed in")
}
static func openCodeCatalog(_ output: String) -> [InstalledAIModel] {
let clean = output.replacingOccurrences(
of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression)
var entries: [(String, [String])] = []
var id: String?
var objectLines: [String] = []
func appendEntry() {
guard let id else { return }
let data = Data(objectLines.joined(separator: "\n").utf8)
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let variants = object?["variants"] as? [String: Any] ?? [:]
entries.append((id, variants.keys.sorted(by: effortOrder)))
}
for raw in clean.components(separatedBy: .newlines) {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if raw == line, line.contains("/"), !line.hasPrefix("{") {
appendEntry()
id = line
objectLines = []
} else if id != nil {
objectLines.append(raw)
}
}
appendEntry()
return entries.map { id, efforts in
InstalledAIModel(
id: id, name: id,
efforts: efforts.map { ChatGPTSubscription.Effort(id: $0, detail: nil) })
}
}
/// `agent --list-models` lines look like `composer-2.5 - Composer 2.5`.
static func cursorCatalog(_ output: String) -> [InstalledAIModel] {
let clean = output.replacingOccurrences(
of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression)
var models: [InstalledAIModel] = []
var seen = Set<String>()
for raw in clean.components(separatedBy: .newlines) {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let separator = line.range(of: " - ") else { continue }
let id = String(line[..<separator.lowerBound]).trimmingCharacters(in: .whitespaces)
let name = String(line[separator.upperBound...]).trimmingCharacters(in: .whitespaces)
guard !id.isEmpty, !name.isEmpty, !seen.contains(id) else { continue }
seen.insert(id)
models.append(InstalledAIModel(id: id, name: name))
}
return models
}
private static func effortOrder(_ lhs: String, _ rhs: String) -> Bool {
let order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
let left = order.firstIndex(of: lhs) ?? order.endIndex
let right = order.firstIndex(of: rhs) ?? order.endIndex
return left == right ? lhs < rhs : left < right
}
}
/// 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
case checking
case ready
case signInRequired
case notInstalled
case failed(String)
}
var phase: Phase = .idle
var version: String?
var executable: URL?
var models: [InstalledAIModel] = []
var account: InstalledAIAccount?
var isReady: Bool { phase == .ready && executable != nil && !models.isEmpty }
}