* Add OAuth authentication for HTTP MCP servers * Escape a plus sign in the OAuth authorization URL * Drop an issuer's terminating slash before inserting its well-known path * Keep the issuer comment under the 100-character cap * Keep a saved server signed in when Test Connection tries an edited URL * Let a token refresh finish when the server editor closes or saves * Say why an MCP server was kept when Remove cannot delete its credentials * Cover supplied OAuth client credentials in the harness
43 KiB
AI providers and chat
Tinycast has one app-wide provider layer for features that need text generation. AI Chat chooses its
model from the chat header; callers ask AppCore.aiProvider() for the current provider and stream an
AIRequest.
AI Chat is the first consumer and Quick Actions the second; the provider layer
depends on neither, and Quick Actions carries its own route rather than borrowing this one.
Invariants
- AI Chat is off out of the box, and off means fully off.
AppSettings.aiEnabledis the flag: noAI Chatcommand in the launcher, no history database opened or created, no Codex helper for Chat, no stop for it on Tab's ring, and the palette leaves.ai. Installed providers may still remain available for Quick Actions, which has its own switch and route. Turning AI Chat off cancels a streaming reply and drops the transcript, but touches neither the saved conversations inai-chats.sqlite3nor a Keychain key.aiEnabledis excluded from settings backups like every other AI key, so an import can never arm a feature it cannot configure. - Installed model discovery is per-provider. Settings → AI → Providers keeps Codex, Claude, Grok, 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.
- Every request carries Tinycast's own preamble, and the user's text goes after it.
AIInstructions.composebuildsAIRequest.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 keeps the model a general-purpose assistant — the app facts are reference for when the user asks, never a scope limit — and asks for honest comparisons; it does not instruct the model to favour Tinycast over anything else. It is not shown in the pane, andAIPreamble.swiftholds the only copy of it — edit the prompt there, not here.composereturnsnilwhen the user has turned the system prompt off, and every transport drops a nil instruction, so a turn then carries none. - API keys live only in the login Keychain.
AIConnectionpersists the provider, endpoint and model identifiers inUserDefaults; it never contains a key. Keys are addressed by connection UUID throughKeychainSecretStore.aiAPIKeys, and never enter logs, errors or settings backups. A key is issued for one endpoint and never follows a connection retargeted at another — change the provider or base URL and both model discovery and Save ask for a new key, rather than introduce the saved one to a host it was never meant to reach (AIEndpointPolicy.sameDestination). - Remote endpoints require HTTPS. Plain HTTP is accepted only for
localhost,127.0.0.1and::1, where a key is optional, and any other scheme is rejected outright — a loopback host does not excuseftp://.AIEndpointPolicyis the one place that decides this. - MCP OAuth is separate from the model provider's login. The HTTP MCP client can sign into a hosted server from Settings and keep its client credentials and tokens in that server's Keychain item. API chat uses this session without receiving its credentials. A rejected refresh becomes Sign-in required on the server, an unserved one does not end the session, and either way a running tool loop receives an explainable failure result. This does not offer Tinycast's MCP servers to installed CLI or on-device routes. See MCP.
- The chat model is the routing decision. It names the on-device model, a model exposed by the installed Codex, Claude, Grok, OpenCode or Cursor command, or one saved API connection and model. Installed routes also carry their reasoning effort when the selected model supports one. A removed route falls forward to the on-device model when this Mac has one, then to another usable API model, then to no selection. Discovering an installed command never silently selects a networked model.
- The on-device route is configured by having a Mac.
.appleIntelligencetakes no key, opens no socket and names no endpoint, so the Keychain, HTTPS and ephemeral-session rules below have nothing to bind to — the Settings pane must never grow a credential field for it. It is text-only and offers no web search, the preamble still rides ahead of every turn, and history is bounded toAppleIntelligence.contextBudgetrather than the cloud routes' ~100 KB because the on-device window holds a prompt and its reply together. Availability is asked for each time, never cached at launch: the model finishes downloading mid-session, and reading it inside a view body leaves SwiftUI observing the framework's own state. - An unavailable on-device model is reported, never rerouted. Fall-forward exists for a route the reader removed; a Mac with Apple Intelligence switched off keeps its stored selection and is told why, because silently moving someone from a free, private, local model onto a billed endpoint is the one redirection this feature must never perform.
- Installed commands reuse their own login. Tinycast launches the user's
codex,claude,grokoropencodeexecutable without asking for or storing another key. Codex inherits the user's normal home and credential-store setting; Claude, Grok, OpenCode and Cursor inherit their normal configuration. Tinycast never reads those credential files, browser cookies or undocumented web endpoints. - Codex tools are unavailable. The app-server launches with tool capabilities disabled, approvals
set to never and a read-only, network-disabled sandbox. Any server approval request is declined.
MCP does not lift this:
AIModelCapabilities.toolsis false for the subscription route and for the on-device one, so only the two HTTP shapes are ever handed a tool. - Tool calling is a decorator, not a transport change.
AIToolLoopProviderwraps a route and re-streams the turn until the model stops asking, so a route with no tools behaves exactly as it did andAIChatStatereduces one more pair of events. Only chat wraps:quickActionProvider()rewrites the reader's own selected text and has nothing to call. A turn's tool messages stay inside the loop — what the transcript keeps is aChatToolUserecord, pinned at a text offset like a search, soboundedContextcan never separate a stored call from its result. - Every HTTP request uses a private ephemeral
URLSessionwith no URL cache. Provider traffic must not create a second credential or response cache on disk. Model/stays Foundation-only.ai-provider-testcompiles the shipped provider models and pins endpoints, request bodies, stream parsing, persistence repair and Codex protocol framing. Request bodies areAIRequestBody's, inModel/, precisely so a wrong shape fails a harness rather than a conversation.installed-ai-testruns the Claude, Grok, OpenCode and Cursor adapters against real subprocess stubs and pins their safety boundaries.- Claude, Grok, OpenCode and Cursor are text transports, not agents. Claude runs one turn with no tools,
browser integration, slash commands or persisted session — but never
--bare, which reads neither OAuth nor the keychain and so refuses the very sign-in this route reuses. Grok runs with--deny *,dontAskpermissions and a workspace sandbox, and never--always-approve, so a user's always-approve config cannot arm tools for this route. OpenCode runs--purewith deny-all permissions, disabled sharing and a private working directory. Cursor runsagent -p --mode askwith--trustagainst Tinycast's private workspace and never--force/--yolo/--approve-mcps; ask mode blocks edits. Each deletes the session or chat it created once the child exits, and Tinycast never reaches into the user's own config to do it. Only Claude keeps the user's MCP servers out of the process, through--strict-mcp-configwith an empty config — and even that yields to an installed managed MCP policy, which makes the CLI reject both flags. Grok, OpenCode and Cursor load the global config either way, because their configs merge with no opt-out; what refuses the call is--deny *for Grok,permission: denyfor OpenCode and withheld MCP approval for Cursor. Cursor's is the only one resting on an approval prompt rather than an explicit deny, which is why its Providers row says so and the others do not. None of these routes offer images or web search. - Chat is a palette screen, not another window — including its lifetime. The launcher command
enters
.ai; its search field is the composer, and the shared footer's primary pill is Return's job: Send (↵), or Stop (↵) while a response streams — followed by Actions (⌘K), which owns New Chat. A conversation outlives the window that showed it, and one place decides for how long. Pop to Root forgets the screen and the query; whether the next summon resumes the transcript is Settings → AI'sOpens to, applied inAIChatCoordinator.applyOpenPolicyon the way into.ai. That used to be Pop to Root's job by accident — it fires on every hide, so a chat never survived Escape — and deciding at open time from a timestamp leaves one clock instead of two racing over the same state, and a verdict that still holds after a relaunch. A reply still streaming is never reset out from under the reader — it was asked for — and the transcript is saved regardless, so the old conversation is one ⌘K → Chat History away. - Arriving with a question skips the open policy entirely.
ask(_:)— ⇥ from the launcher, and the AI fallback row — always starts a new chat and submits the text, because a question asked outright is not a summon: resuming a transcript to append an unrelated line to it would be the one reading ofOpens tonobody wants. It isshowPalette(mode: .ai)andsend, nevershowChat. - Staged files survive a re-summon; only the typed draft does not.
applyOpenPolicytreats a pasted-but-unsent attachment as resident state:Recent Conversationwill not open a saved chat over one, andA New Conversationresets only a chat that actually has messages, since an empty chat is already new and resetting it would drop the file for nothing. A file cost a read and a decode, which is not the same as a half-typed line — that is still dropped byprepare. Switching conversations through history still disowns them, which is the rule they belong to. AIConversationOpenPolicyis the whole rule, and it is pure.Recent Conversationresumes the resident transcript, or reopens the newest saved one when nothing is resident, unless it has been idle pastStart a new conversation after;A New Conversationalways starts fresh. There is no third setting for "immediately" because that isA New Conversation— two controls able to express one state would only ever disagree.- History is local and lazy. Conversation summaries stay in memory while transcripts load from the system SQLite database only for the selected preview or opened chat. Empty chats are never saved.
- Retention is enforced only while AI is on.
Keep conversationsprunes on the enable transition and whenever the setting changes, throughAIChatCoordinator.applyRetention— never on a schedule and never whileaiEnabledis false, because age passes while the feature is off and "off means fully off" promises the file is untouched. A Mac left off for four months keeps its chats.ChatHistoryStore.prune(before:)is oneDELETEthat cascades to messages, images and searches, and itVACUUMs only when something actually went: pictures live inline as BLOBs, so this is the one store where a delete alone frees pages without ever shrinking the file. - Everything but the newest message is bounded.
ChatSession.boundedContextsends that message whole — truncating what someone just typed is worse than the provider's own error — keeps images and documents only on that turn and only up toAIAttachmentBudget, inlining an attached text file into that turn alone, and walks older text newest-first into a budget the route names: ~100 KB for a cloud endpoint,AppleIntelligence.contextBudgetfor the on-device model. Every transport funnels throughrequestMessages(textBudget:), so no route can resend every image each turn or let history grow the payload as a chat goes on. The composer refuses a picture past the budget and says so, rather than letting send time drop it silently.
Connections and routing
AIModelSelection has seven cases: .appleIntelligence, .codex, .claude, .grok, .openCode,
.cursor and .api.
The first needs no connection at all. The next five name a model from an installed command and carry
no credential. .api points at one AIConnection; AIProviderKind exposes four named presets plus a
custom OpenAI-compatible route. Decoding still accepts the old .chatGPT spelling and writes it back
as .codex, so an existing selection survives the rename.
| Setting | Transport | Default base URL |
|---|---|---|
| Apple Intelligence | Foundation Models, on device | none |
| Codex | installed codex app-server |
user's Codex account |
| Claude | installed claude -p |
user's Claude login |
| Grok | installed grok --prompt-file |
user's Grok login |
| OpenCode | installed opencode run |
providers already configured in OpenCode |
| Cursor | installed agent -p --mode ask |
user's Cursor login |
| OpenAI API | OpenAI Chat Completions | https://api.openai.com/v1 |
| Anthropic Claude | Anthropic Messages | https://api.anthropic.com |
| Google Gemini | Gemini's OpenAI-compatible API | https://generativelanguage.googleapis.com/v1beta/openai |
| OpenRouter | OpenAI-compatible | https://openrouter.ai/api/v1 |
| OpenAI Compatible | OpenAI-compatible | user-editable |
The base URL stays editable for every preset because gateways and organization proxies are legitimate
destinations. AIHTTPConfiguration.endpointURL accepts a complete endpoint or appends the transport's
completion path. Gemini requests identify Tinycast through x-goog-api-client; OpenRouter requests
carry the app title.
Each connection has an ordered, deduplicated list of exact model identifiers. While its editor is open,
Tinycast asks the configured provider for the models available to the entered key and uses the result
for search-as-you-type completion and validation. It never renders the whole provider catalog at once;
selected models stay visible and search shows at most twelve additions. Discovery is debounced,
cacheless and never persists the typed key. A
custom gateway may not implement a model-list endpoint, so exact identifiers can always be entered
manually. Tinycast does not ship or guess an API catalog that can become stale. Codex gets its models
and reasoning efforts from model/list; OpenCode gets identifiers and model-specific variants from
opencode models --pure --verbose. Claude exposes the CLI's stable sonnet, opus and haiku
aliases, with the CLI's effort levels on the supported Opus and Sonnet families. 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
the connection's catalogued efforts, or — for a connection with no catalog to publish one — Default
and None. takesThinkingField decides who gets that pair: an OpenAI-shaped preset whose base URL is
not that preset's own, because a preset pointed away from its own API is a gateway, and a gateway is
the only destination Tinycast can offer the switch to honestly. Picking None sends
"thinking": {"type": "disabled"}, which is how DeepSeek and the endpoints that copied its contract
answer without reasoning first. A vendor API is never offered the pair and so is never sent a field it
does not define — which matters precisely because the preset alone says nothing about the destination
when every base URL is editable. Only None is ever written, so every other body is the one it always
was, and the choice rides in AIModelSelection.effort like every other route's.
Provider interface
AIProvider.stream(_:) accepts provider-neutral messages, optional instructions, a maximum output
token count and the tools the turn may call. It returns an AsyncThrowingStream of text, thinking
state, tool activity, usage and completion. OpenAI-
compatible reasoning fields are surfaced as .thinking, never mixed into answer text. Anthropic
system messages are lifted into its top-level system field; the other HTTP routes keep system
messages in the OpenAI message array.
AIProviderFactory resolves the selection, validates an API endpoint, reads an API key at the last
possible moment and returns AppleIntelligenceProvider, HTTPAIProvider, CodexInstalledProvider
or InstalledCLIProvider. A consumer should hold neither settings nor credentials itself. The
selection and guardrails overload lets Quick Actions pick its own route and ask for permissive
content transformations without a second factory.
AIModelOption.groupedCatalog is the Settings picker catalog for both AI and Quick Actions. Provider
sections, ordering and model labels therefore cannot drift between the panes. Each route stores its
own complete AIModelSelection, including the selected reasoning effort for installed models and
OpenRouter models that offer one.
AppleIntelligenceProvider is the only route whose model is a local process. It builds a Transcript
from the turns ahead of the newest user message and streams the rest as the prompt, so a conversation
resumes rather than being replayed as one blob. Foundation Models reports the whole answer so far in
every snapshot while every other transport speaks in deltas; AppleIntelligenceDelta is the one place
that difference lives. Guardrails are a construction parameter rather than a constant: chat writes
fresh prose, where the default filter belongs, and a later text-rewrite feature transforms text the
reader already wrote, which is what permissiveContentTransformations exists for — so that feature
needs no second provider. GenerationError never reaches the transcript as-is; its Context carries
a debug description written for a log, so each case maps to a plain sentence instead.
Chat surface
The built-in AI Chat launcher command enters AIScreen, and carries a bindable global shortcut
(HotKeyAction.command(.aiChat)) that does the same thing from any app; Tab from the launcher is the
third way in. Settings → AI holds both the recorder and a checkbox for the command's place in launcher
search; the shortcut keeps working while the command is hidden, and does nothing at all while the
feature is off. The palette search field becomes the single-line composer. The footer pill and
Return are one action, activate: Send, or Stop while a response streams — an empty composer sends
nothing, so the pill never needs a disabled state. The header's trailing model switcher uses the
same in-window menu control as Clipboard's type filter and changes the chat route for the next
message. For installed routes and OpenRouter models whose catalog reports the capability, it also
shows the supported reasoning efforts and changes the chat effort for the next message.
Other API routes keep their provider default because their model catalogs expose no portable effort
contract. Neither change interrupts a response already streaming; stopping one is the pill's job,
so the header never has to fit a third control beside the switcher.
The second footer control is the palette's normal Actions (⌘K) menu. It owns New Chat, Chat History
and AI Settings, plus Stop Response and Copy Last Response when those actions apply. Chat adds no
separate footer design and no independent window.
AIChatState turns provider-neutral stream events into one live assistant message. Thinking state is
shown without entering the transcript, partial text is preserved on failure, cancellation invalidates
the active generation, and only completed assistant messages become context for the next request.
Assistant replies render Markdown; user messages remain literal. A reply keeps streaming while the
palette is hidden or showing another screen — the state is AppCore's, not the view's — and is
saved when it finishes; only Stop, New Chat, deleting the chat or quitting cut it short.
Tool activity persists in message_tools beside message_searches, and ChatMessage.segments
interleaves the two by text offset so a reply renders what it did in the order it did it. A call
loaded still marked running belonged to a process that is gone, so it reads back as failed — the same
repair a message left streaming gets.
ChatHistoryStore writes ai-chats.sqlite3 below the bundle-specific Application Support directory.
It uses the system SQLite already linked by Tinycast, stores no provider credentials, and repairs a
reply left streaming by a prior process into an interrupted failure when loaded.
Palette integration
Two palette modes carry the feature, and neither changes the shell's rules:
| Mode | Screen | Body |
|---|---|---|
.ai |
AIScreen |
ChatTranscriptView |
.aiHistory |
ChatHistoryScreen |
ChatHistoryList + preview, bucketed by day like Clipboard |
Chat History backs out to Chat; both are sub-screens, so the header shows the back chevron. The
search field is the composer: Return submits, or stops a streaming response, and the footer pill
reads Send ↵ / Stop ↵ to match. The model switcher is a HeaderMenuButton — the
active label, glyph and disclosure chevron layered over BarButton — which is also what Clipboard's
type filter is now, so the two header menus hover and open identically. Its menu is the palette's
fourth OpenMenu case, .topTrailing like the type filter, and it opens on the selected model. Each
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
unrecognised model keeps the generic sparkle. Provenance, the MIT notice and the trademark position
are recorded in 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.
The palette's click-away catcher is mounted permanently and only toggles allowsHitTesting with the
open menu. Inserting it on open and removing it on close could strand SwiftUI's hover target on the
header button underneath — AppKit kept delivering clicks, but neither the button nor the catcher saw
them until an unrelated render or a window exit/re-enter recomputed hover. It dismisses on a
DragGesture(minimumDistance: 0) rather than a tap, so a press that drifts a few points still closes
the menu, as a native menu's click-away does.
Seven more @MainActor @Observable types join the shared state: AISettingsStore,
ChatGPTSubscriptionManager, InstalledAIManager, ChatHistoryStore, AIChatState,
MCPSettingsStore and MCPServerManager. AIChatCoordinator is the nineteenth feature coordinator
and MCPCoordinator the twentieth.
Manual sweep
- The selected model appears at the right of the composer and truncates without crowding typed text.
- Clicking it opens the same anchored menu shape as Clipboard's type filter; arrows, Return and Escape operate the menu without changing the draft.
- Repeatedly clicking either the model switcher or the type filter opens and closes every time, even when the next click lands immediately after dismissal or a few points off the first one.
- Selecting a model updates the button immediately and the next message reaches that route.
- Switching while a response streams does not stop or reroute that response; the new model applies to the following message.
- With no available model, the menu offers Configure AI and opens the AI Settings pane.
- With Apple Intelligence available and nothing else configured, opening chat or the AI pane leaves the selection reading Apple Intelligence without asking for anything.
- With it switched off in System Settings, the pane says so and chat says so; neither moves the reader onto a configured API connection.
- Install and sign in to each supported command outside Tinycast, choose one of its discovered models, and confirm Chat and each model-backed Quick Action use it without showing a credential field.
- Sign out of an installed command, press Check Again, and confirm its models leave both pickers while the stored selection is repaired according to the normal routing rule.
- With
Opens to: Recent Conversationand a five-minute window, Escape out and summon again inside five minutes resumes the transcript; past it, the composer is empty. Quitting and relaunching still reopens the last conversation.A New Conversationis always empty. - Setting
Keep conversationsto 7 days drops older chats from ⌘K → Chat History and shrinksai-chats.sqlite3. Switching AI off, waiting past a boundary and switching back on prunes nothing that was saved before it went off. - Harnesses:
ai-provider-test(endpoints, request bodies, stream decoding, persistence repair, Codex framing, on-device routing),ai-chat-test(ChatSession,MarkdownBlock,ChatHistoryStore,AIToolLoopProvider),codex-turn-test(the Stop path, driven against a stub app-server stalled where Stop races the turn ID, plus the no-config-mutation boundary),installed-ai-test(Claude/Grok/OpenCode/Cursor flags, prompt framing, streaming and cleanup) andapple-intelligence-test(status copy, snapshot deltas, transcript assembly, error mapping, plus one real generation when this Mac can run one), all inrun-tests.sh.
Installed commands
ExecutableLocator finds codex, claude, grok, opencode and agent by asking the account's
login shell, so a stale copy in another prefix never shadows the one Terminal runs. Only when the shell
names no absolute executable does it fall back to the app's PATH, the normal Homebrew and local-bin
locations and every nvm Node version, newest first — a fallback that can pick a different copy. 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 three model aliases; a successful Grok or OpenCode model list is both its auth
check and catalog; Cursor's status --format json gates --list-models.
ChatGPTSubscriptionManager retains its historical type name but now owns only the installed Codex
app-server lifecycle and discovered account metadata. Production never sets CODEX_HOME, so the
server uses the same login and credential store as the user's normal Codex command. Tinycast supplies
only a private working directory. The server stops after ten idle minutes, when AI is switched off or
when the app terminates, and restarts on demand. Account state, model availability and rate-limit
windows come from the supported app-server protocol.
CodexTurnRunner is the generation half behind CodexInstalledProvider.
It creates an ephemeral thread for each request, injects prior user/assistant messages, and
streams agent-message deltas, plus item/started for the reasoning and web-search items that feed the
bubble's status line. System messages become developer instructions alongside Tinycast's fixed
no-tools boundary. Cancellation interrupts the active turn, including one the server has started but
not yet named: Stop arms that thread, and whichever of turn/started or the turn/start response
names the turn first spends a single turn/interrupt on it.
Web search is thread-scoped config (thread/start.config.web_search, live or disabled) and
reasoning effort belongs to turn/start; neither is written to the user's Codex configuration. The
developer instructions say whether the model may reach the web so the two cannot disagree. Images go
out as image input parts with data URLs, and as input_image when prior turns are injected.
InstalledCLITurnRunner handles Claude, Grok, OpenCode and Cursor behind the same provider protocol. It
frames Tinycast's instructions and bounded conversation history as stdin (or a private --prompt-file for
Grok, whose CLI requires a path), consumes newline-delimited JSON, and never puts prompt text on the
process command line. Claude uses stream JSON, --effort and no session persistence.
Grok uses streaming-messages-json and --effort, with --deny * so tools cannot run even when the
user's Grok config is always-approve; it captures the session id, then calls grok sessions delete.
OpenCode runs pure with an inline deny-all configuration and passes the selected
model variant through --variant; it captures the returned session identifier, then calls
opencode session delete after the process exits. Cursor runs ask mode with --trust,
stream-json and --stream-partial-output, never --force / --yolo / --approve-mcps, then removes
the local chat under ~/.cursor/chats/<workspace>/<session_id> because the CLI has no delete-chat.
A turn finishes on its own completion frame; both cleanups run detached after the child exits, so
housekeeping never holds the composer shut.
Cancellation terminates the child process; only one installed-CLI turn can own a runner at a time.
Web search and attachments
AIRequest.webSearch, AIMessage.images and AIMessage.documents are provider-neutral; each
route maps them itself:
A text-ish file is deliberately absent from this table: it is inlined as text before any transport sees the turn, so every route — the on-device model and all installed CLI transports included — takes one with no transport code at all.
| Route | Web search | Images | PDFs | MCP tools |
|---|---|---|---|---|
| Apple Intelligence | never — it reaches nothing | never — the model is text-only | never | never |
| Codex | thread-scoped web_search config |
image input part |
never — the app-server takes no document part | never — its tools are disabled by design |
| Claude command | never | never | never | none load — --strict-mcp-config with an empty config, unless a managed MCP policy forces both flags off |
| Grok command | never | never | never | the global config still loads — --deny * refuses the call |
| OpenCode command | never | never | never | the global config still loads — permission: deny refuses the call |
| Cursor command | never | never | never | the global config still loads — ask mode and withheld approval refuse the call |
| OpenRouter | plugins: [{id: "web"}] — OpenRouter's own layer, any model |
image_url part, only for models whose catalog lists the image modality |
never yet — its catalog publishes a file modality Tinycast does not read |
tools + role: "tool" turns |
| OpenAI | not offered | image_url part, assumed supported |
file part with filename and a file_data data URL |
tools + role: "tool" turns |
| Gemini / compatible | not offered | image_url part, assumed supported |
never — a gateway that has not implemented the part bills the upload before rejecting it | tools + role: "tool" turns |
| Anthropic | not offered | base64 image block |
base64 document block, ahead of the text block |
tools + tool_use / tool_result blocks |
A search is part of the reply, not a status: item/started for a webSearch item appends a
ChatSearch to the streaming message pinned at the text length so far, item/completed (or the
next text delta, or the turn ending) marks it finished and fills in the query if the start didn't
carry it. ChatMessage.segments splits the text around its searches so the transcript renders
text, a search row (spinner → globe, "Searching web" → "Searched web · query"), then the rest, in
the order it happened. Searches persist in message_searches. OpenRouter's web plugin is invisible
to the stream, so it shows none. The web-search instructions ask for citations linked by the
publication's name — the model otherwise labels them "Read more".
AIModelCapabilities says what the footer may offer for the selected model. OpenRouter is the only
catalog that reports architecture.input_modalities, so it is the only provider gated on it:
Settings records a model into AIConnection.visionModels when it is added from that catalog, and a
model added by hand is assumed text-only. A vendor API is assumed to take images; a model that
doesn't simply returns the provider's error.
Web search is a Settings → AI toggle, aiWebSearch, off by default: a prompt reaches a search engine
only once the user has opted in.
It's still excluded from backups — which Mac may send prompts to a search engine is that Mac's call.
Nothing guesses at a capability: images ride on what the model's own catalog said, and a vendor
API that does not take one simply returns its error. What is gated is only what a route provably
cannot carry — a PDF to a text transport — refused at the composer with a HUD naming the reason.
AIModelCapabilities.documents is true only for the two HTTP shapes whose bodies Tinycast writes;
a gateway that has not implemented the file part would bill the upload before rejecting it, which
is why documents are not assumed the way images are. An attachment is never dropped on the way
out: answering a question about a document the model never received is the one outcome this must
not produce.
Attachments arrive by ⌘V, in three kinds: an image, a PDF sent as a native document block,
and a text-ish file whose contents are inlined as fenced, named text.
PaletteWindowController's command-shortcut hook gives chat the chord first; a pasteboard holding
file URLs or a bare image (a screenshot) stages them, while anything else carrying text falls
through to the field editor as a normal paste. Only isFileURL URLs are read — without that
filter a copied https://…/a.png reaches Data(contentsOf:), turning a keystroke into a network
request. Every file is sized before it is read, so a huge CSV can never be slurped into memory.
A text file is inlined by ChatSession.boundedContext, never into ChatMessage.text. That
seam is load-bearing: ChatSession.title summarises the first user message, so folding a CSV into
the stored text would make the dump the conversation's title in Chat History, its preview, and what
the user bubble renders back. Inlining after the transcript and before the transport also means
only the newest turn carries it, so a chat's payload cannot grow turn on turn. The block names the
file and fences it with a run longer than any inside it, so a Markdown file holding its own fence
cannot escape; a staged name is stripped of newlines and capped, so a file called
a\nAttached file: passwd cannot forge a second header. Undecodable bytes are refused rather than
guessed at, and a file past AIAttachmentBudget.maxInlinedTextBytes is refused rather than
truncated — a silently truncated CSV is a lie the model then answers confidently.
AIAttachmentPolicy is the one place deciding what may be attached and as what. It is pure and
Foundation-only, so ai-chat-test pins it. It uses extension allowlists rather than
UTType.conforms(to:): a machine's installed apps declare types, so a conformance answer differs
between two Macs and would make a harness machine-dependent — the exact environment coupling
Model/ exists to keep out. Adding a type is a one-line change; a type answer that differs per Mac
is a bug you cannot reproduce.
Images are re-encoded to PNG and bounded to 1568px on the long edge, off-main on a detached task so
a display-sized screenshot does not decode on the keystroke; one past AIAttachmentBudget is refused
with a HUD instead of being staged. Because that decode outlives the keystroke, it shares the staged
images' lifetime exactly: whatever consumes or clears them — a send, a new chat, Remove Attachments,
or leaving the conversation for another through history — disowns one still in flight and says so,
rather than letting it surface on a later message. The counter that decides this sits on
AIChatState beside the staged images, so a route that drops them cannot forget to move it. A
staged attachment shows as a pill beside the typed text — an image carrying a small preview of
itself, a PDF and a text file their own glyph, each followed by the file name, or "Image" for a
screenshot. Every kind is labelled, images included: a bare thumbnail beside an ✕ reads as two
stray marks rather than one pill, and the capsule needs something to wrap. The thumbnail is
deliberately smaller than the pill's height for the same reason. Each pill carries its own ✕, so a
mispaste is taken back without clearing the rest — ⌘K → Remove Attachments and bare backspace stay
as the bulk and last-one routes. Past two pills the rest collapse into a +N count, because
the strip's width is taken out of the search field: three named pills leave too little room to read
what you are typing. The preview is a ~1 KB PNG downsampled on the same detached task that encodes the
attachment and carried on the staged attachment itself, so a header re-rendered per keystroke
decodes nothing and there is no cache whose lifetime could drift from the staging counter's.
Each pill states its own width through AttachmentChip.width(for:), which
RootPaletteView.searchFieldWidth(for:) subtracts from the search field — so the two must move
together or the caret drifts. Pills ride the same headerAccessory the launcher's argument fields use, so the field shrinks to
its text and the chip follows it rather than the composer growing. Bare backspace on an empty composer removes the last
chip before it backs out of chat; ⌘K → Remove Attachments clears them all. Sent images persist in message_images and sent PDFs in message_documents beside their message;
the bubble renders images as thumbnails and documents as the same named chips the composer showed.
A text file is already in the message's text and needs no table. The schema is
CREATE TABLE IF NOT EXISTS re-applied on every open, so the table needed no migration, and its
ON DELETE CASCADE leaves prune unchanged.
The switcher's glyph comes from the selection. Codex uses OpenAI's mark; Claude and Grok use their own
marks; OpenCode resolves a brand from the model id or falls back to a sparkle; Cursor uses an SF Symbol;
an API model resolves through its connection. It never depends on modelOptions, which for
Codex is empty until the app-server has answered model/list; opening the chat on a Codex model warms that list so the title is the
display name from the first frame. Tab hands chat on to the clipboard, and Escape on an empty
composer takes chat's own back step — to whatever opened it, or out of the palette when its hotkey
did; either way the unsent draft is dropped rather than carried into a field that would search it.
History is pushed over chat and pops back to it, and Tab carries its query to the launcher because
there the field really is a search. Neither exit touches the conversation: it lives on AIChatState,
so Tab away and back resumes the same transcript.
The model switcher is fixedSize with its title shortened in AIChatCoordinator (26 characters,
middle ellipsis) rather than truncated by layout: a flexible label claimed the row up to its max
width and clipped the search field well short of the button.
Settings and backup boundary
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. The
chat header changes the same default without a trip to Settings, 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
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
real address can be recovered from a still frame while a blurred fake one cannot. It hides an
address from a camera, not a secret from an attacker: the length still shows and one click undoes
it. The scramble is not selectable, since dragging it out would only ever yield the stand-in.
The System prompt box appends to the preamble rather than replacing it, so the model never loses
the ground truth about where it is. SystemPromptEditor opens blurred and non-editable whenever it
already holds something — a Settings pane is exactly what ends up in a screenshot or a stream — and
opens plain when it is empty, since a blurred empty box is only a puzzle. The footer says the text
rides along on every turn, because it is billed on every turn and nothing else in the pane is.
Send a system prompt governs the whole instruction, not just the half the user typed. Clearing the
box already withholds their own text, so a switch that spared the preamble would add nothing; the
preamble is the part that is billed on every turn for every user and has no other way off. Off
disables the editor rather than hiding it, so what is being withheld stays readable. One thing it
deliberately cannot reach: the Codex route always prepends its own instruction never to invoke
tools, run commands or touch files. That is a sandbox boundary on a local CLI, not Tinycast
describing itself, and a user switch must not be able to lift it.
mcpEnabled and mcpServers are excluded for the reasons in mcp.md.
aiConnections, aiDefaultModel, aiSystemPrompt and aiSystemPromptEnabled are deliberately
excluded from settings backups. The first is meaningless without machine-local Keychain items; the
second names an external destination and must not silently redirect AI traffic after an import; the
last two are standing instructions and the switch that sends them, both of which change every
answer and must not arrive on another Mac unread. aiRetention, aiOpensTo and aiNewChatAfter
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.