diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index 77c5a4d9e8..8a09d9c1c1 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -33,6 +33,7 @@ import { } from "../queries/query-keys"; import { allThreadDefaultExecutionOptionsQueryKeyPrefix } from "../queries/thread-default-execution-options-query"; import type { QueryClientArg } from "../cache-effect-types"; +import { clearCachedModelCatalogs } from "@/lib/model-catalog-cache"; import { bumpAllDiffPatchEvictionGenerations } from "./environment-diff-patch-cache-owner"; import { invalidateSystemVersion } from "./system-version-cache-owner"; import { @@ -167,6 +168,22 @@ export function invalidateGeneralSettingsDependencies({ }); } +/** + * Forget every model catalog after streamer mode flips. An invalidation would + * keep showing the previous catalog, and the localStorage preload would replay + * it on the next mount, until a refetch succeeds; both can still name a model + * the server now hides. A reset drops the data first, so open pickers show a + * loading state and refetch instead of the stale list. + */ +export function resetModelCatalogsAfterStreamerModeChange({ + queryClient, +}: QueryClientArg): Promise { + clearCachedModelCatalogs(); + return queryClient.resetQueries({ + queryKey: allSystemExecutionOptionsQueryKeyPrefix(), + }); +} + function getServerReconnectInvalidationQueryKeys(): QueryKey[] { return [ hostsQueryKey(), diff --git a/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts b/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts index bf7551f2d4..c5727f214a 100644 --- a/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts @@ -47,3 +47,14 @@ export function rollbackKeyboardSettingsCacheTransaction({ if (transaction?.previous === undefined) return; queryClient.setQueryData(systemConfigQueryKey(), transaction.previous); } + +/** + * The streamer mode value the cache last saw from the server, or undefined + * when `/system/config` has not resolved in this window. + */ +export function readCachedStreamerMode( + queryClient: QueryClient, +): boolean | undefined { + return queryClient.getQueryData(systemConfigQueryKey()) + ?.generalSettings.streamerMode; +} diff --git a/apps/app/src/hooks/mutations/settings-mutations.test.tsx b/apps/app/src/hooks/mutations/settings-mutations.test.tsx index 33774a10f0..95c6042536 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/settings-mutations.test.tsx @@ -10,10 +10,16 @@ import { type AppKeybindings, } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + modelCatalogCacheKey, + readCachedModelCatalog, + writeCachedModelCatalog, +} from "@/lib/model-catalog-cache"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { systemConfigQueryKey, + systemExecutionOptionsQueryKey, threadTimelineQueryKey, threadTimelineTurnSummaryDetailsQueryKey, } from "../queries/query-keys"; @@ -72,11 +78,12 @@ function systemConfig(): SystemConfigResponse { afterEach(() => { cleanup(); + window.localStorage.clear(); vi.clearAllMocks(); }); describe("general settings mutation", () => { - it("invalidates config and timeline projections after visibility changes", async () => { + it("invalidates config and timeline projections and leaves model catalogs alone for a non-streamer write", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); const configKey = systemConfigQueryKey(); const timelineKey = threadTimelineQueryKey("thread-1"); @@ -86,9 +93,15 @@ describe("general settings mutation", () => { sourceSeqStart: 1, sourceSeqEnd: 2, }); + const executionOptionsKey = systemExecutionOptionsQueryKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); queryClient.setQueryData(configKey, systemConfig()); queryClient.setQueryData(timelineKey, {}); queryClient.setQueryData(summaryKey, {}); + queryClient.setQueryData(executionOptionsKey, { models: ["cached"] }); const nextSettings = { ...defaultAppSettings, showUnhandledProviderEvents: true, @@ -104,6 +117,50 @@ describe("general settings mutation", () => { expect(queryClient.getQueryState(configKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(summaryKey)?.isInvalidated).toBe(true); + // The server's `config-changed` broadcast already refreshes catalogs; an + // unrelated preference must not add a second picker refetch here. + expect(queryClient.getQueryState(executionOptionsKey)?.isInvalidated).toBe( + false, + ); + expect(queryClient.getQueryData(executionOptionsKey)).toEqual({ + models: ["cached"], + }); + }); + + // A stale catalog can still name a model the server now hides, both in the + // active query and in the localStorage preload, until a refetch succeeds. + it("drops cached model catalogs when streamer mode flips", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const executionOptionsKey = systemExecutionOptionsQueryKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); + const catalogCacheKey = modelCatalogCacheKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); + queryClient.setQueryData(systemConfigQueryKey(), systemConfig()); + queryClient.setQueryData(executionOptionsKey, { models: ["secret"] }); + writeCachedModelCatalog(catalogCacheKey, { + models: [], + selectedOnlyModels: [], + }); + expect(readCachedModelCatalog(catalogCacheKey)).not.toBeNull(); + const nextSettings = { ...defaultAppSettings, streamerMode: true }; + vi.mocked(sdk.system.updateGeneralSettings).mockResolvedValue(nextSettings); + const { result } = renderHook(() => useUpdateGeneralSettings(), { + wrapper, + }); + + act(() => result.current.mutate(nextSettings)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + await waitFor(() => + expect(queryClient.getQueryData(executionOptionsKey)).toBeUndefined(), + ); + expect(readCachedModelCatalog(catalogCacheKey)).toBeNull(); }); }); diff --git a/apps/app/src/hooks/mutations/settings-mutations.ts b/apps/app/src/hooks/mutations/settings-mutations.ts index c415f84925..6cf1eb6e55 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.ts +++ b/apps/app/src/hooks/mutations/settings-mutations.ts @@ -10,9 +10,11 @@ import { sdk } from "@/lib/sdk"; import { invalidateGeneralSettingsDependencies, invalidateSystemConfig, + resetModelCatalogsAfterStreamerModeChange, } from "../cache-owners/system-cache-effects"; import { beginKeyboardSettingsCacheTransaction, + readCachedStreamerMode, rollbackKeyboardSettingsCacheTransaction, } from "../cache-owners/system-config-cache-owner"; @@ -50,8 +52,14 @@ export function useUpdateGeneralSettings() { }, mutationFn: (settings: AppSettings) => sdk.system.updateGeneralSettings(settings), - onSuccess: () => { + onSuccess: (_settings, written) => { + // Read the previous value before the config invalidation replaces it. + const previous = readCachedStreamerMode(queryClient); invalidateGeneralSettingsDependencies({ queryClient }); + // An unknown previous value also resets: a stale preload is the risk. + if (previous !== written.streamerMode) { + void resetModelCatalogsAfterStreamerModeChange({ queryClient }); + } }, }); } @@ -107,8 +115,7 @@ export function useUpdateAppearance() { meta: { errorMessage: "Failed to update appearance.", }, - mutationFn: (selection: AppThemeSelection) => - sdk.theme.set(selection), + mutationFn: (selection: AppThemeSelection) => sdk.theme.set(selection), onSuccess: () => { invalidateSystemConfig({ queryClient }); }, diff --git a/apps/app/src/lib/last-known-cache.ts b/apps/app/src/lib/last-known-cache.ts index 4f520d0850..cbf8b3fd73 100644 --- a/apps/app/src/lib/last-known-cache.ts +++ b/apps/app/src/lib/last-known-cache.ts @@ -11,6 +11,11 @@ export interface LastKnownCache { read(key: string): T | null; /** Best-effort: storage failures (quota, privacy modes) are swallowed. */ write(key: string, value: T): void; + /** + * Forget every scope of this cache's current version. Use it when a policy + * change makes every remembered answer wrong to replay, not merely stale. + */ + clear(): void; } /** @@ -96,5 +101,22 @@ export function createLastKnownCache({ // Best-effort by contract; see above. } }, + clear: () => { + try { + const owned: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const stored = window.localStorage.key(index); + if ( + stored !== null && + (stored === zeroScopeKey || stored.startsWith(versionPrefix)) + ) { + owned.push(stored); + } + } + for (const key of owned) window.localStorage.removeItem(key); + } catch { + // No storage, or none we may enumerate: nothing to clear. + } + }, }; } diff --git a/apps/app/src/lib/model-catalog-cache.ts b/apps/app/src/lib/model-catalog-cache.ts index edf367bf46..2700317cc4 100644 --- a/apps/app/src/lib/model-catalog-cache.ts +++ b/apps/app/src/lib/model-catalog-cache.ts @@ -38,3 +38,8 @@ export function modelCatalogCacheKey({ export const readCachedModelCatalog = modelCatalogCache.read; export const writeCachedModelCatalog = modelCatalogCache.write; +/** + * Drop every remembered catalog. Streamer mode changes which models the server + * lists, so a catalog cached before the toggle must not preload the picker. + */ +export const clearCachedModelCatalogs = modelCatalogCache.clear; diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index ad0106ac84..0c5d259188 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -204,6 +204,7 @@ function useSettingsStoryState() { const [richTextEditing, setRichTextEditing] = useState(false); const [steerActiveThreadOnEnter, setSteerActiveThreadOnEnter] = useState(false); + const [streamerMode, setStreamerMode] = useState(false); const [showUnhandledProviderEvents, setShowUnhandledProviderEvents] = useState(false); const [preferredAudioInputDeviceId, setPreferredAudioInputDeviceId] = @@ -226,6 +227,7 @@ function useSettingsStoryState() { rewriteLocalhostLinks, richTextEditing, steerActiveThreadOnEnter, + streamerMode, showUnhandledProviderEvents, setAppearance, setDirectoryTargetId, @@ -237,6 +239,7 @@ function useSettingsStoryState() { setRewriteLocalhostLinks, setRichTextEditing, setSteerActiveThreadOnEnter, + setStreamerMode, setShowUnhandledProviderEvents, setThemePreference, themePreference, @@ -278,11 +281,14 @@ function GeneralSettingsStory({ onRewriteLocalhostLinksChange={state.setRewriteLocalhostLinks} onRichTextEditingChange={state.setRichTextEditing} onSteerActiveThreadOnEnterChange={state.setSteerActiveThreadOnEnter} + onStreamerModeChange={state.setStreamerMode} openLinksInAppBrowser={state.openLinksInAppBrowser} rewriteLocalhostLinks={state.rewriteLocalhostLinks} richTextEditing={state.richTextEditing} steerActiveThreadOnEnter={state.steerActiveThreadOnEnter} steerActiveThreadOnEnterDisabled={false} + streamerMode={state.streamerMode} + streamerModeDisabled={false} /> void; } +export interface StreamerModeSettingsControlProps { + disabled: boolean; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; +} + export interface RichTextEditingSettingsControlProps { enabled: boolean; onEnabledChange: (enabled: boolean) => void; @@ -184,11 +190,14 @@ export interface GeneralSettingsSectionProps { onRewriteLocalhostLinksChange: (enabled: boolean) => void; onRichTextEditingChange: (enabled: boolean) => void; onSteerActiveThreadOnEnterChange: (enabled: boolean) => void; + onStreamerModeChange: (enabled: boolean) => void; openLinksInAppBrowser: boolean; rewriteLocalhostLinks: boolean; richTextEditing: boolean; steerActiveThreadOnEnter: boolean; steerActiveThreadOnEnterDisabled: boolean; + streamerMode: boolean; + streamerModeDisabled: boolean; } export type DebugSettingsSectionProps = @@ -564,6 +573,7 @@ const UNHANDLED_PROVIDER_EVENTS_SETTING_LABEL = "Show unhandled provider events"; const STEER_ACTIVE_THREAD_ON_ENTER_SETTING_LABEL = "Steer running threads on Enter"; +const STREAMER_MODE_SETTING_LABEL = "Streamer mode"; export function RootComposeBehaviorSettingsControl({ navigateToThreadAfterCreate, @@ -600,6 +610,26 @@ export function SteerActiveThreadOnEnterSettingsControl({ ); } +export function StreamerModeSettingsControl({ + disabled, + enabled, + onEnabledChange, +}: StreamerModeSettingsControlProps) { + return ( + + + + ); +} + export function InAppBrowserLinkSettingsControl({ enabled, onEnabledChange, @@ -833,11 +863,14 @@ export function GeneralSettingsSection({ onRewriteLocalhostLinksChange, onRichTextEditingChange, onSteerActiveThreadOnEnterChange, + onStreamerModeChange, openLinksInAppBrowser, rewriteLocalhostLinks, richTextEditing, steerActiveThreadOnEnter, steerActiveThreadOnEnterDisabled, + streamerMode, + streamerModeDisabled, }: GeneralSettingsSectionProps) { return ( @@ -871,6 +904,12 @@ export function GeneralSettingsSection({ enabled={rewriteLocalhostLinks} onEnabledChange={onRewriteLocalhostLinksChange} /> + + ); @@ -1275,6 +1314,17 @@ export function SettingsView() { steerActiveThreadOnEnter: enabled, }) } + streamerMode={generalSettings.streamerMode} + streamerModeDisabled={ + systemConfigQuery.data === undefined || + updateGeneralSettingsMutation.isPending + } + onStreamerModeChange={(enabled) => + updateGeneralSettingsMutation.mutate({ + ...generalSettings, + streamerMode: enabled, + }) + } /> diff --git a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx index cce9f95ea7..cabee93736 100644 --- a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx +++ b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx @@ -72,6 +72,22 @@ function ConnectedGeneralSettingsScreen() { /> + + + updateGeneral.mutate({ + ...settings, + streamerMode: value, + }) + } + testID="general-streamer-mode" + /> + + `. +- The `streamerMode` General preference defaults to false. Enable it to hide + every `customModels` entry from `~/.bb/config.json` in all model lists + (pickers, `bb provider models`, and the SDK) during a screen share. Update it + with `bb settings general streamerMode `. - Settings → Keyboard records server-backed per-command shortcut overrides. The `showKeyboardHints` preference controls the delayed badges shown while holding Command or Control and defaults to true; update it with @@ -359,6 +363,7 @@ environment pull-request show `. Diff commands require an explicit target and bb discovers it automatically. An OpenCode agent is a session mode, not a model, and cannot be selected through bb. This list also has no set/unset CLI surface; edit the JSON and run `bb-app config refresh` or restart bb. + The `streamerMode` General preference hides every entry from model lists. - Top-level `sharedSkillRoots` uses the same relative `user` and `project` paths. bb lists these skills as read-only. bb injects them into each provider, so one physical skill collection can support bb and standalone provider CLIs. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index 28e60f7e95..79c6797c00 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -50,6 +50,21 @@ every window and client sees the same value. stays a newline; iPadOS WebKit preserves the Enter shortcuts for a connected Magic Keyboard. +## Streamer mode + +- `streamerMode` defaults to false. Set it with + `bb settings general streamerMode `. +- When enabled, every `customModels` entry from `~/.bb/config.json` is hidden + in all model lists: the pickers, `bb provider models`, and + `sdk.providers.models`. Use it during a screen share so a private or + early-access model id does not appear. +- The entries stay in `config.json`. A thread request that names a hidden model + explicitly still runs with it, and default model resolution for a new thread + keeps the full list. +- A composer whose stored selection is a hidden model falls back to the + provider default, and the next send records that default. Select the custom + model again after you turn streamer mode off. + ## Mobile app - The `mobileApp` experiment defaults to false while the bb mobile app is in diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index 7dbdf0a0ec..ce70c221a1 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -18,6 +18,7 @@ import { type AvailableModel, type ProviderInfo, } from "@bb/domain"; +import { getAppSettings } from "@bb/db"; import { normalizeHostDaemonAcpLaunchSpec, type HostDaemonRetryableOnlineRpcCommand, @@ -342,7 +343,10 @@ function findCustomAcpAgentForProviderId( * Load one provider's model catalog on an already-resolved host. Unlike the * full execution-options response, this does not probe for other installed ACP * agents, so thread creation can resolve an omitted model with one targeted - * daemon request. + * daemon request. This is execution policy, not a public list, so it keeps + * every custom model: streamer mode must not change which default model a + * thread resolves to, and a provider whose only models come from config.json + * must still be able to start a thread. */ export async function resolveSystemProviderModels( deps: LoggedWorkSessionDeps, @@ -383,6 +387,23 @@ export async function resolveSystemProviderModels( }; } +/** + * The config.json custom models that public model lists may show. Streamer + * mode hides all of them: a custom entry is often a private or early-access + * model id, and the execution-options response is where every picker, the CLI, + * and the SDK read them from. Execution policy is unaffected: an explicit + * thread model request bypasses the catalog, and `resolveSystemProviderModels` + * keeps the full list for default resolution. + */ +export function listVisibleCustomModels( + deps: Pick, +): CustomProviderModel[] { + if (deps.config.customModels.length === 0) { + return deps.config.customModels; + } + return getAppSettings(deps.db).streamerMode ? [] : deps.config.customModels; +} + function buildCustomModel( registry: ProviderRegistryService, customModel: CustomProviderModel, @@ -533,7 +554,7 @@ export async function resolveSystemExecutionOptions( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: deps.config.customModels, + customModels: listVisibleCustomModels(deps), models: [], providerId: modelsProvider.id, selectedOnlyModels: [], @@ -566,7 +587,7 @@ export async function resolveSystemExecutionOptions( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: deps.config.customModels, + customModels: listVisibleCustomModels(deps), models: modelResult.models, providerId: modelsProvider.id, selectedOnlyModels: modelResult.selectedOnlyModels, diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index aa6f351c13..994e95043a 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { getAppSettings, setAppSettings } from "@bb/db"; import { hostDaemonServerWsMessageSchema, type HostDaemonOnlineRpcRequestMessage, @@ -7,6 +8,7 @@ import { appendCustomModels, listSystemProviderInfos, resolveSystemExecutionOptions, + resolveSystemProviderModels, } from "../../src/services/system/execution-options.js"; import { ApiError } from "../../src/errors.js"; import { availableModelFixture } from "../helpers/available-models.js"; @@ -858,6 +860,104 @@ describe("resolveSystemExecutionOptions", () => { ); }); + it("hides custom models while streamer mode is on and restores them when it is off", async () => { + await withTestHarness( + { + customModels: [ + { + providerId: "claude-code", + model: "claude-example-preview", + displayName: "Example Preview", + }, + ], + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-execution-options-streamer-mode", + }); + const catalogModel = availableModelFixture({ model: "claude-opus-5" }); + const responder = registerProviderHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + modelsByProviderId: { + "claude-code": { models: [catalogModel], selectedOnlyModels: [] }, + }, + }); + const listModelIds = async () => + ( + await resolveSystemExecutionOptions(harness.deps, { + hostId: host.id, + providerId: "claude-code", + }) + ).models.map((model) => model.model); + + expect(await listModelIds()).toEqual([ + "claude-opus-5", + "claude-example-preview", + ]); + + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: true, + }); + expect(await listModelIds()).toEqual(["claude-opus-5"]); + + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: false, + }); + expect(await listModelIds()).toEqual([ + "claude-opus-5", + "claude-example-preview", + ]); + // The toggle filters after the memoized probe, so it never re-probes + // the daemon. + expect( + responder.requests.filter( + (request) => request.command.type === "provider.list_models", + ), + ).toHaveLength(1); + }, + ); + }); + + it("keeps custom models in the thread-create default catalog while streamer mode is on", async () => { + await withTestHarness( + { + customModels: [ + { providerId: "claude-code", model: "claude-example-preview" }, + ], + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-provider-models-streamer-mode", + }); + // A provider whose only models come from config.json must still + // resolve a default for a thread created without an explicit model. + registerProviderHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + modelsByProviderId: { + "claude-code": { models: [], selectedOnlyModels: [] }, + }, + }); + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: true, + }); + + const catalog = await resolveSystemProviderModels(harness.deps, { + hostId: host.id, + providerId: "claude-code", + }); + + expect(catalog.models.map((model) => model.model)).toEqual([ + "claude-example-preview", + ]); + }, + ); + }); + it("serves the curated Claude catalog when the model probe fails transiently", async () => { await withTestHarness({}, async (harness) => { const { host, session } = seedHostSession(harness.deps, { diff --git a/docs/configuration.md b/docs/configuration.md index 899186ab62..29b9f26907 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -176,6 +176,18 @@ defaults to off: Enter queues and Command+Enter steers. When enabled, Enter steers and Command+Enter queues. Set it with `bb settings general steerActiveThreadOnEnter `. +The "Streamer mode" toggle in Settings → General hides every `customModels` +entry from `~/.bb/config.json` in all model lists: the web and mobile pickers, +`bb provider models`, and `sdk.providers.models`. Turn it on before a screen +share so a private or early-access model id does not appear. It defaults to +off. The entries stay in `config.json`, and a thread that names a hidden model +explicitly still runs with it. Default model resolution for a new thread also +keeps the full list, so a provider whose only models are custom still starts. +A composer whose stored selection is a hidden model treats it as unavailable +and falls back to the provider default; the next send records that default, so +select the custom model again after you turn streamer mode off. Set it with +`bb settings general streamerMode `. + Outside an open typeahead menu, Shift+Enter inserts a newline. In zen mode, unmodified Enter also inserts a newline. On coarse-pointer touch devices, the software-keyboard Return path inserts a newline and the submit button sends. @@ -431,7 +443,9 @@ an invalid entry with a warning and keeps the rest of the config. Each entry appears in `bb provider models ` and in the model picker after the provider's own catalog. The provider catalog wins on a model -id collision. +id collision. The "Streamer mode" General setting +(`bb settings general streamerMode true`) hides every entry from these lists +until you turn it off again. A `customModels` entry only makes the id selectable; the provider must still accept it. Built-in providers such as `claude-code` and `codex` accept diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index f85fffe7d4..12e45b5e57 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -1600,6 +1600,7 @@ describe("migrate", () => { codexSubagentsDisabled: true, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: true, + streamerMode: false, }); expect(getAppKeybindingOverrides(db)).toEqual([ { command: "thread.new", shortcut: null }, diff --git a/packages/domain/src/app-settings.ts b/packages/domain/src/app-settings.ts index 88fb35daf1..2319af174d 100644 --- a/packages/domain/src/app-settings.ts +++ b/packages/domain/src/app-settings.ts @@ -29,6 +29,11 @@ export const appSettingsSchema = z claudeCodeSubagentsDisabled: z.boolean(), /** Prevent Claude Code from exposing its native Workflow tool. */ claudeCodeWorkflowsDisabled: z.boolean(), + /** + * Hide the `customModels` entries from `config.json` in every model list + * (pickers, CLI, SDK) so a screen share does not reveal a private model id. + */ + streamerMode: z.boolean(), }) .strict(); export type AppSettings = z.infer; @@ -42,4 +47,5 @@ export const defaultAppSettings: AppSettings = { codexSubagentsDisabled: false, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: false, + streamerMode: false, }; diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index ae761f5d7d..5d6a8d2d13 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -89,6 +89,11 @@ in zen mode. On coarse-pointer touch devices, the software-keyboard Return path inserts a newline. iPadOS WebKit preserves these Enter shortcuts for a connected Magic Keyboard. +Settings → General also includes `streamerMode`, which defaults to false. Turn +it on to hide every `customModels` entry from `~/.bb/config.json` in all model +lists (pickers, `bb provider models`, and the SDK) during a screen share. The +entries stay in the config file. + bb settings show bb settings general bb settings experiment diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 26d4b43c14..5997248ee5 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -107,7 +107,9 @@ in the model picker, but the provider must still accept the id: claude-code and codex accept unlisted ids, while an ACP agent can reject an id it does not know at session start. OpenCode rejects unlisted ids, so add an OpenCode model to the OpenCode config instead. Like customAcpAgents, edit the JSON and -run bb-app config refresh; there is no set/unset CLI surface. +run bb-app config refresh; there is no set/unset CLI surface. The streamerMode +General setting hides every entry from these lists; see the customization +chapter. Custom ACP agents are configured in the app data-dir config.json under customAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON