refactor(tui): migrate workflow views to V2 themes (#37526)

This commit is contained in:
James Long 2026-07-17 15:18:58 -04:00 committed by GitHub
parent f2a4011371
commit c7a7900ff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 243 additions and 138 deletions

View file

@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
const data = useData()
const location = useLocation()
const exit = useExit()
@ -1087,7 +1087,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background}
backgroundColor={themeV2.background()}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

View file

@ -1,10 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { createSignal, For } from "solid-js"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
return (
<box
@ -16,6 +17,27 @@ export function DevToolsSidebar() {
paddingRight={2}
backgroundColor={themeV2.background()}
>
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
Theme
</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued()}>Mode</text>
<box flexGrow={1} />
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(true)}
onMouseOut={() => setModeHovered(false)}
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
>
<text fg={themeV2.text()}>{mode()}</text>
</box>
</box>
</box>
<For each={DevTools.data()}>
{(group) => (
<box flexShrink={0} marginBottom={1}>

View file

@ -62,7 +62,7 @@ export function connectionSummary(integration: IntegrationInfo) {
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
const methods = connectMethods(integration)
@ -74,7 +74,7 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
gutter: connected ? () => <text fg={themeV2.text.feedback.success()}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog, props.onConnected)
@ -89,12 +89,12 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No integrations available</text>
<text fg={themeV2.text.subdued()}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No integrations found</text>
<text fg={themeV2.text.subdued()}>No integrations found</text>
</box>
}
/>
@ -289,23 +289,30 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text fg={theme.text}>{props.output.trim()}</text>
<box
backgroundColor={overlayTheme.background()}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text()}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted}>{props.message}</text>
<text fg={themeV2.text.subdued()}>{props.message}</text>
</box>
</box>
)
@ -320,7 +327,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [error, setError] = createSignal<string>()
return (
@ -338,7 +345,9 @@ function KeyMethod(props: {
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
description={() => (
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
)}
/>
)
}
@ -493,7 +502,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@ -527,9 +536,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.primary} />
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
<text fg={themeV2.text.subdued()}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={themeV2.markdown.link()} />
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
</box>
)}
/>
@ -538,31 +547,31 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={theme.primary} />
<Link href={url()} fg={themeV2.markdown.link()} />
<Show when={props.instructions}>
{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}
{(instructions) => <text fg={themeV2.text.subdued()}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={theme.textMuted}>{props.message}</text>
<text fg={themeV2.text.subdued()}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
<text fg={themeV2.text()}>
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
</text>
</Show>
</box>

View file

@ -4,7 +4,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme, type Theme } from "../context/theme"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@ -12,30 +12,59 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import type { ComponentTheme } from "../theme/v2/component"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
function statusMeta(status: McpServer["status"], theme: Theme) {
function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) {
switch (status.status) {
case "needs_auth":
return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false }
return {
rank: 0,
icon: "!",
label: "Needs authentication",
color: themeV2.text.feedback.warning(),
error: undefined,
bold: false,
}
case "needs_client_registration":
return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false }
return {
rank: 1,
icon: "✗",
label: "Needs registration",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "failed":
return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false }
return {
rank: 2,
icon: "✗",
label: "Failed",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "connected":
return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true }
return {
rank: 3,
icon: "✓",
label: "Connected",
color: themeV2.text.feedback.success(),
error: undefined,
bold: true,
}
case "pending":
return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false }
return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false }
default:
return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false }
return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false }
}
}
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
@ -47,7 +76,7 @@ export function DialogMcp() {
pipe(
data.location.mcp.server.list() ?? [],
sortBy(
(server) => statusMeta(server.status, theme).rank,
(server) => statusMeta(server.status, themeV2).rank,
(server) => server.name,
),
),
@ -61,7 +90,7 @@ export function DialogMcp() {
const options = createMemo(() =>
servers().map((server) => {
const meta = statusMeta(server.status, theme)
const meta = statusMeta(server.status, themeV2)
return {
value: server.name,
title: server.name,
@ -77,12 +106,12 @@ export function DialogMcp() {
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusMeta(server.status, theme).error : undefined
return server ? statusMeta(server.status, themeV2).error : undefined
})
const open = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, theme).error) return
if (!server || !statusMeta(server.status, themeV2).error) return
setDetail(server)
}
@ -100,7 +129,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
footer={
<Show when={focusedError()}>
<text fg={theme.textMuted}>enter to view error</text>
<text fg={themeV2.text.subdued()}>enter to view error</text>
</Show>
}
/>
@ -116,11 +145,12 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error"
const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
@ -152,29 +182,35 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
MCP server: {props.server.name}
</text>
<text fg={theme.textMuted} onMouseUp={props.onBack}>
<text fg={themeV2.text.subdued()} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.error}> Failed</text>
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text fg={themeV2.text.feedback.error()}> Failed</text>
<box
backgroundColor={overlayTheme.background()}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={theme.text} wrapMode="word">
<text fg={overlayTheme.text()} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}> scroll</text>
<text fg={theme.textMuted} onMouseUp={copy}>
<text fg={themeV2.text.subdued()}> scroll</text>
<text fg={themeV2.text.subdued()} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>

View file

@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@ -172,16 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return {
title,
titleView: isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
<span style={{ fg: themeV2.text.feedback.error() }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
<span style={{ fg: themeV2.text.action.destructive() }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? theme.error : undefined,
bg: deleting ? themeV2.background.action.destructive() : undefined,
value: {
type: "directory",
directory: item.location,
@ -314,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
@ -327,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
<text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued()}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading project directories</text>
<text fg={themeV2.text.subdued()}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories available</text>
<text fg={themeV2.text.subdued()}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories found</text>
<text fg={themeV2.text.subdued()}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}

View file

@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { selectedForeground, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@ -38,10 +38,9 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const { themeV2 } = useTheme().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background()) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
@ -86,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()} bg={textBg()}>
{props.title}
</text>
<text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={theme.textMuted} bg={textBg()}>
<text fg={themeV2.text.subdued()} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={themeV2.markdown.link()} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={theme.primary} wrapMode="none" />
<Link href={props.link} fg={themeV2.markdown.link()} wrapMode="none" />
</box>
)
) : (
@ -115,12 +114,14 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={
selected() === "dismiss" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? fg : theme.textMuted}
fg={selected() === "dismiss" ? themeV2.text.action("focused") : themeV2.text.subdued()}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
@ -130,12 +131,12 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={selected() === "action" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? fg : theme.text}
fg={selected() === "action" ? themeV2.text.action("focused") : themeV2.text()}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>

View file

@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
<text fg={themeV2.text.subdued()} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
<text fg={themeV2.text.subdued()} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
backgroundColor={item.id === store.active ? themeV2.background.action("focused") : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
@ -94,11 +94,14 @@ export function DialogSessionDeleteFailed(props: {
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text()}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
<text
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}
wrapMode="word"
>
{item.description}
</text>
</box>

View file

@ -31,14 +31,15 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
const fileNameWidth = createMemo(() =>
Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
)
function confirm() {
@ -71,21 +72,21 @@ export function DialogWorkspaceFileChanges(props: {
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
{props.title ?? "File Changes Found"}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
<text fg={themeV2.text.subdued()} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
<scrollbox
height={height()}
backgroundColor={theme.backgroundElement}
backgroundColor={overlayTheme.background()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
@ -94,15 +95,19 @@ export function DialogWorkspaceFileChanges(props: {
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
<text fg={overlayTheme.text.subdued()}>{statusLabel(item.status)}</text>
</box>
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued()} />
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
{" "}
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
{item.additions ? (
<span style={{ fg: overlayTheme.diff.text.added() }}>+{item.additions}</span>
) : null}
{item.deletions ? (
<span style={{ fg: overlayTheme.diff.text.removed() }}> -{item.deletions}</span>
) : null}
</text>
</box>
</box>
@ -115,14 +120,14 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
backgroundColor={item === store.active ? themeV2.background.action("focused") : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
<text fg={item === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>{item}</text>
</box>
)}
</For>

View file

@ -26,7 +26,8 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
Keymap.createLayer(() => ({
commands: [
@ -42,19 +43,19 @@ function Scrap(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background()}>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
backgroundColor={elevatedTheme.background()}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
<text fg={elevatedTheme.text.subdued()}>~/code/anomalyco/opencode</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>esc home</text>
<text fg={elevatedTheme.text.subdued()}>esc home</text>
</box>
</box>
)

View file

@ -267,7 +267,7 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundMenu },
{ step: 300, color: theme.backgroundElement || theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
]

View file

@ -17,7 +17,8 @@ type Active = ExportFormat | "debug" | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
debug: false,
@ -75,25 +76,33 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
Export session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>Export as:</text>
<text fg={themeV2.text()}>Export as:</text>
<box flexDirection="row" gap={1}>
<For each={["markdown", "json"] as const}>
{(format) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={store.format === format ? theme.backgroundElement : undefined}
backgroundColor={themeV2.background.formfield({
focused: store.active === format,
selected: store.format === format,
})}
onMouseUp={() => selectFormat(format)}
>
<text fg={store.format === format ? theme.text : theme.textMuted}>
<text
fg={themeV2.text.formfield({
focused: store.active === format,
selected: store.format === format,
})}
>
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
</text>
</box>
@ -105,43 +114,60 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<box
flexDirection="row"
gap={1}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
backgroundColor={themeV2.background.formfield({
focused: store.active === "thinking",
selected: store.thinking,
})}
onMouseUp={() => {
setStore("active", "thinking")
setStore("thinking", !store.thinking)
}}
>
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
{store.thinking ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
Include thinking
</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
flexDirection="row"
gap={1}
backgroundColor={store.active === "debug" ? theme.backgroundElement : undefined}
backgroundColor={themeV2.background.formfield({
focused: store.active === "debug",
selected: store.debug,
})}
onMouseUp={() => {
setStore("active", "debug")
setStore("debug", !store.debug)
}}
>
<text fg={store.active === "debug" ? theme.primary : theme.textMuted}>{store.debug ? "[x]" : "[ ]"}</text>
<text fg={store.active === "debug" ? theme.primary : theme.text}>Events (debug)</text>
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
{store.debug ? "[x]" : "[ ]"}
</text>
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
Events (debug)
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={theme.backgroundElement}
backgroundColor={overlayTheme.background()}
onMouseUp={() => confirm("copy")}
>
<text fg={theme.text}>Copy</text>
<text fg={overlayTheme.text()}>Copy</text>
</box>
<box paddingLeft={4} paddingRight={4} backgroundColor={theme.primary} onMouseUp={() => confirm("export")}>
<text fg={theme.selectedListItemText}>Export</text>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={themeV2.background.action({ focused: store.active === "export" })}
onMouseUp={() => confirm("export")}
>
<text fg={themeV2.text.action({ focused: store.active === "export" })}>Export</text>
</box>
</box>
</box>

View file

@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
placeholderColor={themeV2.text.subdued()}
textColor={themeV2.text.formfield({ disabled: props.busy })}
focusedTextColor={themeV2.text.formfield({ disabled: props.busy })}
cursorColor={props.busy ? themeV2.background.formfield("disabled") : themeV2.text()}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
<Spinner color={themeV2.text.subdued()}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued()}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={theme.text}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.textMuted }}>submit</span>
<text fg={themeV2.text()}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued() }}>submit</span>
</text>
</Show>
</Show>

View file

@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const renderer = useRenderer()
let dismiss = false
@ -59,7 +59,7 @@ export function Dialog(
}}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
paddingTop={1}
>
{props.children}

View file

@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { theme, themeV2 } = useTheme()
const { themeV2 } = useTheme().contextual("overlay")
const dimensions = useTerminalDimensions()
return (
@ -31,8 +31,8 @@ export function Toast() {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundPanel}
borderColor={theme[current().variant]}
backgroundColor={themeV2.background()}
borderColor={themeV2.text.feedback[current().variant]()}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>

View file

@ -85,7 +85,7 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using borders", () => {
test("builds gray from V1 surfaces and text without using menus or borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
@ -97,12 +97,12 @@ test("builds gray from V1 surfaces and text without using borders", () => {
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundMenu))
expect(lightGray[300]).toBe(hex(light.backgroundElement))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundMenu))
expect(darkGray[700]).toBe(hex(dark.backgroundElement))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))