diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index c4781113..b26062ef 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -26,6 +26,7 @@ const PreferencesSchema = z showUsageMetrics: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), + logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), // OS notifications osNotificationsEnabled: z.boolean().default(false), diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index e693a96d..d734ea3e 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co if (typeof listeningMode === "string") { serverConfig.listeningMode = listeningMode } + const logLevel = preferences.logLevel + if (typeof logLevel === "string") { + serverConfig.logLevel = logLevel + } const lastUsedBinary = preferences.lastUsedBinary if (typeof lastUsedBinary === "string") { serverConfig.opencodeBinary = lastUsedBinary @@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co const moved = new Set([ "environmentVariables", "listeningMode", + "logLevel", "lastUsedBinary", "modelRecents", "modelFavorites", diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index 45924076..f4f0409c 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -1,6 +1,7 @@ import type { Logger } from "../logger" import type { EventBus } from "../events/bus" import type { ConfigLocation } from "../config/location" +import { z } from "zod" import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { migrateSettingsLayout } from "./migrate" import type { WorkspaceEventPayload } from "../api-types" @@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config" export type DocKind = "config" | "state" +const CanonicalLogLevelSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toUpperCase() : value), + z.enum(["DEBUG", "INFO", "WARN", "ERROR"]), +) + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return false + } +} + +function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { + if (!isPlainObject(value)) { + return {} + } + + const next: SettingsDoc = { ...value } + const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel) + if (parsedLogLevel.success) { + next.logLevel = parsedLogLevel.data + } else if (next.logLevel !== undefined) { + next.logLevel = "DEBUG" + } + return next +} + +function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc { + if (!isPlainObject(doc)) { + return {} + } + + if (!isPlainObject(doc.server)) { + return doc + } + + return { + ...doc, + server: normalizeServerConfigOwner(doc.server as SettingsDoc), + } +} + export class SettingsService { private readonly configStore: YamlDocStore private readonly stateStore: YamlDocStore @@ -23,22 +72,44 @@ export class SettingsService { } getDoc(kind: DocKind): SettingsDoc { - return kind === "config" ? this.configStore.get() : this.stateStore.get() + if (kind !== "config") { + return this.stateStore.get() + } + + const current = this.configStore.get() + const normalized = normalizeConfigDoc(current) + if (!isDeepEqual(current, normalized)) { + this.configStore.replace(normalized) + } + return normalized } mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { - const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) + const updated = + kind === "config" + ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch))) + : this.stateStore.mergePatch(patch) this.publish(kind, "*") return updated } getOwner(kind: DocKind, owner: string): SettingsDoc { - return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) + if (kind !== "config") { + return this.stateStore.getOwner(owner) + } + + return owner === "server" + ? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc) + : this.getDoc("config")[owner] as SettingsDoc } mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { const updated = - kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) + kind === "config" + ? owner === "server" + ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch))) + : this.configStore.mergePatchOwner(owner, patch) + : this.stateStore.mergePatchOwner(owner, patch) this.publish(kind, owner, updated) return updated } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 805b8f95..dc939758 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -142,12 +142,15 @@ export class WorkspaceManager { [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, } + const logLevel = (serverConfig as any)?.logLevel + try { const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, environment, + logLevel, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 0246fbfd..1269f0b7 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -116,6 +116,7 @@ interface LaunchOptions { folder: string binaryPath: string environment?: Record + logLevel?: string onExit?: (info: ProcessExitInfo) => void } @@ -139,7 +140,8 @@ export class WorkspaceRuntime { async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) - const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] + const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" + const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx index 8af940eb..8c0bc01b 100644 --- a/packages/ui/src/components/settings/opencode-settings-section.tsx +++ b/packages/ui/src/components/settings/opencode-settings-section.tsx @@ -1,14 +1,30 @@ -import { createEffect, createSignal, type Component } from "solid-js" -import { Terminal } from "lucide-solid" +import { Select } from "@kobalte/core/select" +import { createEffect, createMemo, createSignal, type Component } from "solid-js" +import { ChevronDown, Terminal } from "lucide-solid" import OpenCodeBinarySelector from "../opencode-binary-selector" import EnvironmentVariablesEditor from "../environment-variables-editor" import { useConfig } from "../../stores/preferences" +import type { ServerLogLevel } from "../../stores/preferences" import { useI18n } from "../../lib/i18n" +type LogLevelOption = { + value: ServerLogLevel + label: string +} + export const OpenCodeSettingsSection: Component = () => { const { t } = useI18n() - const { serverSettings, updateLastUsedBinary } = useConfig() + const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const logLevelOptions = createMemo(() => [ + { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, + { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, + { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, + { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, + ]) + const selectedLogLevel = createMemo( + () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], + ) createEffect(() => { const binary = serverSettings().opencodeBinary || "opencode" @@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => { +
+
+
+

{t("settings.opencode.logLevel.title")}

+

{t("settings.opencode.logLevel.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
+
+
{t("settings.opencode.logLevel.selector.title")}
+
{t("settings.opencode.logLevel.selector.subtitle")}
+
+ + value={selectedLogLevel()} + onChange={(option) => { + if (!option) return + updateLogLevel(option.value) + }} + options={logLevelOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => ( + + {state.selectedOption()?.label} + + )} + +
+ + + +
+ + + + + + + +
+
+
+
diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index bdd4a710..a81e4e4f 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -113,6 +113,15 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode Log Level", + "settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.", + "settings.opencode.logLevel.selector.title": "Default log level", + "settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.", + "settings.opencode.logLevel.option.debug": "Debug", + "settings.opencode.logLevel.option.info": "Info", + "settings.opencode.logLevel.option.warn": "Warn", + "settings.opencode.logLevel.option.error": "Error", + "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index ea17bb48..8c3210d6 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Nivel de logs de OpenCode", + "settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.", + "settings.opencode.logLevel.selector.title": "Verbosidad de logs", + "settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.", + "settings.opencode.logLevel.option.debug": "Depuracion", + "settings.opencode.logLevel.option.info": "Informacion", + "settings.opencode.logLevel.option.warn": "Advertencia", + "settings.opencode.logLevel.option.error": "Error", "settings.appearance.behavior.title": "Interaccion", "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 5a543305..cf9ed3ed 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Niveau de logs OpenCode", + "settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.", + "settings.opencode.logLevel.selector.title": "Verbosite des logs", + "settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.", + "settings.opencode.logLevel.option.debug": "Debogage", + "settings.opencode.logLevel.option.info": "Info", + "settings.opencode.logLevel.option.warn": "Avertissement", + "settings.opencode.logLevel.option.error": "Erreur", "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 534a6d30..836dce28 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -112,6 +112,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.", "settings.opencode.runtime.title": "סביבת ריצה", "settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.", + "settings.opencode.logLevel.title": "רמת הלוגים של OpenCode", + "settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.", + "settings.opencode.logLevel.selector.title": "פירוט לוגים", + "settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.", + "settings.opencode.logLevel.option.debug": "ניפוי שגיאות", + "settings.opencode.logLevel.option.info": "מידע", + "settings.opencode.logLevel.option.warn": "אזהרה", + "settings.opencode.logLevel.option.error": "שגיאה", "settings.appearance.behavior.title": "אינטראקציה", "settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 9373a8ca..6eedf011 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode のログレベル", + "settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。", + "settings.opencode.logLevel.selector.title": "ログ出力の詳細度", + "settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。", + "settings.opencode.logLevel.option.debug": "デバッグ", + "settings.opencode.logLevel.option.info": "情報", + "settings.opencode.logLevel.option.warn": "警告", + "settings.opencode.logLevel.option.error": "エラー", "settings.appearance.behavior.title": "操作", "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 2b3e0fa1..656b4692 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Уровень логирования OpenCode", + "settings.opencode.logLevel.subtitle": "Задайте уровень логирования, используемый при запуске новых экземпляров OpenCode.", + "settings.opencode.logLevel.selector.title": "Подробность логов", + "settings.opencode.logLevel.selector.subtitle": "Выберите, сколько логов должны выводить новые экземпляры OpenCode.", + "settings.opencode.logLevel.option.debug": "Отладка", + "settings.opencode.logLevel.option.info": "Информация", + "settings.opencode.logLevel.option.warn": "Предупреждение", + "settings.opencode.logLevel.option.error": "Ошибка", "settings.appearance.behavior.title": "Взаимодействие", "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 75303a7b..ec79a1f2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode 日志级别", + "settings.opencode.logLevel.subtitle": "设置启动新的 OpenCode 实例时使用的日志级别。", + "settings.opencode.logLevel.selector.title": "日志详细程度", + "settings.opencode.logLevel.selector.subtitle": "选择新的 OpenCode 实例应输出多少日志信息。", + "settings.opencode.logLevel.option.debug": "调试", + "settings.opencode.logLevel.option.info": "信息", + "settings.opencode.logLevel.option.warn": "警告", + "settings.opencode.logLevel.option.error": "错误", "settings.appearance.behavior.title": "交互", "settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 0d4cff6e..a1ef1c72 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -28,6 +28,7 @@ export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" export type ListeningMode = "local" | "all" +export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" export type SpeechProviderPreference = "openai-compatible" export type SpeechPlaybackMode = "streaming" | "buffered" export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac" @@ -94,6 +95,7 @@ interface UiConfigBucket { interface ServerConfigBucket { listeningMode?: ListeningMode + logLevel?: ServerLogLevel environmentVariables?: Record opencodeBinary?: string speech?: Partial @@ -272,13 +274,17 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { function normalizeServerConfig( input?: ServerConfigBucket | null, -): Required> & { speech: SpeechSettings } { +): Required> & { speech: SpeechSettings } { const source = input ?? {} const listeningMode = source.listeningMode === "all" ? "all" : "local" + const logLevel = + source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" + ? source.logLevel + : "DEBUG" const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" const environmentVariables = normalizeRecord(source.environmentVariables) const speech = normalizeSpeechSettings(source.speech) - return { listeningMode, opencodeBinary, environmentVariables, speech } + return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech } } function getModelKey(model: { providerId: string; modelId: string }): string { @@ -409,6 +415,11 @@ function updateLastUsedBinary(path: string): void { void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) } +function updateLogLevel(level: ServerLogLevel): void { + const target = level ?? "DEBUG" + void patchConfigOwner("server", { logLevel: target }).catch((error) => log.error("Failed to set log level", error)) +} + async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise { const apiKeyPatch = updates.apiKey const { apiKey: _apiKey, ...restUpdates } = updates @@ -612,8 +623,9 @@ interface ConfigContextValue { updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable - updateLastUsedBinary: typeof updateLastUsedBinary - updateSpeechSettings: typeof updateSpeechSettings + updateLastUsedBinary: typeof updateLastUsedBinary + updateLogLevel: typeof updateLogLevel + updateSpeechSettings: typeof updateSpeechSettings // ui-owned state recentFolders: typeof recentFolders @@ -663,6 +675,7 @@ const configContextValue: ConfigContextValue = { addEnvironmentVariable, removeEnvironmentVariable, updateLastUsedBinary, + updateLogLevel, updateSpeechSettings, recentFolders, opencodeBinaries, @@ -746,6 +759,7 @@ export { addEnvironmentVariable, removeEnvironmentVariable, updateLastUsedBinary, + updateLogLevel, updateSpeechSettings, addRecentFolder, removeRecentFolder,