diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e44f32e60f5ab..1e8b538a665ae 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentThemeFile } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentThemeFile, + ServerSettingsError, +} from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +16,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); @@ -30,6 +35,9 @@ const SHARED_THEME: EnvironmentThemeFile = { colors: { canvas: "#eff1f5", accent: "#1e66f5" }, }; +/** The settings mock refuses to make this theme the default, as a managed policy can. */ +const REFUSED_THEME_ID = "refused"; + const withEnvironmentThemes = ( seeds: Readonly>, body: Effect.Effect< @@ -41,6 +49,8 @@ const withEnvironmentThemes = ( | Path.Path | Scope.Scope >, + /** Receives each theme and appearance the service makes the environment default. */ + selected: Array<{ readonly theme?: string; readonly appearance?: string }> = [], ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -56,6 +66,27 @@ const withEnvironmentThemes = ( Effect.provide( EnvironmentTheme.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge( + Layer.mock(ServerSettingsService)({ + updateSettings: (patch) => + patch.defaultTheme === REFUSED_THEME_ID + ? Effect.fail( + new ServerSettingsError({ + settingsPath: "settings.json", + operation: "write-managed-setting", + }), + ) + : Effect.sync(() => { + selected.push({ + ...(patch.defaultTheme === undefined ? {} : { theme: patch.defaultTheme }), + ...(patch.defaultAppearance === undefined + ? {} + : { appearance: patch.defaultAppearance }), + }); + return DEFAULT_SERVER_SETTINGS; + }), + }), + ), ), ), ); @@ -144,6 +175,62 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("puts the previous theme back when the new one cannot be selected", () => + withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: { ...NIGHTFALL_THEME, name: "Changed" } }) + .pipe(Effect.flip); + assert.strictEqual(refused._tag, "EnvironmentThemeNotSelectedError"); + assert.deepEqual(yield* themes.current, [{ id: REFUSED_THEME_ID, ...NIGHTFALL_THEME }]); + }), + ), + ); + + it.effect("does not replace a theme file it could not put back", () => { + const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) }); + return withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: oversized }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be backed up/); + assert.strictEqual( + yield* fs.readFileString(`${config.environmentThemesDir}/${REFUSED_THEME_ID}.json`), + oversized, + ); + }), + ); + }); + + it.effect.skipIf(!symlinksSupported)("does not write through a symlink at its staging path", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const outside = path.join(environmentThemesDir, "..", "outside.txt"); + yield* fs.writeFileString(outside, "keep"); + yield* fs.symlink( + outside, + path.join(environmentThemesDir, `look.json.staging-${process.pid}`), + ); + + yield* themes.apply({ themeId: "look", theme: NIGHTFALL_THEME }); + assert.strictEqual(yield* fs.readFileString(outside), "keep"); + assert.deepEqual(yield* themes.current, [{ id: "look", ...NIGHTFALL_THEME }]); + }), + ), + ); + it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () => withEnvironmentThemes( {}, @@ -159,6 +246,97 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("publishes and selects a theme, and refuses unsafe CSS and unknown ids", () => { + const selected: Array<{ readonly theme?: string; readonly appearance?: string }> = []; + return withEnvironmentThemes( + {}, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + // A comment may name an address. Only a rule that loads one is refused. + const css = + "/* from url(https://example.com/guide) */ [data-chat-header] { border-bottom: 1px solid red; }"; + yield* themes.apply({ themeId: "agent-look", theme: { ...NIGHTFALL_THEME, css } }); + assert.deepEqual(yield* themes.current, [{ id: "agent-look", ...NIGHTFALL_THEME, css }]); + + const leaky = yield* themes + .apply({ + themeId: "leaky", + theme: { + ...NIGHTFALL_THEME, + css: 'input[value^="a"] { background: url(https://example.com/a); }', + }, + }) + .pipe(Effect.flip); + assert.match(leaky.message, /loads files/); + const reserved = yield* themes + .apply({ themeId: "dark", theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(reserved.message, /not a valid theme id/); + const unknown = yield* themes.apply({ themeId: "missing" }).pipe(Effect.flip); + assert.match(unknown.message, /not a built-in or published theme/); + yield* themes.apply({ themeId: "grove" }); + yield* themes.apply({ themeId: "grove", appearance: "dark" }); + yield* themes.apply({ appearance: "light" }); + + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + ["agent-look"], + ); + // Only a theme that was published or already exists becomes the environment theme. + assert.deepEqual(selected, [ + { theme: "agent-look" }, + { theme: "grove" }, + { theme: "grove", appearance: "dark" }, + { appearance: "light" }, + ]); + }), + selected, + ); + }); + + it.effect("keeps the served theme when an update does not fit the size limit", () => { + const large = (name: string): EnvironmentThemeFile => ({ + ...NIGHTFALL_THEME, + name, + css: `/* ${"x".repeat(19_900)} */`, + }); + return withEnvironmentThemes( + { + ...Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `aa-${index}.json`, + encodeThemeFile(large(`Large ${index}`)), + ]), + ), + "zz-look.json": encodeThemeFile(NIGHTFALL_THEME), + }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const served = Effect.map(themes.current, (all) => + all.find((theme) => theme.id === "zz-look"), + ); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + const refused = yield* themes + .apply({ themeId: "zz-look", theme: large("Too much") }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be published/); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + // A new theme that sorts first fits, and the last large one no longer does. + const before = (yield* themes.current).map((theme) => theme.id); + const displacing = yield* themes + .apply({ themeId: "a0-first", theme: large("Displacing") }) + .pipe(Effect.flip); + assert.match(displacing.message, /could not be published/); + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + before, + ); + }), + ); + }); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 44e50bd103639..b658870f2e960 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -3,20 +3,26 @@ import * as NodeFS from "node:fs"; import { + type DefaultAppearancePreference, EnvironmentTheme, + type ServerSettingsError, EnvironmentThemeFile, EnvironmentThemeId, environmentThemeFileHasColors, } from "@t3tools/contracts"; -import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { themeCssLoadsRemoteFiles } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -25,10 +31,14 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import * as ServerSettings from "./serverSettings.ts"; const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( Schema.fromJsonString(EnvironmentThemeFile), ); +const encodeEnvironmentThemeFileJson = Schema.encodeEffect( + fromJsonStringPretty(EnvironmentThemeFile), +); const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); const THEME_FILE_SUFFIX = ".json"; @@ -43,16 +53,86 @@ interface PublishedThemes { readonly themes: ReadonlyArray; } +const INVALID_ID = + "is not a valid theme id. Use lowercase letters, digits, and hyphens, and not a built-in name."; +const NOT_PUBLISHED = "could not be published. The themes directory may be full or not writable."; + +/** A theme the service will not publish or select. `problem` ends the sentence naming it. */ +export class EnvironmentThemeRefusedError extends Schema.TaggedError()( + "EnvironmentThemeRefusedError", + { + themeId: Schema.String, + problem: Schema.String, + known: Schema.optional(Schema.Array(Schema.String)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const known = this.known ? ` Available: ${this.known.join(", ")}.` : ""; + return `Theme "${this.themeId}" ${this.problem}${known}`; + } +} + +/** The settings write that makes a theme or mode the default failed. */ +export class EnvironmentThemeNotSelectedError extends Schema.TaggedError()( + "EnvironmentThemeNotSelectedError", + { + /** Absent when only the appearance was set. */ + themeId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const subject = this.themeId === undefined ? "The appearance" : `Theme "${this.themeId}"`; + return `${subject} was not applied, because the settings could not be saved.`; + } +} + export class EnvironmentThemeService extends Context.Service< EnvironmentThemeService, { readonly current: Effect.Effect>; + /** The default theme id and the published themes, plus the one named `themeId`. */ + readonly read: (themeId?: string) => Effect.Effect< + { + readonly current: string; + readonly themes: ReadonlyArray; + readonly theme?: EnvironmentTheme; + }, + EnvironmentThemeRefusedError | ServerSettingsError + >; + + /** + * Makes `themeId` the environment theme and `appearance` its mode, so connected clients + * switch to them. With `theme`, first publishes that file under the id, replacing one already + * there. + */ + readonly apply: ( + input: + | { + readonly themeId: string; + readonly theme?: EnvironmentThemeFile | undefined; + readonly appearance?: Exclude | undefined; + } + | { readonly appearance: Exclude }, + ) => Effect.Effect; + /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; } >()("t3/environmentTheme/EnvironmentThemeService") {} +/** True when anything, a dangling symlink included, is at the path. */ +const pathIsTaken = (filePath: string): boolean => { + try { + NodeFS.lstatSync(filePath); + return true; + } catch { + return false; + } +}; + /** Validates and reads one handle; rejects symlinked files, allows symlinked directories. * Uses non-blocking opens against FIFOs; Windows lacks these flags and checks symlinks with lstat. */ export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { @@ -156,6 +236,7 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); @@ -197,8 +278,146 @@ const make = Effect.gen(function* () { Effect.asVoid, ); + // One publish at a time: they share a staging path, and each one checks what is served after it. + const publishSemaphore = yield* Semaphore.make(1); + + /** Callers hold `publishSemaphore`. Succeeds with the effect that undoes this publish. */ + + const publish = Effect.fnUntraced(function* (themeId: string, file: EnvironmentThemeFile) { + if (UNPUBLISHABLE_THEME_IDS.has(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + if (!environmentThemeFileHasColors(file)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "has no colors. Give canvas and accent, or at least one color role.", + }); + } + if (file.css !== undefined && themeCssLoadsRemoteFiles(file.css)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: + "has CSS that loads files. Remove @import, image-set(), and url() that is not data: or #id.", + }); + } + const contents = yield* encodeEnvironmentThemeFileJson(file).pipe( + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + if (Buffer.byteLength(contents) > MAX_THEME_FILE_BYTES) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: `is larger than ${MAX_THEME_FILE_BYTES} bytes.`, + }); + } + + const destination = `${environmentThemesDir}/${themeId}${THEME_FILE_SUFFIX}`; + // The watcher reads only .json names, so it never sees a half-written file. + const staging = `${destination}.staging-${process.pid}`; + // Exclusive create: a link left at the staging path is removed, never written through. + const replace = (next: string) => + fs.remove(staging, { force: true }).pipe( + Effect.andThen(fs.writeFileString(staging, next, { flag: "wx" })), + Effect.andThen(fs.rename(staging, destination)), + Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore)), + ); + + // The guarded reader does not block on a FIFO and does not follow a symlink. + const previous = Option.fromNullOr(readThemeFileGuarded(destination, MAX_THEME_FILE_BYTES)); + // A file it cannot read back, such as one over the size limit, could not be restored. + if (Option.isNone(previous) && pathIsTaken(destination)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "would replace a file that could not be backed up. Use another theme id.", + }); + } + const servedBefore = (yield* refresh).themes.map((theme) => theme.id); + // Puts back what was there, so a failed apply does not take a working theme away. + const revert = Option.match(previous, { + onNone: () => fs.remove(destination, { force: true }), + onSome: replace, + }).pipe(Effect.ignore, Effect.andThen(refresh), Effect.asVoid); + + yield* fs.makeDirectory(environmentThemesDir, { recursive: true }).pipe( + Effect.andThen(replace(contents)), + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + // The reader stops at its count and size limits, so this file can go unserved, or it can + // push another theme out. Both count as a failed publish. + const served = new Set((yield* refresh).themes.map((theme) => theme.id)); + if (served.has(themeId) && servedBefore.every((id) => served.has(id))) return revert; + yield* revert; + return yield* new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED }); + }); + + const apply: EnvironmentThemeService["Service"]["apply"] = Effect.fn( + "EnvironmentThemeService.apply", + )(function* (input) { + const themeId = "themeId" in input ? input.themeId : undefined; + const theme = "theme" in input ? input.theme : undefined; + const { appearance } = input; + if (themeId !== undefined && !isEnvironmentThemeId(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + // Timestamps make clients apply a set again when its value did not change. Theme and + // appearance have one each, so setting one does not undo a later local choice of the other. + const select = Effect.gen(function* () { + const setAt = DateTime.formatIso(yield* DateTime.now); + yield* settings + .updateSettings({ + ...(themeId === undefined ? {} : { defaultTheme: themeId, defaultThemeSetAt: setAt }), + ...(appearance === undefined + ? {} + : { defaultAppearance: appearance, defaultAppearanceSetAt: setAt }), + }) + .pipe(Effect.mapError((cause) => new EnvironmentThemeNotSelectedError({ themeId, cause }))); + }); + // One at a time, so a selection never sees a file that a failed publish then takes back. + // If the theme cannot be selected, the file it replaced comes back. + yield* publishSemaphore.withPermits(1)( + Effect.gen(function* () { + if (themeId === undefined) return yield* select; + if (theme === undefined) { + const known = [...BUILT_IN_THEME_IDS, ...(yield* refresh).themes.map(({ id }) => id)]; + if (!known.includes(themeId)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a built-in or published theme.", + known, + }); + } + return yield* select; + } + const revert = yield* publish(themeId, theme); + yield* select.pipe(Effect.tapError(() => revert)); + }), + ); + }); + + const read: EnvironmentThemeService["Service"]["read"] = Effect.fn( + "EnvironmentThemeService.read", + )(function* (themeId) { + const { themes } = yield* refresh; + const { defaultTheme } = yield* settings.getSettings; + if (themeId === undefined) return { current: defaultTheme, themes }; + const theme = themes.find((candidate) => candidate.id === themeId); + if (theme === undefined) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a published theme.", + known: themes.map(({ id }) => id), + }); + } + return { current: defaultTheme, themes, theme }; + }); + return { current: Effect.map(refresh, (state) => state.themes), + read, + apply, get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 597e78d810084..062c51ac6d7c2 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -53,6 +53,8 @@ import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; +import { ThemeToolkit } from "./toolkits/theme/tools.ts"; +import * as ThemeHandlers from "./toolkits/theme/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; @@ -1258,6 +1260,10 @@ const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); +const layerThemeToolkit = toolkitRegistration(ThemeToolkit, ThemeHandlers.layer).pipe( + Layer.provide(ThreadCommandExecutor.layer), +); + export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); export const layerAttachmentToolkit = toolkitRegistration( @@ -1323,6 +1329,7 @@ export const layerTools = Layer.mergeAll( layerAttachmentToolkit, layerProjectRegistration, layerEnvironmentToolkit, + layerThemeToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, diff --git a/apps/server/src/mcp/toolkits/theme/handlers.test.ts b/apps/server/src/mcp/toolkits/theme/handlers.test.ts new file mode 100644 index 0000000000000..4da9c8a15f421 --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type EnvironmentThemeFile, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { + EnvironmentThemeRefusedError, + EnvironmentThemeService, +} from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as ThemeHandlers from "./handlers.ts"; +import { ThemeToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:theme"); +const threadId = ThreadId.make("thread:theme"); +const AGENT_THEME = { + name: "Agent Look", + appearance: "dark", + canvas: "#101010", + accent: "#7aa2f7", + css: "[data-chat-header] { border-bottom: 1px solid red; }", +} satisfies EnvironmentThemeFile; + +it.effect("applies the theme through the service and reports a refusal as an invalid request", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:theme", + thread: { + threadId, + providerSessionId: "provider:theme", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(liveThreadShell(threadId)), + }), + Layer.mock(EnvironmentThemeService)({ + apply: (input) => + "themeId" in input && input.themeId === "missing" + ? Effect.fail( + new EnvironmentThemeRefusedError({ + themeId: input.themeId, + problem: "is not a built-in or published theme.", + }), + ) + : Effect.void, + }), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ThemeToolkit; + const call = (params: Parameters>[1]) => + toolkit.handle("t3_theme_set", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((parts) => parts.at(-1)?.result), + ); + + expect(yield* call({ themeId: "agent-look", theme: AGENT_THEME })).toEqual({ + themeId: "agent-look", + published: true, + }); + expect(yield* call({ themeId: "grove" })).toEqual({ themeId: "grove", published: false }); + expect(yield* call({ appearance: "dark" })).toEqual({ + appearance: "dark", + published: false, + }); + expect(yield* call({ theme: AGENT_THEME })).toMatchObject({ code: "invalid_request" }); + expect(yield* call({})).toMatchObject({ code: "invalid_request" }); + expect(yield* call({ themeId: "missing" })).toMatchObject({ + code: "invalid_request", + message: 'Theme "missing" is not a built-in or published theme.', + }); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ThemeHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/theme/handlers.ts b/apps/server/src/mcp/toolkits/theme/handlers.ts new file mode 100644 index 0000000000000..ad469095b0e0d --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.ts @@ -0,0 +1,79 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import { THEME_GUIDE } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Effect from "effect/Effect"; +import * as Struct from "effect/Struct"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { unavailable } from "../../threadAccess.ts"; +import { ThemeToolkit } from "./tools.ts"; + +export const layer = McpToolAccess.toLayer(ThemeToolkit, { + t3_theme_read: McpToolAccess.reads(({ themeId }) => + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const { + current, + themes: published, + theme, + } = yield* themes.read(themeId).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + ServerSettingsError: () => Effect.fail(unavailable()), + }), + ); + return { + current, + builtIn: BUILT_IN_THEME_IDS, + published: published.map(({ id, name, css }) => ({ id, name, hasCss: Boolean(css) })), + ...(theme === undefined ? {} : { theme: Struct.omit(theme, ["id"]) }), + guide: THEME_GUIDE, + }; + }), + ), + t3_theme_set: McpToolAccess.writesEnvironment(({ themeId, theme, appearance }, check) => + Effect.gen(function* () { + const input = + themeId !== undefined + ? { themeId, theme, appearance } + : theme === undefined && appearance !== undefined + ? { appearance } + : undefined; + if (input === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Give `themeId`, `appearance`, or both. `theme` needs a `themeId`.", + }); + } + const scope = yield* McpInvocationContext.McpInvocationContext; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const set = Effect.gen(function* () { + // The turn may have ended, or the thread's modes changed, while this waited for the lock. + yield* check; + yield* themes.apply(input).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + EnvironmentThemeNotSelectedError: () => Effect.fail(unavailable()), + }), + ); + return { + ...(themeId === undefined ? {} : { themeId }), + ...(appearance === undefined ? {} : { appearance }), + published: theme !== undefined, + }; + }); + return yield* scope.thread === undefined + ? set + : executor.withLock(scope.thread.threadId, set); + }), + ), +}); diff --git a/apps/server/src/mcp/toolkits/theme/tools.ts b/apps/server/src/mcp/toolkits/theme/tools.ts new file mode 100644 index 0000000000000..291a34f6ecb6b --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/tools.ts @@ -0,0 +1,69 @@ +import { + DefaultThemePreference, + EnvironmentThemeFile, + EnvironmentThemeId, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; + +const Appearance = Schema.Literals(["light", "dark", "system"]); + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + EnvironmentTheme.EnvironmentThemeService, + ThreadCommandExecutor.ThreadCommandExecutor, + ], +}; + +const ThemeReadTool = Tool.make("t3_theme_read", { + ...shared, + description: + "Read T3 Code's own themes and the guide to restyle it: color roles and CSS selectors. Call before `t3_theme_set`.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey( + EnvironmentThemeId.annotate({ description: "Also return this published theme's file." }), + ), + }), + success: Schema.Struct({ + current: DefaultThemePreference, + builtIn: Schema.Array(Schema.String), + published: Schema.Array( + Schema.Struct({ + id: EnvironmentThemeId, + name: Schema.String, + hasCss: Schema.Boolean, + }), + ), + theme: Schema.optionalKey(EnvironmentThemeFile), + guide: Schema.String, + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const ThemeSetTool = Tool.make("t3_theme_set", { + ...shared, + description: + "Restyle T3 Code itself, only when the user asks. With `theme`, publish it as `themeId` (the same id updates it) and switch this machine's web and desktop clients to it. Without `theme`, switch to a built-in or published `themeId`. `appearance` switches them to light, dark, or system mode, with or without a theme. Live full-access or default caller thread only.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + theme: Schema.optionalKey(EnvironmentThemeFile), + appearance: Schema.optionalKey(Appearance), + }), + success: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + appearance: Schema.optionalKey(Appearance), + published: Schema.Boolean, + }), +}).annotate(Tool.Destructive, true); + +export const ThemeToolkit = Toolkit.make(ThemeReadTool, ThemeSetTool); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 343675e2cfc40..dd350fb736216 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -92,6 +92,7 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; +import { ThemeToolkit } from "../../mcp/toolkits/theme/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; @@ -662,6 +663,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(WorktreeToolkit.tools), ...Object.values(ProjectToolkit.tools), ...Object.values(EnvironmentToolkit.tools), + ...Object.values(ThemeToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), ...Object.values(MessageToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index daf472ad6475d..f1ae8c58e7ae9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2275,6 +2275,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_environment_links", + "mcp__t3-code__t3_theme_read", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", "mcp__t3-code__send_to_user", diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 5f4cb5ed4b8ed..2a7d02fc28173 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -44,6 +44,7 @@ import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; import { DocumentText } from "./DocumentText.ts"; +import { EnvironmentThemeService } from "../environmentTheme.ts"; import { ProviderRegistry } from "./ProviderRegistry.ts"; import budget from "./agentContextBudget.json" with { type: "json" }; @@ -52,6 +53,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(AssetSigner)({}), Layer.mock(DeviceService)({}), Layer.mock(DocumentText)({}), + Layer.mock(EnvironmentThemeService)({}), Layer.mock(GitVcsDriver)({}), Layer.mock(GitWorkflowService)({}), Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index ae7da1e678118..0a354c1e7d560 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 48566 + "mcpToolListJsonBytes": 49806 } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dc2e1f21c1022..12eeb8cbda5f6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -707,15 +707,15 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(SecretCheck.layer), Layer.provideMerge(SecretResolver.layer), + // Above the settings it writes when a theme is applied. + Layer.provideMerge(EnvironmentTheme.layer), Layer.provideMerge(layerServerSettings), Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), Layer.provideMerge(layerPersistence), - Layer.provideMerge( - Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), - ), + Layer.provideMerge(Layer.mergeAll(Keybindings.layer, UsageLimitSources.layer)), Layer.provideMerge(ProviderRegistry.layer), Layer.provideMerge( ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(SqlitePersistence.layerConfig)), diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index 3d7e5520ac8c4..3fe69b8b16d1d 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -17,6 +17,7 @@ export function ProjectMonogram({ return (