Add log level configuration support (#272)

Add log level configuration support via config.yaml and UI settings.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
bluelovers
2026-04-02 18:12:33 +08:00
committed by GitHub
parent e82e529a8f
commit 893d5f9296
14 changed files with 235 additions and 12 deletions

View File

@@ -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),

View File

@@ -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",

View File

@@ -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<string, unknown> {
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
}

View File

@@ -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),
})

View File

@@ -116,6 +116,7 @@ interface LaunchOptions {
folder: string
binaryPath: string
environment?: Record<string, string>
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; 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

View File

@@ -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<LogLevelOption[]>(() => [
{ 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 = () => {
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-body">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
</div>
<Select<LogLevelOption>
value={selectedLogLevel()}
onChange={(option) => {
if (!option) return
updateLogLevel(option.value)
}}
options={logLevelOptions()}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
<div class="flex-1 min-w-0">
<Select.Value<LogLevelOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 וקלט.",

View File

@@ -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": "メッセージ、差分、入力の既定値。",

View File

@@ -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": "Значения по умолчанию для сообщений, диффов и ввода.",

View File

@@ -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": "消息、差异与输入的默认值。",

View File

@@ -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<string, string>
opencodeBinary?: string
speech?: Partial<SpeechSettings>
@@ -272,13 +274,17 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
function normalizeServerConfig(
input?: ServerConfigBucket | null,
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary">> & { 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<void> {
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,