diff --git a/apps/server/src/appearance.test.ts b/apps/server/src/appearance.test.ts new file mode 100644 index 0000000000..52d4021360 --- /dev/null +++ b/apps/server/src/appearance.test.ts @@ -0,0 +1,164 @@ +import { AppearanceConfig, type AppearanceConfigIssue } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, Fiber, FileSystem, Layer, Path, Schema, Stream } from "effect"; +import { ServerConfig, type ServerConfigShape } from "./config"; + +import { Appearance, AppearanceLive, DEFAULT_APPEARANCE_CONFIG } from "./appearance"; + +const AppearanceConfigJson = Schema.fromJsonString(AppearanceConfig); + +const makeAppearanceLayer = ( + resolveAppearanceConfigPath?: (stateDir: string, join: Path.Path["join"]) => string, +) => + AppearanceLive.pipe( + Layer.provideMerge( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-appearance-test-" }); + const appearanceConfigPath = + resolveAppearanceConfigPath?.(dir, join) ?? join(dir, "appearance.json"); + return { appearanceConfigPath } as ServerConfigShape; + }), + ), + ), + ); + +const readAppearanceConfig = (configPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const rawConfig = yield* fileSystem.readFileString(configPath); + return yield* Schema.decodeUnknownEffect(AppearanceConfigJson)(rawConfig); + }); + +it.layer(NodeServices.layer)("appearance", (it) => { + it.effect("bootstraps default appearance when config file is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { appearanceConfigPath } = yield* ServerConfig; + assert.isFalse(yield* fs.exists(appearanceConfigPath)); + + yield* Effect.gen(function* () { + const appearance = yield* Appearance; + yield* appearance.syncDefaultAppearanceOnStartup; + }); + + const persisted = yield* readAppearanceConfig(appearanceConfigPath); + assert.deepEqual(persisted, DEFAULT_APPEARANCE_CONFIG); + }).pipe(Effect.provide(makeAppearanceLayer())), + ); + + it.effect("bootstraps default appearance when parent directory is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { appearanceConfigPath } = yield* ServerConfig; + assert.isFalse(yield* fs.exists(appearanceConfigPath)); + + yield* Effect.gen(function* () { + const appearance = yield* Appearance; + yield* appearance.syncDefaultAppearanceOnStartup; + }); + + const persisted = yield* readAppearanceConfig(appearanceConfigPath); + assert.deepEqual(persisted, DEFAULT_APPEARANCE_CONFIG); + }).pipe( + Effect.provide( + makeAppearanceLayer((dir, join) => join(dir, "nested", "state", "appearance.json")), + ), + ), + ); + + it.effect("uses defaults in runtime when config is malformed without overriding file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { appearanceConfigPath } = yield* ServerConfig; + yield* fs.writeFileString(appearanceConfigPath, "{ not-json"); + + const configState = yield* Effect.gen(function* () { + const appearance = yield* Appearance; + return yield* appearance.loadConfigState; + }); + + assert.deepEqual(configState.appearance, DEFAULT_APPEARANCE_CONFIG); + assert.deepEqual(configState.issues, [ + { + kind: "appearance.malformed-config", + message: configState.issues[0]?.message ?? "", + }, + ]); + assert.equal(yield* fs.readFileString(appearanceConfigPath), "{ not-json"); + }).pipe(Effect.provide(makeAppearanceLayer())), + ); + + it.effect("keeps valid appearance fields and reports invalid ones", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { appearanceConfigPath } = yield* ServerConfig; + yield* fs.writeFileString( + appearanceConfigPath, + JSON.stringify({ + uiFontFamily: '"IBM Plex Sans", system-ui, sans-serif', + uiFontSizePx: 30, + monoFontFamily: '"Iosevka", monospace', + terminalFontSizePx: "big", + }), + ); + + const configState = yield* Effect.gen(function* () { + const appearance = yield* Appearance; + return yield* appearance.loadConfigState; + }); + + assert.deepEqual(configState.appearance, { + uiFontFamily: '"IBM Plex Sans", system-ui, sans-serif', + uiFontSizePx: DEFAULT_APPEARANCE_CONFIG.uiFontSizePx, + monoFontFamily: '"Iosevka", monospace', + terminalFontSizePx: DEFAULT_APPEARANCE_CONFIG.terminalFontSizePx, + }); + assert.deepEqual(configState.issues, [ + { + kind: "appearance.invalid-value", + field: "uiFontSizePx", + message: configState.issues[0]?.message ?? "", + }, + { + kind: "appearance.invalid-value", + field: "terminalFontSizePx", + message: configState.issues[1]?.message ?? "", + }, + ] satisfies AppearanceConfigIssue[]); + }).pipe(Effect.provide(makeAppearanceLayer())), + ); + + it.effect("emits change events when appearance config changes on disk", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { appearanceConfigPath } = yield* ServerConfig; + const appearance = yield* Appearance; + + yield* appearance.syncDefaultAppearanceOnStartup; + + const changeFiber = yield* Stream.runHead(appearance.changes).pipe(Effect.forkChild); + yield* fs.writeFileString(appearanceConfigPath, "{ not-json"); + const change = yield* Fiber.join(changeFiber); + + assert.equal(change._tag, "Some"); + if (change._tag !== "Some") { + return; + } + + assert.deepEqual(change.value, { + changedSections: ["appearance"], + issues: [ + { + kind: "appearance.malformed-config", + message: change.value.issues[0]?.message ?? "", + }, + ], + }); + }).pipe(Effect.provide(makeAppearanceLayer())), + ); +}); diff --git a/apps/server/src/appearance.ts b/apps/server/src/appearance.ts new file mode 100644 index 0000000000..ca64ba9fbc --- /dev/null +++ b/apps/server/src/appearance.ts @@ -0,0 +1,321 @@ +import { + AppearanceConfig, + type AppearanceConfigField, + type AppearanceConfigIssue, + type ResolvedAppearanceConfig, + type ServerConfigSection, +} from "@t3tools/contracts"; +import { + Cache, + Cause, + Effect, + FileSystem, + Layer, + Path, + PubSub, + Schema, + ServiceMap, + Stream, +} from "effect"; +import * as Semaphore from "effect/Semaphore"; +import { Mutable } from "effect/Types"; +import { ServerConfig } from "./config"; + +export const DEFAULT_APPEARANCE_CONFIG: ResolvedAppearanceConfig = { + uiFontFamily: + '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + uiFontSizePx: 16, + monoFontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + terminalFontSizePx: 12, +}; + +export class AppearanceConfigError extends Schema.TaggedErrorClass()( + "AppearanceConfigError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse appearance config at ${this.configPath}: ${this.detail}`; + } +} + +type AppearanceConfigInput = Partial>; +const appearanceFieldSchemas = { + uiFontFamily: AppearanceConfig.fields.uiFontFamily, + uiFontSizePx: AppearanceConfig.fields.uiFontSizePx, + monoFontFamily: AppearanceConfig.fields.monoFontFamily, + terminalFontSizePx: AppearanceConfig.fields.terminalFontSizePx, +} as const; + +export interface AppearanceConfigState { + readonly appearance: ResolvedAppearanceConfig; + readonly issues: readonly AppearanceConfigIssue[]; +} + +export interface AppearanceChangeEvent { + readonly changedSections: readonly ServerConfigSection[]; + readonly issues: readonly AppearanceConfigIssue[]; +} + +export interface AppearanceShape { + readonly syncDefaultAppearanceOnStartup: Effect.Effect; + readonly loadConfigState: Effect.Effect; + readonly changes: Stream.Stream; +} + +export class Appearance extends ServiceMap.Service()("t3/appearance") {} + +function trimIssueMessage(message: string): string { + const trimmed = message.trim(); + return trimmed.length > 0 ? trimmed : "Invalid appearance configuration."; +} + +function malformedConfigIssue(detail: string): AppearanceConfigIssue { + return { + kind: "appearance.malformed-config", + message: trimIssueMessage(detail), + }; +} + +function invalidValueIssue(field: AppearanceConfigField, detail: string): AppearanceConfigIssue { + return { + kind: "appearance.invalid-value", + field, + message: trimIssueMessage(detail), + }; +} + +function decodeAppearanceField( + field: K, + value: unknown, +): ResolvedAppearanceConfig[K] | AppearanceConfigIssue { + const schema = appearanceFieldSchemas[field]; + const decoded = Schema.decodeUnknownExit(schema)(value); + if (decoded._tag === "Failure") { + return invalidValueIssue(field, Cause.pretty(decoded.cause)); + } + return decoded.value as ResolvedAppearanceConfig[K]; +} + +function resolveAppearanceConfig(input: AppearanceConfigInput): AppearanceConfigState { + const issues: AppearanceConfigIssue[] = []; + const appearance: Mutable = { ...DEFAULT_APPEARANCE_CONFIG }; + + for (const field of Object.keys(DEFAULT_APPEARANCE_CONFIG) as AppearanceConfigField[]) { + if (!(field in input)) { + continue; + } + + switch (field) { + case "uiFontFamily": { + const result = decodeAppearanceField("uiFontFamily", input[field]); + if (typeof result === "object" && result !== null && "kind" in result) { + issues.push(result); + continue; + } + appearance.uiFontFamily = result; + break; + } + case "uiFontSizePx": { + const result = decodeAppearanceField("uiFontSizePx", input[field]); + if (typeof result === "object" && result !== null && "kind" in result) { + issues.push(result); + continue; + } + appearance.uiFontSizePx = result; + break; + } + case "monoFontFamily": { + const result = decodeAppearanceField("monoFontFamily", input[field]); + if (typeof result === "object" && result !== null && "kind" in result) { + issues.push(result); + continue; + } + appearance.monoFontFamily = result; + break; + } + case "terminalFontSizePx": { + const result = decodeAppearanceField("terminalFontSizePx", input[field]); + if (typeof result === "object" && result !== null && "kind" in result) { + issues.push(result); + continue; + } + appearance.terminalFontSizePx = result; + break; + } + } + } + + return { appearance, issues }; +} + +const makeAppearance = Effect.gen(function* () { + const { appearanceConfigPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const revalidateSemaphore = yield* Semaphore.make(1); + const resolvedConfigCacheKey = "resolved" as const; + const changesPubSub = yield* PubSub.unbounded(); + + const emitChange = (issues: readonly AppearanceConfigIssue[]) => + PubSub.publish(changesPubSub, { + changedSections: ["appearance"], + issues, + }).pipe(Effect.asVoid); + + const readConfigExists = fs.exists(appearanceConfigPath).pipe( + Effect.mapError( + (cause) => + new AppearanceConfigError({ + configPath: appearanceConfigPath, + detail: "failed to access appearance config", + cause, + }), + ), + ); + + const readRawConfig = fs.readFileString(appearanceConfigPath).pipe( + Effect.mapError( + (cause) => + new AppearanceConfigError({ + configPath: appearanceConfigPath, + detail: "failed to read appearance config", + cause, + }), + ), + ); + + const writeConfigAtomically = (config: ResolvedAppearanceConfig) => { + const tempPath = `${appearanceConfigPath}.${process.pid}.${Date.now()}.tmp`; + + return Schema.encodeEffect(AppearanceConfig)(config).pipe( + Effect.map((encoded) => `${JSON.stringify(encoded, null, 2)}\n`), + Effect.tap(() => fs.makeDirectory(path.dirname(appearanceConfigPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, appearanceConfigPath)), + Effect.mapError( + (cause) => + new AppearanceConfigError({ + configPath: appearanceConfigPath, + detail: "failed to write appearance config", + cause, + }), + ), + ); + }; + + const loadConfigStateFromDisk = Effect.gen(function* () { + if (!(yield* readConfigExists)) { + return { appearance: DEFAULT_APPEARANCE_CONFIG, issues: [] }; + } + + const rawConfig = yield* readRawConfig; + const decoded = yield* Effect.try({ + try: () => JSON.parse(rawConfig) as unknown, + catch: (cause) => + new AppearanceConfigError({ + configPath: appearanceConfigPath, + detail: `expected JSON object (${String(cause)})`, + cause, + }), + }).pipe(Effect.exit); + if (decoded._tag === "Failure") { + return { + appearance: DEFAULT_APPEARANCE_CONFIG, + issues: [malformedConfigIssue(messageFromCause(decoded.cause))], + }; + } + + if (typeof decoded.value !== "object" || decoded.value === null || Array.isArray(decoded.value)) { + return { + appearance: DEFAULT_APPEARANCE_CONFIG, + issues: [malformedConfigIssue("Expected a JSON object.")], + }; + } + + return resolveAppearanceConfig(decoded.value as AppearanceConfigInput); + }); + + const resolvedConfigCache = yield* Cache.make< + typeof resolvedConfigCacheKey, + AppearanceConfigState, + AppearanceConfigError + >({ + capacity: 1, + lookup: () => loadConfigStateFromDisk, + }); + + const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); + + const revalidateAndEmit = revalidateSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + const configState = yield* loadConfigStateFromCacheOrDisk; + yield* emitChange(configState.issues); + }), + ); + + const appearanceConfigDir = path.dirname(appearanceConfigPath); + const appearanceConfigFile = path.basename(appearanceConfigPath); + const appearanceConfigPathResolved = path.resolve(appearanceConfigPath); + yield* fs.makeDirectory(appearanceConfigDir, { recursive: true }).pipe( + Effect.orElseSucceed(() => undefined), + ); + yield* Stream.runForEach(fs.watch(appearanceConfigDir), (event) => { + const isTargetConfigEvent = + event.path === appearanceConfigFile || + event.path === appearanceConfigPath || + path.resolve(appearanceConfigDir, event.path) === appearanceConfigPathResolved; + if (!isTargetConfigEvent) { + return Effect.void; + } + return revalidateAndEmit.pipe( + Effect.catch((error) => + Effect.logWarning("failed to revalidate appearance config after file update", { + path: appearanceConfigPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("appearance config watcher stopped unexpectedly", { + path: appearanceConfigPath, + cause, + }), + ), + Effect.forkScoped, + ); + + const syncDefaultAppearanceOnStartup = revalidateSemaphore.withPermits(1)( + Effect.gen(function* () { + if (yield* readConfigExists) { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + return; + } + + yield* writeConfigAtomically(DEFAULT_APPEARANCE_CONFIG); + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + }), + ); + + return { + syncDefaultAppearanceOnStartup, + loadConfigState: loadConfigStateFromCacheOrDisk, + changes: Stream.fromPubSub(changesPubSub), + } satisfies AppearanceShape; +}); + +export const AppearanceLive = Layer.effect(Appearance, makeAppearance); + +function messageFromCause(cause: Cause.Cause): string { + const squashed = Cause.squash(cause); + if (typeof squashed === "object" && squashed !== null && "detail" in squashed) { + return String((squashed as { detail: unknown }).detail); + } + return String(squashed); +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d..ad9332e946 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -21,6 +21,7 @@ export interface ServerConfigShape { readonly host: string | undefined; readonly cwd: string; readonly keybindingsConfigPath: string; + readonly appearanceConfigPath: string; readonly stateDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; @@ -51,6 +52,7 @@ export class ServerConfig extends ServiceMap.Service(); const emitChange = (issues: readonly ServerConfigIssue[]) => - PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid); + PubSub.publish(changesPubSub, { changedSections: ["keybindings"], issues }).pipe( + Effect.asVoid, + ); const readConfigExists = fs.exists(keybindingsConfigPath).pipe( Effect.mapError( diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..e649a526f8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -169,6 +169,7 @@ const ServerConfigLive = (input: CliInput) => const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const appearanceConfigPath = join(stateDir, "appearance.json"); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -179,6 +180,7 @@ const ServerConfigLive = (input: CliInput) => port, cwd: cliConfig.cwd, keybindingsConfigPath, + appearanceConfigPath, host, stateDir, staticDir, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b9..e2667ec9cd 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -26,6 +26,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { AppearanceLive } from "./appearance"; import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; @@ -124,6 +125,7 @@ export function makeServerRuntimeServicesLayer() { gitCoreLayer, gitManagerLayer, terminalLayer, + AppearanceLive, KeybindingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 285028cca6..6de6146463 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -29,6 +29,7 @@ import { type ResolvedKeybindingsConfig, type WsPush, } from "@t3tools/contracts"; +import { DEFAULT_APPEARANCE_CONFIG } from "./appearance"; import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; import type { TerminalClearInput, @@ -417,6 +418,7 @@ describe("WebSocket Server", () => { host: undefined, cwd: options.cwd ?? "/test/project", keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + appearanceConfigPath: path.join(stateDir, "appearance.json"), stateDir, staticDir: options.staticDir, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, @@ -736,6 +738,7 @@ describe("WebSocket Server", () => { it("responds to server.getConfig", async () => { const stateDir = makeTempDir("t3code-state-get-config-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); + const appearancePath = path.join(stateDir, "appearance.json"); fs.writeFileSync(keybindingsPath, "[]", "utf8"); server = await createTestServer({ cwd: "/my/workspace", stateDir }); @@ -753,8 +756,11 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + appearanceConfigPath: appearancePath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [], + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [], + appearanceIssues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); @@ -764,6 +770,7 @@ describe("WebSocket Server", () => { it("bootstraps default keybindings file when missing", async () => { const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); + const appearancePath = path.join(stateDir, "appearance.json"); expect(fs.existsSync(keybindingsPath)).toBe(false); server = await createTestServer({ cwd: "/my/workspace", stateDir }); @@ -779,8 +786,11 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + appearanceConfigPath: appearancePath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [], + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [], + appearanceIssues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); @@ -795,6 +805,7 @@ describe("WebSocket Server", () => { it("falls back to defaults and reports malformed keybindings config issues", async () => { const stateDir = makeTempDir("t3code-state-malformed-keybindings-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); + const appearancePath = path.join(stateDir, "appearance.json"); fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); server = await createTestServer({ cwd: "/my/workspace", stateDir }); @@ -810,13 +821,16 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + appearanceConfigPath: appearancePath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [ + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [ { kind: "keybindings.malformed-config", message: expect.stringContaining("expected JSON array"), }, ], + appearanceIssues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); @@ -827,6 +841,7 @@ describe("WebSocket Server", () => { it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); + const appearancePath = path.join(stateDir, "appearance.json"); fs.writeFileSync( keybindingsPath, JSON.stringify([ @@ -850,14 +865,19 @@ describe("WebSocket Server", () => { const result = response.result as { cwd: string; keybindingsConfigPath: string; + appearanceConfigPath: string; keybindings: ResolvedKeybindingsConfig; - issues: Array<{ kind: string; index?: number; message: string }>; + appearance: typeof DEFAULT_APPEARANCE_CONFIG; + keybindingsIssues: Array<{ kind: string; index?: number; message: string }>; + appearanceIssues: Array<{ kind: string; message: string }>; providers: ReadonlyArray; availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); expect(result.keybindingsConfigPath).toBe(keybindingsPath); - expect(result.issues).toEqual([ + expect(result.appearanceConfigPath).toBe(appearancePath); + expect(result.appearance).toEqual(DEFAULT_APPEARANCE_CONFIG); + expect(result.keybindingsIssues).toEqual([ { kind: "keybindings.invalid-entry", index: 1, @@ -869,6 +889,7 @@ describe("WebSocket Server", () => { message: expect.any(String), }, ]); + expect(result.appearanceIssues).toEqual([]); expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); @@ -879,7 +900,9 @@ describe("WebSocket Server", () => { it("pushes server.configUpdated issues when keybindings file changes", async () => { const stateDir = makeTempDir("t3code-state-keybindings-watch-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); + const appearancePath = path.join(stateDir, "appearance.json"); fs.writeFileSync(keybindingsPath, "[]", "utf8"); + fs.writeFileSync(appearancePath, JSON.stringify(DEFAULT_APPEARANCE_CONFIG), "utf8"); server = await createTestServer({ cwd: "/my/workspace", stateDir }); const addr = server.address(); @@ -894,13 +917,17 @@ describe("WebSocket Server", () => { ws, WS_CHANNELS.serverConfigUpdated, (push) => - Array.isArray((push.data as { issues?: unknown[] }).issues) && - Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) && - (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind === + Array.isArray((push.data as { keybindingsIssues?: unknown[] }).keybindingsIssues) && + Boolean( + (push.data as { keybindingsIssues: Array<{ kind: string }> }).keybindingsIssues[0], + ) && + (push.data as { keybindingsIssues: Array<{ kind: string }> }).keybindingsIssues[0]!.kind === "keybindings.malformed-config", ); expect(malformedPush.data).toEqual({ - issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + changedSections: ["keybindings"], + keybindingsIssues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + appearanceIssues: [], providers: defaultProviderStatuses, }); @@ -909,10 +936,121 @@ describe("WebSocket Server", () => { ws, WS_CHANNELS.serverConfigUpdated, (push) => - Array.isArray((push.data as { issues?: unknown[] }).issues) && - (push.data as { issues: unknown[] }).issues.length === 0, + Array.isArray((push.data as { changedSections?: unknown[] }).changedSections) && + (push.data as { changedSections: string[] }).changedSections.includes("keybindings") && + Array.isArray((push.data as { keybindingsIssues?: unknown[] }).keybindingsIssues) && + (push.data as { keybindingsIssues: unknown[] }).keybindingsIssues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ + changedSections: ["keybindings"], + keybindingsIssues: [], + appearanceIssues: [], + providers: defaultProviderStatuses, + }); + }); + + it("bootstraps default appearance file when missing", async () => { + const stateDir = makeTempDir("t3code-state-bootstrap-appearance-"); + const appearancePath = path.join(stateDir, "appearance.json"); + expect(fs.existsSync(appearancePath)).toBe(false); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect((response.result as { appearance: unknown }).appearance).toEqual(DEFAULT_APPEARANCE_CONFIG); + expect(JSON.parse(fs.readFileSync(appearancePath, "utf8"))).toEqual(DEFAULT_APPEARANCE_CONFIG); + }); + + it("bootstraps default appearance file when the state directory does not exist yet", async () => { + const stateDirRoot = makeTempDir("t3code-state-bootstrap-appearance-parent-"); + const stateDir = path.join(stateDirRoot, "fresh", "state"); + const appearancePath = path.join(stateDir, "appearance.json"); + expect(fs.existsSync(stateDir)).toBe(false); + expect(fs.existsSync(appearancePath)).toBe(false); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + appearanceConfigPath: appearancePath, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [], + appearanceIssues: [], + providers: defaultProviderStatuses, + availableEditors: expect.any(Array), + }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expect(fs.existsSync(stateDir)).toBe(true); + expect(JSON.parse(fs.readFileSync(appearancePath, "utf8"))).toEqual(DEFAULT_APPEARANCE_CONFIG); + }); + + it("pushes server.configUpdated issues when appearance file changes", async () => { + const stateDir = makeTempDir("t3code-state-appearance-watch-"); + const appearancePath = path.join(stateDir, "appearance.json"); + const keybindingsPath = path.join(stateDir, "keybindings.json"); + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + fs.writeFileSync(appearancePath, JSON.stringify(DEFAULT_APPEARANCE_CONFIG), "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + fs.writeFileSync(appearancePath, "{ not-json", "utf8"); + const malformedPush = await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + (push) => + Array.isArray((push.data as { appearanceIssues?: unknown[] }).appearanceIssues) && + Boolean( + (push.data as { appearanceIssues: Array<{ kind: string }> }).appearanceIssues[0], + ) && + (push.data as { appearanceIssues: Array<{ kind: string }> }).appearanceIssues[0]!.kind === + "appearance.malformed-config", + ); + expect(malformedPush.data).toEqual({ + changedSections: ["appearance"], + keybindingsIssues: [], + appearanceIssues: [{ kind: "appearance.malformed-config", message: expect.any(String) }], + providers: defaultProviderStatuses, + }); + + fs.writeFileSync(appearancePath, JSON.stringify(DEFAULT_APPEARANCE_CONFIG), "utf8"); + const successPush = await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + (push) => + Array.isArray((push.data as { changedSections?: unknown[] }).changedSections) && + (push.data as { changedSections: string[] }).changedSections.includes("appearance") && + Array.isArray((push.data as { appearanceIssues?: unknown[] }).appearanceIssues) && + (push.data as { appearanceIssues: unknown[] }).appearanceIssues.length === 0, + ); + expect(successPush.data).toEqual({ + changedSections: ["appearance"], + keybindingsIssues: [], + appearanceIssues: [], + providers: defaultProviderStatuses, + }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -970,8 +1108,11 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + appearanceConfigPath: path.join(stateDir, "appearance.json"), keybindings: compileKeybindings(persistedConfig), - issues: [], + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [], + appearanceIssues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); @@ -1010,7 +1151,7 @@ describe("WebSocket Server", () => { expect(persistedCommands.has("script.run-tests.run")).toBe(true); expect(upsertResponse.result).toEqual({ keybindings: compileKeybindings(persistedConfig), - issues: [], + keybindingsIssues: [], }); const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); @@ -1018,8 +1159,11 @@ describe("WebSocket Server", () => { expect(configResponse.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + appearanceConfigPath: path.join(stateDir, "appearance.json"), keybindings: compileKeybindings(persistedConfig), - issues: [], + appearance: DEFAULT_APPEARANCE_CONFIG, + keybindingsIssues: [], + appearanceIssues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d8859c2fa5..4b01792e35 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -47,6 +47,7 @@ import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { Appearance } from "./appearance"; import { Keybindings } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; @@ -215,6 +216,7 @@ export type ServerRuntimeServices = | GitManager | GitCore | TerminalManager + | Appearance | Keybindings | Open | AnalyticsService; @@ -241,6 +243,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< port, cwd, keybindingsConfigPath, + appearanceConfigPath, staticDir, devUrl, authToken, @@ -252,6 +255,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; + const appearanceManager = yield* Appearance; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; const git = yield* GitCore; @@ -267,6 +271,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ), ); + yield* appearanceManager.syncDefaultAppearanceOnStartup.pipe( + Effect.catch((error) => + Effect.logWarning("failed to sync appearance defaults on startup", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); const providerStatuses = yield* providerHealth.getStatuses; @@ -630,7 +643,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< type: "push", channel: WS_CHANNELS.serverConfigUpdated, data: { - issues: event.issues, + changedSections: [...event.changedSections], + keybindingsIssues: event.issues, + appearanceIssues: [], + providers: providerStatuses, + }, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(appearanceManager.changes, (event) => + broadcastPush({ + type: "push", + channel: WS_CHANNELS.serverConfigUpdated, + data: { + changedSections: [...event.changedSections], + keybindingsIssues: [], + appearanceIssues: event.issues, providers: providerStatuses, }, }), @@ -877,12 +905,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } case WS_METHODS.serverGetConfig: + const appearanceConfig = yield* appearanceManager.loadConfigState; const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { cwd, keybindingsConfigPath, + appearanceConfigPath, keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, + appearance: appearanceConfig.appearance, + keybindingsIssues: keybindingsConfig.issues, + appearanceIssues: appearanceConfig.issues, providers: providerStatuses, availableEditors, }; @@ -890,7 +922,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); - return { keybindings: keybindingsConfig, issues: [] }; + return { keybindings: keybindingsConfig, keybindingsIssues: [] }; } default: { diff --git a/apps/web/src/appearance.test.ts b/apps/web/src/appearance.test.ts new file mode 100644 index 0000000000..9296c8c994 --- /dev/null +++ b/apps/web/src/appearance.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_APPEARANCE, + getResolvedAppearanceSnapshot, + setResolvedAppearance, +} from "./appearance"; + +describe("appearance runtime", () => { + it("starts with the bundled default appearance", () => { + expect(getResolvedAppearanceSnapshot()).toEqual(DEFAULT_APPEARANCE); + }); + + it("updates the in-memory snapshot when resolved appearance changes", () => { + const nextAppearance = { + ...DEFAULT_APPEARANCE, + uiFontFamily: '"IBM Plex Sans", system-ui, sans-serif', + uiFontSizePx: 18, + }; + + setResolvedAppearance(nextAppearance); + expect(getResolvedAppearanceSnapshot()).toEqual(nextAppearance); + + setResolvedAppearance(DEFAULT_APPEARANCE); + }); +}); diff --git a/apps/web/src/appearance.ts b/apps/web/src/appearance.ts new file mode 100644 index 0000000000..b2806c5825 --- /dev/null +++ b/apps/web/src/appearance.ts @@ -0,0 +1,70 @@ +import { useSyncExternalStore } from "react"; +import type { ResolvedAppearanceConfig } from "@t3tools/contracts"; + +export const DEFAULT_APPEARANCE: ResolvedAppearanceConfig = Object.freeze({ + uiFontFamily: + '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + uiFontSizePx: 16, + monoFontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + terminalFontSizePx: 12, +}); + +let listeners: Array<() => void> = []; +let currentAppearance: ResolvedAppearanceConfig = DEFAULT_APPEARANCE; + +function applyAppearanceToDom(appearance: ResolvedAppearanceConfig): void { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + root.style.setProperty("--app-font-sans", appearance.uiFontFamily); + root.style.setProperty("--app-font-mono", appearance.monoFontFamily); + root.style.setProperty("--app-root-font-size", `${appearance.uiFontSizePx}px`); +} + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +function appearancesEqual( + left: ResolvedAppearanceConfig, + right: ResolvedAppearanceConfig, +): boolean { + return ( + left.uiFontFamily === right.uiFontFamily && + left.uiFontSizePx === right.uiFontSizePx && + left.monoFontFamily === right.monoFontFamily && + left.terminalFontSizePx === right.terminalFontSizePx + ); +} + +applyAppearanceToDom(DEFAULT_APPEARANCE); + +export function setResolvedAppearance(appearance: ResolvedAppearanceConfig): void { + if (appearancesEqual(currentAppearance, appearance)) { + applyAppearanceToDom(appearance); + return; + } + + currentAppearance = appearance; + applyAppearanceToDom(appearance); + emitChange(); +} + +function subscribe(listener: () => void): () => void { + listeners.push(listener); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + }; +} + +export function getResolvedAppearanceSnapshot(): ResolvedAppearanceConfig { + return currentAppearance; +} + +export function useResolvedAppearance(): ResolvedAppearanceConfig { + return useSyncExternalStore(subscribe, getResolvedAppearanceSnapshot, () => DEFAULT_APPEARANCE); +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ba27e383d8..806a4b0f11 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -394,7 +394,7 @@ export function BranchToolbarBranchSelector({
{itemValue} {badge && ( - {badge} + {badge} )}
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe8..9d9b3ea6a3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -95,8 +95,18 @@ function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + appearanceConfigPath: "/repo/project/.t3code-appearance.json", keybindings: [], - issues: [], + appearance: { + uiFontFamily: + '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + uiFontSizePx: 16, + monoFontFamily: + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + terminalFontSizePx: 12, + }, + keybindingsIssues: [], + appearanceIssues: [], providers: [ { provider: "codex", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f4199..39ba82ab07 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -526,7 +526,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ) : null} {props.item.type === "model" ? ( - + model ) : null} @@ -3544,7 +3544,7 @@ export default function ChatView({ threadId }: ChatViewProps) { /> ) : ( -
+
{image.name}
)} @@ -4020,7 +4020,7 @@ const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && !isGitRepo && ( - + No Git )} @@ -4463,11 +4463,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -4496,11 +4496,11 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} @@ -4980,13 +4980,13 @@ const MessagesTimeline = memo(function MessagesTimeline({ return (
-

+

{groupLabel}

{hasOverflow && ( ) : ( -
+
{image.name}
)} @@ -5107,7 +5107,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ )}
-

+

{formatTimestamp(row.message.createdAt)}

@@ -5125,7 +5125,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ {row.showCompletionDivider && (
- + {completionSummary ? `Response • ${completionSummary}` : "Response"} @@ -5149,7 +5149,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ return (
-

+

Changed files ({changedFileCountLabel}) {hasNonZeroStat(summaryStat) && ( <> @@ -5193,7 +5193,7 @@ const MessagesTimeline = memo(function MessagesTimeline({

); })()} -

+

{formatMessageMeta( row.message.createdAt, row.message.streaming @@ -5219,7 +5219,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ {row.kind === "working" && (

-
+
@@ -5451,7 +5451,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { )} /> {option.label} - + Coming soon @@ -5464,7 +5464,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2c089dab01..6081ec4cb0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -99,7 +99,7 @@ class ComposerMentionNode extends TextNode { override createDOM(_config: EditorConfig): HTMLElement { const dom = document.createElement("span"); dom.className = - "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; + "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[0.75rem] leading-[1.1] text-foreground align-middle"; dom.contentEditable = "false"; dom.setAttribute("spellcheck", "false"); renderMentionChipDom(dom, this.__path); @@ -746,7 +746,7 @@ function ComposerPromptEditorInner({ contentEditable={ } placeholder={ -
+
{placeholder}
} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 27248ce634..bb9ad116df 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -451,7 +451,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", )} > -
All turns
+
All turns
{orderedTurnDiffSummaries.map((summary) => ( @@ -472,7 +472,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { )} >
- + Turn{" "} {summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId] ?? @@ -550,7 +550,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > {checkpointDiffError && !renderablePatch && (
-

{checkpointDiffError}

+

{checkpointDiffError}

)} {!renderablePatch ? ( @@ -608,8 +608,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ) : (
-

{renderablePatch.reason}

-
+                  

{renderablePatch.reason}

+
                     {renderablePatch.text}
                   
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e3a3ce02ef..ae3ece1d4b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1177,7 +1177,7 @@ export default function Sidebar() { )} {threadStatus && ( )} @@ -1255,7 +1255,7 @@ export default function Sidebar() { } size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[0.625rem] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { expandThreadListForProject(project.id); }} @@ -1269,7 +1269,7 @@ export default function Sidebar() { } size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[0.625rem] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { collapseThreadListForProject(project.id); }} @@ -1300,7 +1300,7 @@ export default function Sidebar() { {addingProject ? ( <> -

+

Add project

; + terminal: Terminal | null; + fitAddon: FitAddon | null; + threadId: ThreadId; + terminalId: string; +}) { + const { api, terminal, fitAddon, threadId, terminalId } = params; + if (!api || !terminal || !fitAddon) return; + + const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + fitAddon.fit(); + if (wasAtBottom) { + terminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: terminal.cols, + rows: terminal.rows, + }) + .catch(() => undefined); +} + function TerminalViewport({ threadId, terminalId, @@ -130,6 +156,7 @@ function TerminalViewport({ resizeEpoch, drawerHeight, }: TerminalViewportProps) { + const appearance = useResolvedAppearance(); const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -150,9 +177,9 @@ function TerminalViewport({ const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, - fontSize: 12, + fontSize: appearance.terminalFontSizePx, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: appearance.monoFontFamily, theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); @@ -358,23 +385,13 @@ function TerminalViewport({ }); const fitTimer = window.setTimeout(() => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - activeFitAddon.fit(); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + fitTerminalAndSyncSize({ + api, + terminal: terminalRef.current, + fitAddon: fitAddonRef.current, + threadId, + terminalId, + }); }, 30); void openTerminal(); @@ -394,6 +411,27 @@ function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, runtimeEnv, terminalId, threadId]); + useEffect(() => { + const api = readNativeApi(); + const terminal = terminalRef.current; + if (!api || !terminal) return; + + terminal.options.fontFamily = appearance.monoFontFamily; + terminal.options.fontSize = appearance.terminalFontSizePx; + fitTerminalAndSyncSize({ + api, + terminal, + fitAddon: fitAddonRef.current, + threadId, + terminalId, + }); + }, [ + appearance.monoFontFamily, + appearance.terminalFontSizePx, + terminalId, + threadId, + ]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; @@ -411,20 +449,14 @@ function TerminalViewport({ const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { - fitAddon.fit(); - if (wasAtBottom) { - terminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: terminal.cols, - rows: terminal.rows, - }) - .catch(() => undefined); + fitTerminalAndSyncSize({ + api, + terminal, + fitAddon, + threadId, + terminalId, + }); }); return () => { window.cancelAnimationFrame(frame); @@ -882,7 +914,7 @@ export default function ThreadTerminalDrawer({ {showGroupHeaders && (
-

+

{APP_DISPLAY_NAME}

@@ -138,7 +140,6 @@ function EventRouter() { const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); const pathnameRef = useRef(pathname); - const lastConfigIssuesSignatureRef = useRef(null); const handledBootstrapThreadIdRef = useRef(null); pathnameRef.current = pathname; @@ -237,46 +238,62 @@ function EventRouter() { })().catch(() => undefined); }); const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { - const signature = JSON.stringify(payload.issues); - if (lastConfigIssuesSignatureRef.current === signature) { - return; - } - lastConfigIssuesSignatureRef.current = signature; - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (!issue) { + + const openServerConfigPath = (configPath: "keybindingsConfigPath" | "appearanceConfigPath") => { + void queryClient + .ensureQueryData(serverConfigQueryOptions()) + .then((config) => + api.shell.openInEditor(config[configPath], preferredTerminalEditor()), + ) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open config file", + description: error instanceof Error ? error.message : "Unknown error opening file.", + }); + }); + }; + + if (payload.changedSections.includes("keybindings") && payload.keybindingsIssues.length === 0) { toastManager.add({ type: "success", title: "Keybindings updated", description: "Keybindings configuration reloaded successfully.", }); - return; } - toastManager.add({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionProps: { - children: "Open keybindings.json", - onClick: () => { - void queryClient - .ensureQueryData(serverConfigQueryOptions()) - .then((config) => - api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()), - ) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }); - }); + if (payload.changedSections.includes("keybindings") && payload.keybindingsIssues[0]) { + toastManager.add({ + type: "warning", + title: "Invalid keybindings configuration", + description: payload.keybindingsIssues[0].message, + actionProps: { + children: "Open keybindings.json", + onClick: () => openServerConfigPath("keybindingsConfigPath"), }, - }, - }); + }); + } + + if (payload.changedSections.includes("appearance") && payload.appearanceIssues.length === 0) { + toastManager.add({ + type: "success", + title: "Appearance updated", + description: "Appearance configuration reloaded.", + }); + } + + if (payload.changedSections.includes("appearance") && payload.appearanceIssues[0]) { + toastManager.add({ + type: "warning", + title: "Invalid appearance configuration", + description: payload.appearanceIssues[0].message, + actionProps: { + children: "Open appearance.json", + onClick: () => openServerConfigPath("appearanceConfigPath"), + }, + }); + } }); return () => { disposed = true; @@ -296,6 +313,19 @@ function EventRouter() { return null; } +function ServerAppearanceSync() { + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + + useEffect(() => { + if (!serverConfigQuery.data?.appearance) { + return; + } + setResolvedAppearance(serverConfigQuery.data.appearance); + }, [serverConfigQuery.data?.appearance]); + + return null; +} + function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a272..f8b700a9f7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -92,6 +92,8 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [isOpeningAppearanceConfig, setIsOpeningAppearanceConfig] = useState(false); + const [openAppearanceConfigError, setOpenAppearanceConfigError] = useState(null); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -105,6 +107,7 @@ function SettingsRouteView() { const codexHomePath = settings.codexHomePath; const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const appearanceConfigPath = serverConfigQuery.data?.appearanceConfigPath ?? null; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -123,6 +126,23 @@ function SettingsRouteView() { }); }, [keybindingsConfigPath]); + const openAppearanceConfigFile = useCallback(() => { + if (!appearanceConfigPath) return; + setOpenAppearanceConfigError(null); + setIsOpeningAppearanceConfig(true); + const api = ensureNativeApi(); + void api.shell + .openInEditor(appearanceConfigPath, preferredTerminalEditor()) + .catch((error) => { + setOpenAppearanceConfigError( + error instanceof Error ? error.message : "Unable to open appearance config file.", + ); + }) + .finally(() => { + setIsOpeningAppearanceConfig(false); + }); + }, [appearanceConfigPath]); + const addCustomModel = useCallback((provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; const customModels = getCustomModelsForProvider(settings, provider); @@ -228,7 +248,7 @@ function SettingsRouteView() { {option.description} {selected ? ( - + Selected ) : null} @@ -240,6 +260,36 @@ function SettingsRouteView() {

Active theme: {resolvedTheme}

+ +
+
+

Appearance config

+

+ Edit appearance.json directly to change font family and size. +

+
+ +
+
+

Config file path

+

+ {appearanceConfigPath ?? "Resolving appearance path..."} +

+
+ +
+ + {openAppearanceConfigError ? ( +

{openAppearanceConfigError}

+ ) : null} +

@@ -532,7 +582,7 @@ function SettingsRouteView() {

Config file path

-

+

{keybindingsConfigPath ?? "Resolving keybindings path..."}

diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 142174fb01..fefc390407 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -167,13 +167,15 @@ describe("wsNativeApi", () => { onServerConfigUpdated(listener); const payload = { - issues: [ + changedSections: ["keybindings"], + keybindingsIssues: [ { kind: "keybindings.invalid-entry", index: 1, message: "Entry at index 1 is invalid.", }, ], + appearanceIssues: [], providers: defaultProviders, } as const; emitPush(WS_CHANNELS.serverConfigUpdated, payload); @@ -197,17 +199,23 @@ describe("wsNativeApi", () => { onServerConfigUpdated(listener); emitPush(WS_CHANNELS.serverConfigUpdated, { - issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], + changedSections: ["keybindings"], + keybindingsIssues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], + appearanceIssues: [], providers: defaultProviders, }); emitPush(WS_CHANNELS.serverConfigUpdated, { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + changedSections: ["keybindings"], + keybindingsIssues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + appearanceIssues: [], providers: defaultProviders, }); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + changedSections: ["keybindings"], + keybindingsIssues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + appearanceIssues: [], providers: defaultProviders, }); expect(warnSpy).toHaveBeenCalledTimes(1); diff --git a/packages/contracts/src/appearance.test.ts b/packages/contracts/src/appearance.test.ts new file mode 100644 index 0000000000..104c257744 --- /dev/null +++ b/packages/contracts/src/appearance.test.ts @@ -0,0 +1,81 @@ +import { Schema } from "effect"; +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + AppearanceConfig, + AppearanceConfigIssue, + ResolvedAppearanceConfig, +} from "./appearance"; + +const decode = ( + schema: S, + input: unknown, +): Effect.Effect, Schema.SchemaError, never> => + Schema.decodeUnknownEffect(schema as never)(input) as Effect.Effect< + Schema.Schema.Type, + Schema.SchemaError, + never + >; + +it.effect("parses appearance config payloads", () => + Effect.gen(function* () { + const parsed = yield* decode(AppearanceConfig, { + uiFontFamily: '"DM Sans", system-ui, sans-serif', + uiFontSizePx: 16, + monoFontFamily: '"SF Mono", monospace', + terminalFontSizePx: 12, + }); + + assert.strictEqual(parsed.uiFontSizePx, 16); + assert.strictEqual(parsed.terminalFontSizePx, 12); + }), +); + +it.effect("rejects invalid appearance config values", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decode(AppearanceConfig, { + uiFontFamily: "", + uiFontSizePx: 9, + monoFontFamily: '"SF Mono", monospace', + terminalFontSizePx: 12, + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("parses resolved appearance configs", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedAppearanceConfig, { + uiFontFamily: '"DM Sans", system-ui, sans-serif', + uiFontSizePx: 18, + monoFontFamily: '"SF Mono", monospace', + terminalFontSizePx: 14, + }); + + assert.strictEqual(parsed.uiFontSizePx, 18); + }), +); + +it.effect("parses appearance config issues", () => + Effect.gen(function* () { + const malformed = yield* decode(AppearanceConfigIssue, { + kind: "appearance.malformed-config", + message: "Expected a JSON object.", + }); + const invalid = yield* decode(AppearanceConfigIssue, { + kind: "appearance.invalid-value", + field: "uiFontSizePx", + message: "Expected an integer between 12 and 24.", + }); + + assert.strictEqual(malformed.kind, "appearance.malformed-config"); + if (invalid.kind !== "appearance.invalid-value") { + throw new Error("expected appearance.invalid-value issue"); + } + assert.strictEqual(invalid.field, "uiFontSizePx"); + }), +); diff --git a/packages/contracts/src/appearance.ts b/packages/contracts/src/appearance.ts new file mode 100644 index 0000000000..434d1e48f0 --- /dev/null +++ b/packages/contracts/src/appearance.ts @@ -0,0 +1,48 @@ +import { Schema } from "effect"; + +export const AppearanceConfigField = Schema.Literals([ + "uiFontFamily", + "uiFontSizePx", + "monoFontFamily", + "terminalFontSizePx", +]); +export type AppearanceConfigField = typeof AppearanceConfigField.Type; + +const FontFamilyString = Schema.String.pipe( + Schema.check( + Schema.isNonEmpty(), + Schema.isMaxLength(1024), + ), +); + +const UiFontSizePx = Schema.Int.check(Schema.isBetween({ minimum: 12, maximum: 24 })); + +const TerminalFontSizePx = Schema.Int.check(Schema.isBetween({ minimum: 10, maximum: 24 })); + +export const AppearanceConfig = Schema.Struct({ + uiFontFamily: FontFamilyString, + uiFontSizePx: UiFontSizePx, + monoFontFamily: FontFamilyString, + terminalFontSizePx: TerminalFontSizePx, +}); +export type AppearanceConfig = typeof AppearanceConfig.Type; + +export const ResolvedAppearanceConfig = AppearanceConfig; +export type ResolvedAppearanceConfig = typeof ResolvedAppearanceConfig.Type; + +const AppearanceMalformedConfigIssue = Schema.Struct({ + kind: Schema.Literal("appearance.malformed-config"), + message: Schema.String, +}); + +const AppearanceInvalidValueIssue = Schema.Struct({ + kind: Schema.Literal("appearance.invalid-value"), + field: AppearanceConfigField, + message: Schema.String, +}); + +export const AppearanceConfigIssue = Schema.Union([ + AppearanceMalformedConfigIssue, + AppearanceInvalidValueIssue, +]); +export type AppearanceConfigIssue = typeof AppearanceConfigIssue.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..0aa06debbc 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./appearance"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..d96776647a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,27 +1,33 @@ import { Schema } from "effect"; +import { AppearanceConfigIssue, ResolvedAppearanceConfig } from "./appearance"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; -const KeybindingsMalformedConfigIssue = Schema.Struct({ +export const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), message: TrimmedNonEmptyString, }); -const KeybindingsInvalidEntryIssue = Schema.Struct({ +export const KeybindingsInvalidEntryIssue = Schema.Struct({ kind: Schema.Literal("keybindings.invalid-entry"), message: TrimmedNonEmptyString, index: Schema.Number, }); -export const ServerConfigIssue = Schema.Union([ +export const KeybindingsConfigIssue = Schema.Union([ KeybindingsMalformedConfigIssue, KeybindingsInvalidEntryIssue, ]); -export type ServerConfigIssue = typeof ServerConfigIssue.Type; +export type KeybindingsConfigIssue = typeof KeybindingsConfigIssue.Type; +export const ServerConfigIssue = KeybindingsConfigIssue; +export type ServerConfigIssue = KeybindingsConfigIssue; -const ServerConfigIssues = Schema.Array(ServerConfigIssue); +const KeybindingsConfigIssues = Schema.Array(KeybindingsConfigIssue); +const AppearanceConfigIssues = Schema.Array(AppearanceConfigIssue); +export const ServerConfigSection = Schema.Literals(["keybindings", "appearance"]); +export type ServerConfigSection = typeof ServerConfigSection.Type; export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; @@ -48,8 +54,11 @@ const ServerProviderStatuses = Schema.Array(ServerProviderStatus); export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, + appearanceConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, - issues: ServerConfigIssues, + appearance: ResolvedAppearanceConfig, + keybindingsIssues: KeybindingsConfigIssues, + appearanceIssues: AppearanceConfigIssues, providers: ServerProviderStatuses, availableEditors: Schema.Array(EditorId), }); @@ -60,12 +69,14 @@ export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Typ export const ServerUpsertKeybindingResult = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, - issues: ServerConfigIssues, + keybindingsIssues: KeybindingsConfigIssues, }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; export const ServerConfigUpdatedPayload = Schema.Struct({ - issues: ServerConfigIssues, + changedSections: Schema.Array(ServerConfigSection), + keybindingsIssues: KeybindingsConfigIssues, + appearanceIssues: AppearanceConfigIssues, providers: ServerProviderStatuses, }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;