Compare commits

..

1 Commits

Author SHA1 Message Date
Sebastian Herrlinger
12dfd7e6a8 show scrollbar by default 2026-02-26 21:41:01 +01:00
4 changed files with 69 additions and 166 deletions

View File

@@ -152,7 +152,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)

View File

@@ -1,9 +1,9 @@
import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { mergeDeep, unique } from "remeda"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo } from "./tui-schema"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
@@ -17,96 +17,86 @@ const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
interface SourceGroup {
target: string
files: string[]
}
interface LegacyFile {
file: string
source: string
legacy: Record<string, unknown>
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from server config files
* into dedicated tui.json files. Source files are merged in server precedence
* order before writing each target tui.json.
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const groups = await sourceGroups(input)
for (const group of groups) {
const targetExists = await Filesystem.exists(group.target)
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Filesystem.exists(target)
if (targetExists) continue
const parsed = (await Promise.all(group.files.map(parseLegacyFile))).filter((item): item is LegacyFile => !!item)
if (!parsed.length) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const payload = parsed.reduce((acc, item) => mergeDeep(acc, item.legacy), { $schema: TUI_SCHEMA_URL } as Record<
string,
unknown
>)
const wrote = await Bun.write(group.target, JSON.stringify(payload, null, 2))
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", {
from: parsed.map((item) => item.file),
to: group.target,
error,
})
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await Promise.all(parsed.map((item) => backupAndStripLegacy(item.file, item.source)))
if (stripped.some((ok) => !ok)) {
log.warn("tui config migrated but some source files were not stripped", {
from: parsed.map((item) => item.file),
to: group.target,
})
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", {
from: parsed.map((item) => item.file),
to: group.target,
})
log.info("migrated tui config", { from: file, to: target })
}
}
async function parseLegacyFile(file: string) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) return
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) return
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const legacy: Record<string, unknown> = {}
if (theme.success && theme.data !== undefined) legacy.theme = theme.data
if (keybinds.success && keybinds.data !== undefined) legacy.keybinds = keybinds.data
if (legacyTui.success && legacyTui.data) Object.assign(legacy, legacyTui.data)
if (!Object.keys(legacy).length) return
return {
file,
source,
legacy,
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
@@ -144,24 +134,15 @@ async function backupAndStripLegacy(file: string, source: string) {
})
}
async function sourceGroups(input: MigrateInput): Promise<SourceGroup[]> {
const files = [
path.join(Global.Path.config, "config.json"),
path.join(Global.Path.config, "opencode.json"),
path.join(Global.Path.config, "opencode.jsonc"),
]
if (input.custom) files.push(input.custom)
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
files.push(...(await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)))
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
@@ -170,16 +151,5 @@ async function sourceGroups(input: MigrateInput): Promise<SourceGroup[]> {
return ok ? file : undefined
}),
)
const result = new Map<string, string[]>()
for (const file of existing) {
if (!file) continue
const target = path.join(path.dirname(file), "tui.json")
result.set(target, [...(result.get(target) ?? []), file])
}
return Array.from(result.entries()).map(([target, groupFiles]) => ({
target,
files: groupFiles,
}))
return existing.filter((file): file is string => !!file)
}

View File

@@ -107,7 +107,7 @@ export namespace TuiConfig {
}
})()
const parsed = Info.strip().safeParse(normalized)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}

View File

@@ -80,73 +80,6 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
})
})
test("merges legacy tui keys from opencode.jsonc and opencode.json before migrating", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
"theme": "from-jsonc",
"tui": {
"scroll_speed": 2,
"legacy_alpha": 1,
"legacy_nested": { "from_jsonc": true }
},
"keybinds": { "app_exit": "ctrl+q" }
}`,
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "from-json",
keybinds: { theme_list: "ctrl+k" },
tui: {
legacy_beta: 2,
legacy_nested: { from_json: true },
},
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("from-json")
expect(config.scroll_speed).toBe(2)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
theme: "from-json",
scroll_speed: 2,
legacy_alpha: 1,
legacy_beta: 2,
legacy_nested: { from_jsonc: true, from_json: true },
keybinds: { app_exit: "ctrl+q", theme_list: "ctrl+k" },
})
const json = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(json.theme).toBeUndefined()
expect(json.keybinds).toBeUndefined()
const jsonc = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(jsonc).not.toContain('"theme"')
expect(jsonc).not.toContain('"keybinds"')
expect(jsonc).not.toContain('"tui"')
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(true)
},
})
})
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -180,7 +113,7 @@ test("migrates project legacy tui keys even when global tui.json already exists"
})
})
test("preserves unknown legacy tui keys during migration", async () => {
test("drops unknown legacy tui keys during migration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
@@ -207,7 +140,7 @@ test("preserves unknown legacy tui keys during migration", async () => {
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBe(1)
expect(migrated.foo).toBeUndefined()
},
})
})