Compare commits

..

3 Commits

Author SHA1 Message Date
Hona eef1405299 feat(app): move tab layout to general settings 2026-09-12 23:08:34 +00:00
Dax Raad 9c8a4ea4ff fix(cli): replace update preflight renderer 2026-09-12 18:56:37 -04:00
Dax Raad c82340a97b fix(tui): probe palette only for system theme 2026-09-12 18:16:21 -04:00
8 changed files with 60 additions and 73 deletions
@@ -432,7 +432,7 @@ for (const profile of [
})
}
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
test("general settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -452,12 +452,9 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
await expect(settings).toBeVisible()
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
await settings.getByRole("tab", { name: "Preferences" }).click()
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
@@ -469,6 +466,8 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const projectNames = page.locator('[data-slot="vertical-tabs-sidebar"] [data-slot="tab-project"]')
await expect(projectNames).toHaveCount(0)
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
const projectNameSwitch = settings.getByRole("switch", { name: "Show project names", exact: true })
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
await expect(projectNameSwitch).toBeChecked()
@@ -1,5 +1,4 @@
import { Component, Show } from "solid-js"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -8,8 +7,6 @@ import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
@@ -31,24 +28,6 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
/>
</SettingsRow>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
@@ -27,6 +27,7 @@ import {
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
const fontSettings = {
ui: {
action: "settings-ui-font",
@@ -294,6 +295,31 @@ const LanguageSetting = () => {
)
}
const TabLayoutSetting = () => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
/>
</SettingsRow>
)
}
export const SettingsGeneral: Component = () => {
const language = useLanguage()
const platform = usePlatform()
@@ -320,6 +346,7 @@ export const SettingsGeneral: Component = () => {
<h3 class="settings-section-title">{language.t("settings.general.section.general")}</h3>
<SettingsList>
<LanguageSetting />
<TabLayoutSetting />
<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
+6 -6
View File
@@ -23,6 +23,12 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
{
tab: "general",
label: "settings.appearance.row.tabs.title",
target: "settings-tab-layout",
keywords: "vertical horizontal tabs",
},
{
tab: "general",
label: "settings.workspaces.default.title",
@@ -172,12 +178,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
target: "settings-experimental-browser",
available: "browser",
},
{
tab: "experimental",
label: "settings.appearance.row.tabs.title",
target: "settings-tab-layout",
keywords: "vertical horizontal tabs",
},
{ tab: "experimental", label: "settings.appearance.row.projectName.title", target: "settings-show-project-name" },
{
tab: "experimental",
@@ -73,6 +73,7 @@ describe("settings search index", () => {
.map((item) => item.page),
).toEqual(["Desktop notifications", "Sound effects"])
expect(rankSettings("terminal placement", items, root)[0].page).toBe("Preferences / General")
expect(rankSettings("vertical tabs", items, root)[0].page).toBe("Preferences / General")
expect(items.some((item) => item.view.target === "settings-project-startup")).toBe(false)
})
+14 -36
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
@@ -30,17 +30,11 @@ const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly loading: () => void
readonly finish: () => Promise<Handoff | undefined>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
let session: Promise<Session | undefined> | undefined
return {
@@ -72,7 +66,7 @@ export const make = (): Handle => {
type Session = {
readonly loading: () => Promise<void>
readonly finish: () => Promise<Handoff>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
@@ -83,7 +77,6 @@ async function open(from?: string): Promise<Session> {
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@@ -101,20 +94,17 @@ async function open(from?: string): Promise<Session> {
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
),
renderer,
).catch((error) => {
@@ -148,10 +138,8 @@ async function open(from?: string): Promise<Session> {
if (completed) await setTimeout(hold)
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
@@ -174,18 +162,8 @@ async function open(from?: string): Promise<Session> {
await waitForStage()
await transitionTo("success", completionHold)
})
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), setTimeout(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
await close()
return undefined
},
fail: (message) =>
settle(async () => {
+5 -1
View File
@@ -159,7 +159,9 @@ const themeContext = createSimpleContext({
createEffect(() => {
const theme = config.theme?.name
if (theme) setStore("active", theme)
if (!theme) return
setStore("active", theme)
if (theme === "system") refreshPalette()
})
createEffect(() => {
@@ -204,6 +206,7 @@ const themeContext = createSimpleContext({
let queued = false
let disposed = false
function refreshPalette() {
if (store.active !== "system") return
queued = true
if (!canProbe || probing || disposed) return
@@ -347,6 +350,7 @@ const themeContext = createSimpleContext({
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
if (theme === "system") refreshPalette()
void configState
.update((draft) => {
draft.theme = { ...draft.theme, name: theme }
+2 -3
View File
@@ -15,17 +15,16 @@ const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>
const parsed = new WeakMap<object, ThemeDocument>()
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
function listThemes() {
function listThemes(): Record<string, ThemeDocumentSource> {
// Priority: defaults < plugin installs < custom files < generated system.
const themes: Record<string, ThemeDocumentSource> = {
...DEFAULT_THEMES,
...pluginThemes,
...customThemes,
}
if (!systemTheme) return themes
return {
...themes,
system: systemTheme,
system: systemTheme ?? themes.system ?? themes.opencode,
}
}