Compare commits

...

5 Commits

Author SHA1 Message Date
Sebastian Herrlinger a7eac991cf test(core): add FSEvents stress diagnostic 2026-07-28 21:04:38 +00:00
Aiden Cline f95d04fea0 feat(core): improve shell tool guidance (#39401) 2026-07-28 15:53:19 -05:00
James Long 08b80da931 refactor(tui): split theme hooks (#39395) 2026-07-28 16:25:32 -04:00
Aiden Cline f6fb1a7cdd fix(ai): retry transient client statuses (#39391) 2026-07-28 14:25:33 -05:00
Dax Raad 5bcc0016a6 feat(tui): add plugin context hook 2026-07-28 14:57:59 -04:00
70 changed files with 1193 additions and 966 deletions
+3
View File
@@ -596,17 +596,20 @@
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
"solid-js",
],
},
"packages/protocol": {
+1 -2
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status !== undefined && input.status >= 500)
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
+6
View File
@@ -58,6 +58,12 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -0,0 +1,57 @@
import watcher from "@parcel/watcher"
import { execFileSync } from "node:child_process"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const rounds = Number(process.env.FSEVENTS_ROUNDS ?? 100)
const width = Number(process.env.FSEVENTS_WIDTH ?? 25)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-fsevents-"))
const targets = Array.from({ length: width }, (_, index) => path.join(root, `project-${index}`))
await Promise.all(targets.map((target) => fs.mkdir(target)))
function openFiles() {
try {
return Number(execFileSync("sh", ["-c", `lsof -p ${process.pid} 2>/dev/null | wc -l`], { encoding: "utf8" }).trim())
} catch {
return -1
}
}
function sample(round) {
const memory = process.memoryUsage()
console.log(
JSON.stringify({
round,
subscriptions: round * width,
rss: memory.rss,
heapUsed: memory.heapUsed,
external: memory.external,
openFiles: openFiles(),
}),
)
}
try {
sample(0)
for (let round = 1; round <= rounds; round++) {
const subscriptions = await Promise.all(
targets.map((target) => watcher.subscribe(target, () => {}, { backend: "fs-events" })),
)
await Promise.all(
targets.map((target, index) => fs.writeFile(path.join(target, "event.txt"), `${round}-${index}`)),
)
await new Promise((resolve) => setTimeout(resolve, 10))
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()))
if (round % 10 === 0 || round === rounds) sample(round)
}
if (globalThis.gc) {
globalThis.gc()
await new Promise((resolve) => setTimeout(resolve, 100))
sample("after-gc")
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
+10 -3
View File
@@ -44,6 +44,7 @@ type Active = {
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
@@ -134,6 +135,13 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
@@ -167,8 +175,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell, options)
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
+33 -9
View File
@@ -15,22 +15,40 @@ import { Shell } from "../../shell"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
const OS =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
const description = (shell?: string) =>
[
"Execute a shell command and return its output.",
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
"Quote file paths containing spaces or special characters.",
"Prefer dedicated tools over shell commands when possible.",
"When output is large, the full result is saved to a file and a truncated preview is returned.",
"Rely on automatic truncation unless filtering the output is more useful.",
"Commands accept an optional timeout, background commands have no timeout by default.",
"Background commands return immediately, and you will be notified when they complete.",
].join(" ")
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.optionalKey(Schema.String).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
description:
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
}),
timeout: Schema.optionalKey(NonNegativeInt).annotate({
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
}),
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
.annotate({
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
@@ -69,13 +87,11 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
@@ -144,7 +160,7 @@ export const Plugin = {
({
name,
options: { codemode: false },
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
@@ -291,5 +307,13 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(yield* shell.name())
}),
)
}),
}
@@ -0,0 +1,46 @@
import watcher from "@parcel/watcher"
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const backend = process.platform === "darwin" ? "fs-events" : process.platform === "linux" ? "inotify" : undefined
const describeNative = backend ? describe : describe.skip
async function descriptors() {
if (process.platform === "linux") return fs.readdir("/proc/self/fd").then((entries) => entries.length)
return Number(Bun.spawnSync(["sh", "-c", `lsof -p ${process.pid} 2>/dev/null | wc -l`]).stdout.toString().trim())
}
describeNative("native watcher stress diagnostic", () => {
test(
"releases native descriptors after repeated subscription churn",
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-watcher-stress-"))
const targets = Array.from({ length: 20 }, (_, index) => path.join(root, `project-${index}`))
await Promise.all(targets.map((target) => fs.mkdir(target)))
const before = await descriptors()
try {
for (let round = 0; round < 100; round++) {
const subscriptions = await Promise.all(
targets.map((target) => watcher.subscribe(target, () => {}, { backend })),
)
await Promise.all(
targets.map((target, index) => fs.writeFile(path.join(target, "event.txt"), `${round}-${index}`)),
)
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()))
}
Bun.gc(true)
await Bun.sleep(250)
const after = await descriptors()
console.log(JSON.stringify({ subscriptions: targets.length * 100, before, after }))
expect(after - before).toBeLessThanOrEqual(4)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
},
60_000,
)
})
+4 -3
View File
@@ -200,10 +200,11 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
+6 -1
View File
@@ -32,7 +32,8 @@
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5"
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -43,6 +44,9 @@
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -52,6 +56,7 @@
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
+1
View File
@@ -1 +1,2 @@
export * as Plugin from "./plugin.js"
export { PluginContextProvider, usePlugin } from "./solid.js"
+19
View File
@@ -0,0 +1,19 @@
import { createComponent, createContext, useContext, type JSX } from "solid-js"
import type { Context } from "./context.js"
const PluginContext = createContext<Context>()
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
return createComponent(PluginContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function usePlugin() {
const context = useContext(PluginContext)
if (!context) throw new Error("PluginContextProvider is missing")
return context
}
+4 -4
View File
@@ -66,7 +66,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme } from "./context/theme"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -423,8 +423,8 @@ function App(props: { pair?: DialogPairCredentials }) {
const event = useEvent()
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -1090,7 +1090,7 @@ function App(props: { pair?: DialogPairCredentials }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
onMouseDown={(evt) => {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
+7 -5
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
@@ -91,9 +93,9 @@ export function BgPulse() {
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={themeV2.background.default}
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
live
/>
)
+40 -40
View File
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { DevTools } from "../devtools"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
@@ -31,12 +31,12 @@ export function DevToolsBar() {
const location = useLocation()
const route = useRoute()
const plugins = usePlugin()
const theme = useTheme()
const themes = useThemes()
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = themes.contextual("elevated")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -196,8 +196,8 @@ export function DevToolsBar() {
})),
},
theme: {
name: theme.selected,
mode: theme.mode(),
name: themes.selected,
mode: themes.mode(),
},
},
null,
@@ -213,7 +213,7 @@ export function DevToolsBar() {
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<Show when={panel()}>
<box
position="absolute"
@@ -230,12 +230,12 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "connected"
? themeV2.text.feedback.success.default
? theme.text.feedback.success.default
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.default
? theme.text.feedback.error.default
: theme.text.default
}
>
{serverIndicator().icon}
@@ -243,10 +243,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -279,10 +279,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{statusIcon(runtime())}
@@ -290,10 +290,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -320,11 +320,11 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={theme.selected} />
<Row label="Name" value={themes.selected} />
<Row label="Mode" value={mode()} />
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
<Show when={canSwitchMode()}>
@@ -336,7 +336,7 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
<Show when={panel() === "tools"}>
<PanelBox>
<PanelTitle>Tools</PanelTitle>
@@ -406,14 +406,14 @@ export function DevToolsBar() {
</Show>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const { themeV2 } = useTheme()
const theme = useTheme()
const renderer = useRenderer()
return (
<box
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick()
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
return (
<box
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{props.value}</text>
<text fg={theme.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
</box>
)
}
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
return (
<box flexDirection="row">
<box width={7}>
<text fg={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
</box>
</box>
)
+4 -4
View File
@@ -1,6 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
@@ -267,7 +267,7 @@ export function settingID(setting: Setting) {
export function DialogConfig(props: { current?: string }) {
const config = useConfig()
const toast = useToast()
const themeState = useTheme()
const themes = useThemes()
const current = Math.max(
0,
settings.findIndex((setting) => settingID(setting) === props.current),
@@ -280,12 +280,12 @@ export function DialogConfig(props: { current?: string }) {
if (!result || typeof result !== "object") return undefined
return (result as Record<string, unknown>)[key]
}, config.data)
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
: setting.values
const display = (setting: Setting) => {
const current = value(setting)
+8 -8
View File
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
import { useTuiApp } from "../context/runtime"
export function DialogDebug() {
const { themeV2 } = useTheme()
const theme = useTheme()
const dialog = useDialog()
const route = useRoute()
const local = useLocal()
@@ -55,10 +55,10 @@ export function DialogDebug() {
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -68,10 +68,10 @@ export function DialogDebug() {
<For each={entries()}>
{(entry) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={themeV2.text.subdued}>
<text flexShrink={0} fg={theme.text.subdued}>
{entry.label.padEnd(10)}
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{entry.value}
</text>
</box>
@@ -79,12 +79,12 @@ export function DialogDebug() {
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>enter</span>
<span style={{ fg: theme.text.subdued }}>enter</span>
</text>
</box>
</box>
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
@@ -64,7 +64,7 @@ export function DialogIntegration(
) {
const data = useData()
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
@@ -87,7 +87,7 @@ export function DialogIntegration(
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0
? () => <text fg={themeV2.text.feedback.success.default}></text>
? () => <text fg={theme.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
@@ -103,12 +103,12 @@ export function DialogIntegration(
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations available</text>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations found</text>
<text fg={theme.text.subdued}>No integrations found</text>
</box>
}
/>
@@ -303,16 +303,16 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
</box>
)
@@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
return (
@@ -360,7 +360,7 @@ function KeyMethod(props: {
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
)}
/>
)
@@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -550,9 +550,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<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.default}>{value()}</text>}</Show>
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.markdown.link} />
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
</box>
)}
/>
@@ -561,31 +561,31 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={themeV2.markdown.link} />
<Link href={url()} fg={theme.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={themeV2.text.default}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
+14 -14
View File
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
}
function Status(props: { enabled: boolean; loading: boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}> Loading</span>
const theme = useThemes().contextual("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: themeV2.text.subdued }}> Disabled</span>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
@@ -110,7 +110,7 @@ export function DialogMcp() {
]}
footer={
<Show when={focusedError()}>
<text fg={themeV2.text.subdued}>enter to view error</text>
<text fg={theme.text.subdued}>enter to view error</text>
</Show>
}
/>
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
@@ -171,14 +171,14 @@ 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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={themeV2.text.feedback.error.default}> Failed</text>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.subdued}> scroll</text>
<text fg={themeV2.text.subdued} onMouseUp={copy}>
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return {
title,
titleView: isRemoving ? (
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: themeV2.text.action.destructive.default }}>
<span style={{ fg: theme.text.action.destructive.default }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? themeV2.background.action.destructive.default : undefined,
bg: deleting ? theme.background.action.destructive.default : undefined,
value: {
type: "directory",
directory: item.location,
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.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={themeV2.text.subdued}>Loading project directories</text>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories available</text>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories found</text>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
+16 -16
View File
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={themeV2.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
<text fg={theme.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
</box>
<box>
<text fg={themeV2.text.subdued}>Username</text>
<text fg={themeV2.text.default}>{value.username}</text>
<text fg={theme.text.subdued}>Username</text>
<text fg={theme.text.default}>{value.username}</text>
</box>
<box>
<text fg={themeV2.text.subdued}>Password</text>
<text fg={theme.text.subdued}>Password</text>
<text
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
fg={passwordHover() ? theme.text.default : theme.text.subdued}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
</text>
</box>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
</Show>
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
</box>
</box>
)
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Pair
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information</text>}>
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information</text>}>
<Show
when={dimensions().height >= 36}
fallback={
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
>
{(error) => (
<box>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
</box>
)}
</Show>
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Name project copy
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholderColor={themeV2.text.subdued}
textColor={themeV2.text.formfield.default}
focusedTextColor={themeV2.text.formfield.default}
cursorColor={themeV2.text.formfield.default}
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
cursorColor={theme.text.formfield.default}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
</text>
</box>
</box>
@@ -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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
@@ -85,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={themeV2.text.default} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={themeV2.text.subdued} bg={textBg()}>
<text fg={theme.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={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
</box>
)
) : (
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.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 ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="word"
>
{item.description}
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -20,7 +20,9 @@ export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const client = useClient()
const local = useLocal()
const toast = useToast()
@@ -109,13 +111,13 @@ export function DialogSessionList() {
value: session.id,
category,
footer,
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
@@ -143,12 +145,12 @@ export function DialogSessionList() {
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No sessions available</text>
<text fg={theme.text.subdued}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
+7 -7
View File
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const data = useData()
const { themeV2 } = useTheme()
const theme = useTheme()
dialog.setSize("large")
const [loadError, setLoadError] = createSignal<unknown>()
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No skills available</text>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>Loading skills</text>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No skills found</text>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}
/>
+4 -4
View File
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
title: isDeleting
? `Press ${shortcuts.get("stash.delete")} again to confirm`
: getStashPreview(entry.prompt.text),
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
+13 -13
View File
@@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
const color = (status: string) => {
if (status === "connected") return themeV2.text.feedback.success.default
if (status === "failed") return themeV2.text.feedback.error.default
if (status === "needs_auth") return themeV2.text.feedback.warning.default
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
return themeV2.text.subdued
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
</text>
<For each={mcp()}>
@@ -40,9 +40,9 @@ export function DialogStatus() {
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
<b>{item.name}</b>{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
@@ -1,11 +1,11 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()
const options = Object.keys(theme.all())
const themes = useThemes()
const options = Object.keys(themes.all())
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
.map((value) => ({
title: value,
@@ -14,10 +14,10 @@ export function DialogThemeList() {
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<string>
const initial = theme.selected
const initial = themes.selected
onCleanup(() => {
if (!confirmed) theme.set(initial)
if (!confirmed) themes.set(initial)
})
return (
@@ -26,10 +26,10 @@ export function DialogThemeList() {
options={options}
current={initial}
onMove={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
}}
onSelect={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
confirmed = true
dialog.clear()
}}
@@ -38,12 +38,12 @@ export function DialogThemeList() {
}}
onFilter={(query) => {
if (query.length === 0) {
theme.set(initial)
themes.set(initial)
return
}
const first = ref.filtered[0]
if (first) theme.set(first.value)
if (first) themes.set(first.value)
}}
/>
)
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title ?? "File Changes Found"}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
{item}
</text>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
</box>
)}
</For>
+4 -4
View File
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {
const { themeV2 } = useTheme()
const theme = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(themeV2.background.default, fg, 0.25)
const shadow = tint(theme.background.default, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
@@ -52,8 +52,8 @@ export function Logo() {
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
</box>
)}
</For>
@@ -1,20 +1,20 @@
import { useTheme } from "../context/theme"
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={themeV2.text.feedback.warning.default}>
<text fg={theme.text.feedback.warning.default}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box
onMouseUp={props.onHome}
backgroundColor={themeV2.background.action.primary.hovered}
backgroundColor={theme.background.action.primary.hovered}
paddingLeft={1}
paddingRight={1}
>
<text fg={themeV2.text.action.primary.hovered}>go home</text>
<text fg={theme.text.action.primary.hovered}>go home</text>
</box>
</box>
)
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const { themeV2 } = useTheme().contextual("overlay")
const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={themeV2.border.default}
borderColor={theme.border.default}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{emptyMessage()}
</text>
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
</box>
}
>
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
flexShrink={0}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
+43 -42
View File
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
if (!props.disabled) input.cursorColor = themeV2.text.default
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
})
const usage = createMemo(() => {
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (leader()) return themeV2.border.default
if (store.mode === "shell") return themeV2.text.action.primary.selected
if (leader()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return themeV2.border.default
if (!agent) return theme.border.default
return local.agent.color(agent.id)
})
const agentLabel = createMemo(() => {
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1344,7 +1345,7 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => {
const agent = status() === "running" ? local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
const color = agent ? local.agent.color(agent.id) : theme.border.default
return {
frames: createFrames({
color,
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
return (
<>
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={themeV2.text.subdued}
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
placeholderColor={theme.text.subdued}
textColor={leader() ? theme.text.subdued : theme.text.default}
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = themeV2.text.default
input.cursorColor = theme.text.default
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
r.target?.focus()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1470,24 +1471,24 @@ export function Prompt(props: PromptProps) {
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
style={{
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
bold: true,
}}
>
@@ -1541,12 +1542,12 @@ export function Prompt(props: PromptProps) {
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
wrapMode="none"
truncate
flexShrink={1}
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={themeV2.hue.accent[500]}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
>
{file()}
</text>
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={themeV2.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
<text fg={theme.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={themeV2.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
<text fg={theme.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={themeV2.text.default} flexShrink={0}>
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
<text fg={theme.text.default} flexShrink={0}>
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
+5 -5
View File
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box
@@ -21,15 +21,15 @@ export function Reconnecting() {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
<Spinner color={theme.text.default}>Restarting service...</Spinner>
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
+2 -2
View File
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? themeV2.text.subdued
const color = () => props.color ?? theme.text.subdued
return (
<Show
when={config.animations ?? true}
@@ -1,9 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
let wait: NodeJS.Timeout | undefined
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
return (
<Show when={show()}>
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.text.subdued}>{text()}</Spinner>
</box>
</box>
</Show>
@@ -1,13 +1,13 @@
import { onCleanup } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useToast } from "../ui/toast"
export function ThemeErrorToast() {
const theme = useTheme()
const themes = useThemes()
const toast = useToast()
onCleanup(
theme.onError(({ name, error }) =>
themes.onError(({ name, error }) =>
toast.show({
variant: "error",
title: `Failed to load theme: ${name}`,
+4 -3
View File
@@ -17,7 +17,7 @@ import {
type ModelPreference,
type ModelPreferenceModel,
} from "../model-preference"
import { useTheme } from "./theme"
import { useTheme, useThemes } from "./theme"
import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
@@ -50,7 +50,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const { themeV2, mode } = useTheme()
const theme = useTheme()
const { mode } = useThemes()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -82,7 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
themeV2.categorical.map((scale) => scale[step]),
theme.categorical.map((scale) => scale[step]),
(first, second) => first.equals(second),
)
})
+24 -17
View File
@@ -96,13 +96,13 @@ type State = {
}
type ContextName = "elevated" | "overlay"
type ThemeService = {
themeV2: ComponentTheme
contextual(context: ContextName): ThemeService
type Themes = {
current: ComponentTheme
contextual(context: ContextName): ComponentTheme
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
syntax: Accessor<SyntaxStyle>
currentSyntax: Accessor<SyntaxStyle>
mode: Accessor<"dark" | "light">
modes: Accessor<readonly ("dark" | "light")[]>
supports(mode: "dark" | "light"): boolean
@@ -308,7 +308,7 @@ const themeContext = createSimpleContext({
const valuesV2 = () => selected().theme
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const themeV2 = createComponentTheme(valuesV2, mode)
const current = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
@@ -316,19 +316,19 @@ const themeContext = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextualServices[context]
return contextsV2[context]
}
const service: ThemeService = {
themeV2,
const service: Themes = {
current,
currentSyntax,
contextual,
get selected() {
return store.active
},
all: allThemes,
has: hasTheme,
syntax,
mode,
modes,
supports: (requested) => modes().includes(requested),
@@ -355,21 +355,28 @@ const themeContext = createSimpleContext({
return store.ready
},
}
const contextualServices = {
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
return {
current,
themes: service,
get ready() {
return service.ready
},
}
return service
},
})
export const useTheme = themeContext.use
export function useThemes() {
return themeContext.use().themes
}
export function useTheme() {
return themeContext.use().current
}
export const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
const theme = useTheme()
const themes = useThemes()
return (
<themeContext.context.Provider value={theme.contextual(props.context)}>
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
{props.children}
</themeContext.context.Provider>
)
@@ -11,7 +11,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -24,18 +24,16 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.themeV2.text.default}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span
style={{
fg:
count() > 0
? props.context.theme.themeV2.text.feedback.success.default
: props.context.theme.themeV2.text.subdued,
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
}}
>
{" "}
@@ -44,7 +42,7 @@ function Mcp(props: { context: Plugin.Context }) {
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/status</text>
</box>
</Show>
)
@@ -77,7 +75,7 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</box>
)
@@ -1,6 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { contextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
@@ -9,7 +8,7 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { context: Plugin.Context; sessionID: string }) {
const { themeV2 } = useTheme()
const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
@@ -20,20 +19,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
)
}
@@ -8,7 +8,7 @@ function View(props: { context: Plugin.Context }) {
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -1,14 +1,14 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTheme } from "../../context/theme"
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const { themeV2 } = useTheme()
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}
@@ -1,10 +1,9 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
const { themeV2 } = useTheme()
const theme = props.context.theme
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
@@ -19,12 +18,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
)
const dot = (status: string) => {
if (status === "connected") return themeV2.text.feedback.success.default
if (status === "failed") return themeV2.text.feedback.error.default
if (status === "disabled") return themeV2.text.subdued
if (status === "needs_auth") return themeV2.text.feedback.warning.default
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
return themeV2.text.subdued
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -32,12 +31,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
@@ -56,9 +55,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
>
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{item.name}{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>
@@ -1,15 +1,16 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { Locale } from "../../util/locale"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
import { useTheme } from "../../context/theme"
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeProps = {
readonly context: Plugin.Context
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
@@ -23,7 +24,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const { themeV2 } = useTheme()
const theme = props.context.theme
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
let scroll: ScrollBoxRenderable | undefined
@@ -38,10 +39,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width}>
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
@@ -52,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={themeV2.text.default}>No files</text>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
@@ -71,11 +72,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
@@ -85,12 +86,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text
fg={
highlighted()
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: selected()
? themeV2.text.formfield.selected
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? themeV2.text.subdued
: themeV2.text.default
? theme.text.subdued
: theme.text.default
}
wrapMode="none"
>
@@ -98,7 +99,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</text>
</box>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>
@@ -1,13 +1,13 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis }>()
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
@@ -17,10 +17,10 @@ function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
const [local, boxProps] = splitProps(props, ["axis", "children"])
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
@@ -28,24 +28,28 @@ export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis })
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
export function Panel(
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const [local, boxProps] = splitProps(props, ["border"])
const [local, boxProps] = splitProps(props, ["border", "context"])
const context = local.context ?? group?.context
if (!context) throw new Error("Panel context is missing")
const theme = context.theme
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: themeV2.border.default,
borderColor: theme.border.default,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
@@ -59,9 +63,10 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const color = () => props.color ?? themeV2.border.default
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
if (!group) throw new Error("PanelGroup is missing")
const theme = group.context.theme
const color = () => props.color ?? theme.border.default
const axis = () => props.axis ?? crossAxis(group.axis)
if (axis() === "y") {
return (
<Show
@@ -10,7 +10,6 @@ import {
type ScrollBoxRenderable,
} from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useTheme } from "../../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
@@ -83,8 +82,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const themeState = useTheme()
const themeV2 = themeState.themeV2
const theme = props.context.theme
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@@ -738,13 +736,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<PanelGroup axis="y" width="100%" height="100%">
<PanelGroup axis="y" context={props.context} width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<text fg={themeV2.text.default}>Diff </text>
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
<text fg={theme.text.default}>Diff </text>
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<Show when={!diff.loading && !diff.error}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
@@ -755,13 +753,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>Loading diff</text>
<text fg={theme.text.subdued}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.feedback.error.default}>
<text fg={theme.text.feedback.error.default}>
Could not load diff. Reopen the diff viewer to try again.
</text>
</box>
@@ -769,13 +767,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>No changes to show</text>
<text fg={theme.text.subdued}>No changes to show</text>
</box>
</Match>
<Match when={!diff.loading}>
<PanelGroup axis="x">
<PanelGroup axis="x" context={props.context}>
<Show when={showFileTree()}>
<DiffViewerFileTree
context={props.context}
files={files()}
loading={diff.loading}
error={diff.error}
@@ -812,56 +811,52 @@ function DiffViewer(props: { context: Plugin.Context }) {
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
borderColor={theme.border.default}
>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
{entry.file.file}
</text>
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.added}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
fallback={<text fg={theme.text.subdued}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
<box border={patchLeftBorder()} borderColor={theme.border.default}>
<diff
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
syntaxStyle={theme.syntaxStyle()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
fg={reviewed() ? theme.text.subdued : theme.text.default}
addedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
}
removedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
}
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
removedSignColor={
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
}
lineNumberFg={themeV2.diff.lineNumber.text}
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
addedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.added
? theme.background.surface.overlay
: theme.diff.lineNumber.background.added
}
removedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.removed
? theme.background.surface.overlay
: theme.diff.lineNumber.background.removed
}
/>
</box>
@@ -872,11 +867,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}}
</For>
<Show when={patchFillerHeight() > 0}>
<box
height={patchFillerHeight()}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
/>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
@@ -889,57 +880,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
</text>
)}
</Show>
<Show when={nextHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
</text>
)}
</Show>
<Show when={previousHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
</text>
)}
</Show>
<Show when={markReviewedShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
</text>
)}
</Show>
@@ -950,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = props.context.theme.contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{
@@ -1018,30 +1009,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Diff shortcuts
</text>
<text fg={themeV2.text.subdued}>esc</text>
<text fg={theme.text.subdued}>esc</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
<text fg={theme.text.subdued} width={5} wrapMode="none">
Key
</text>
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
<text fg={theme.text.subdued} width={22} wrapMode="none">
Action
</text>
<text fg={themeV2.text.subdued}>Description</text>
<text fg={theme.text.subdued}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={themeV2.text.default} width={5} wrapMode="none">
<text fg={theme.text.default} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={themeV2.text.default} width={22} wrapMode="none">
<text fg={theme.text.default} width={22} wrapMode="none">
{row.action}
</text>
<text fg={themeV2.text.subdued}>{row.description}</text>
<text fg={theme.text.subdued}>{row.description}</text>
</box>
)}
</For>
@@ -1,6 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -23,8 +22,8 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
props.context.keymap.layer(() => ({
commands: [
@@ -40,7 +39,7 @@ function Scrap(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box flexGrow={1} />
<box
height={1}
+41 -12
View File
@@ -1,4 +1,4 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
@@ -23,7 +23,7 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -80,6 +80,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const paths = useTuiPaths()
const location = useLocation()
const theme = useTheme()
const themes = useThemes()
const pluginTheme = createPluginTheme(theme, themes)
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
@@ -100,18 +102,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
let context: Context
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(render, () => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
})
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
@@ -215,7 +221,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
context = {
options: item.options ?? {},
get location() {
return location.current
@@ -225,7 +231,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api,
data,
attention,
theme,
theme: pluginTheme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@@ -245,7 +251,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, page)
setStore("registrations", item.plugin.id, "routes", page.name, {
...page,
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
})
let registered = true
const unregister = () => {
if (!registered) return
@@ -275,7 +284,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => render)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
))
let registered = true
const unregister = () => {
if (!registered) return
@@ -518,6 +529,24 @@ function isPlugin(value: unknown): value is Plugin.Definition {
)
}
type PluginTheme = ReturnType<typeof useTheme> & {
contextual(context: "elevated" | "overlay"): PluginTheme
syntaxStyle(): ReturnType<ReturnType<typeof useThemes>["currentSyntax"]>
}
export function createPluginTheme(theme: ReturnType<typeof useTheme>, themes: ReturnType<typeof useThemes>): PluginTheme {
return new Proxy(theme as PluginTheme, {
get(target, property, receiver) {
if (property === "contextual") {
return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes)
}
if (property === "syntaxStyle") return themes.currentSyntax
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver)
return Reflect.get(themes, property, themes)
},
})
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")
@@ -1,7 +1,7 @@
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../../../context/theme"
import { useThemes } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border"
import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab"
@@ -39,7 +39,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,
@@ -113,8 +113,8 @@ export function Composer(props: ComposerProps) {
<box
{...SplitBorder}
border={["left"]}
borderColor={themeV2.border.default}
backgroundColor={themeV2.background.default}
borderColor={theme.border.default}
backgroundColor={theme.background.default}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
@@ -125,7 +125,7 @@ export function Composer(props: ComposerProps) {
<Show
when={tabList().length > 1}
fallback={
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{tabList()[0]?.label ?? ""}
</text>
}
@@ -136,7 +136,7 @@ export function Composer(props: ComposerProps) {
const isActive = createMemo(() => store.active === t.id)
return (
<text
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
fg={isActive() ? theme.text.default : theme.text.subdued}
attributes={isActive() ? TextAttributes.BOLD : undefined}
>
{t.label}
@@ -146,7 +146,7 @@ export function Composer(props: ComposerProps) {
</For>
</box>
</Show>
<text fg={themeV2.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
@@ -156,19 +156,19 @@ export function Composer(props: ComposerProps) {
<For each={footerHints()}>
{(hint) => (
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>{hint.label}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
<span style={{ fg: theme.text.subdued }}>{hint.shortcut}</span>
</text>
)}
</For>
<Show when={tabList().length > 1}>
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>tabs</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>/</span>
<span style={{ fg: theme.text.subdued }}>/</span>
</text>
</Show>
</box>
@@ -12,7 +12,7 @@ export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const { themeV2 } = useTheme()
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
return (
<Show when={composer.active("shell")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No shell commands</text>}>
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No shell commands</text>}>
<For each={entries()}>
{(shell, index) => {
const active = createMemo(() => index() === store.selected)
@@ -108,12 +108,12 @@ export function ShellTab(props: { sessionID: string }) {
paddingLeft={1}
paddingRight={1}
backgroundColor={
active() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
>
<text
fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -21,7 +21,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const route = useRouteData("session")
const data = useData()
const client = useClient()
const { themeV2 } = useTheme()
const theme = useTheme()
const navigate = useRoute().navigate
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -206,7 +206,7 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No subagents</text>}>
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
@@ -221,10 +221,10 @@ export function SubagentsTab(props: { sessionID: string }) {
paddingRight={1}
backgroundColor={
active()
? themeV2.background.action.primary.focused
? theme.background.action.primary.focused
: entry.current
? themeV2.background.action.primary.selected
: themeV2.background.action.primary.default
? theme.background.action.primary.selected
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
@@ -236,10 +236,10 @@ export function SubagentsTab(props: { sessionID: string }) {
<text
fg={
active()
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: entry.current
? themeV2.text.action.primary.selected
: themeV2.text.action.primary.default
? theme.text.action.primary.selected
: theme.text.action.primary.default
}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
@@ -248,7 +248,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text>
</box>
<Show when={status()}>
<text fg={active() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
<text fg={active() ? theme.text.action.primary.focused : theme.text.subdued} wrapMode="none">
{status()}
</text>
</Show>
+10 -10
View File
@@ -8,7 +8,7 @@ import { useRoute } from "../../context/route"
import { usePermission } from "../../context/permission"
export function Footer() {
const { themeV2 } = useTheme()
const theme = useTheme()
const data = useData()
const route = useRoute()
const permission = usePermission()
@@ -54,35 +54,35 @@ export function Footer() {
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={themeV2.text.subdued}>{directory()}</text>
<text fg={theme.text.subdued}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={themeV2.text.default}>
Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
<text fg={theme.text.default}>
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<text fg={themeV2.text.feedback.warning.default}>
<span style={{ fg: themeV2.text.feedback.warning.default }}></span> {permissions().length} Permission
<text fg={theme.text.feedback.warning.default}>
<span style={{ fg: theme.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: themeV2.text.feedback.success.default }}> </span>
<span style={{ fg: theme.text.feedback.success.default }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={themeV2.text.subdued}>/status</text>
<text fg={theme.text.subdued}>/status</text>
</Match>
</Switch>
</box>
+71 -77
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
@@ -44,7 +44,9 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
@@ -624,27 +626,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={themeV2.text.subdued}>{props.form.title}</text>
<text fg={theme.text.subdued}>{props.form.title}</text>
</box>
<Show when={message()}>
<box paddingLeft={1}>
<text fg={themeV2.text.default}>{message()}</text>
<text fg={theme.text.default}>{message()}</text>
</box>
</Show>
<Show when={!single() && !tabbed()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
</text>
<Show when={fields().length > 0}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
· {answered()}/{fields().length} completed
</text>
</Show>
@@ -661,10 +663,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
paddingRight={2}
backgroundColor={
isTab()
? themeV2.background.formfield.selected
? theme.background.formfield.selected
: tabHover() === index()
? themeV2.background.formfield.focused
: themeV2.background.default
? theme.background.formfield.focused
: theme.background.default
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
@@ -676,12 +678,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<text
fg={
isTab()
? themeV2.text.formfield.selected
? theme.text.formfield.selected
: tabHover() === index()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: isAnswered()
? themeV2.text.default
: themeV2.text.subdued
? theme.text.default
: theme.text.subdued
}
>
{truncate(formLabel(item), 24)}
@@ -693,10 +695,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
backgroundColor={
confirm()
? themeV2.background.formfield.selected
? theme.background.formfield.selected
: tabHover() === "confirm"
? themeV2.background.formfield.focused
: themeV2.background.default
? theme.background.formfield.focused
: theme.background.default
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
@@ -705,7 +707,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
selectTabFromMouse()
}}
>
<text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
<text fg={confirm() ? theme.text.formfield.selected : theme.text.formfield.default}>Confirm</text>
</box>
</box>
</Show>
@@ -714,13 +716,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{(external) => (
<box paddingLeft={1} gap={1}>
<Show when={external().title}>
<text fg={themeV2.text.default}>{external().title}</text>
<text fg={theme.text.default}>{external().title}</text>
</Show>
<Show when={external().description}>
<text fg={themeV2.text.subdued}>{external().description}</text>
<text fg={theme.text.subdued}>{external().description}</text>
</Show>
<text
fg={themeV2.background.action.primary.default}
fg={theme.background.action.primary.default}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
openExternal()
@@ -729,9 +731,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{external().url}
</text>
<text
fg={
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
}
fg={store.answers[external().key] === true ? theme.text.feedback.success.default : theme.text.subdued}
>
{store.answers[external().key] === true
? "✓ Acknowledged"
@@ -746,7 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={!confirm() && answerField()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
<text fg={theme.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
</box>
<Show when={textual() ? answerField()!.key : undefined} keyed>
<box paddingLeft={1}>
@@ -763,12 +763,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
}
placeholder={placeholder()}
placeholderColor={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
minHeight={1}
maxHeight={6}
textColor={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
</box>
</Show>
@@ -793,39 +793,35 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={
active() ? themeV2.background.formfield.focused : themeV2.background.default
}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={
active() ? themeV2.background.formfield.focused : themeV2.background.default
}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
>
<text
fg={
active()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: picked()
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
</text>
</box>
<Show when={!multi()}>
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={row.description}>
<box paddingLeft={3}>
<text fg={themeV2.text.subdued}>{row.description}</text>
<text fg={theme.text.subdued}>{row.description}</text>
</box>
</Show>
</box>
@@ -843,30 +839,28 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
{`${rows().length + 1}.`}
</text>
</box>
<box
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
>
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
<text
fg={
other()
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: customPicked()
? themeV2.text.feedback.success.default
: themeV2.text.default
? theme.text.feedback.success.default
: theme.text.default
}
>
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
</text>
</box>
<Show when={!multi()}>
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={store.editing}>
@@ -882,18 +876,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
minHeight={1}
maxHeight={6}
textColor={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={themeV2.text.subdued}>{input()}</text>
<text fg={theme.text.subdued}>{input()}</text>
</box>
</Show>
</box>
@@ -906,7 +900,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<Show when={tabbed()}>
<box paddingLeft={1}>
<text fg={themeV2.text.default}>Review</text>
<text fg={theme.text.default}>Review</text>
</box>
</Show>
<scrollbox
@@ -921,12 +915,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? themeV2.text.feedback.success.default
: themeV2.text.feedback.error.default,
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
@@ -942,15 +936,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg:
invalid() || missing()
? themeV2.text.feedback.error.default
? theme.text.feedback.error.default
: answered()
? themeV2.text.default
: themeV2.text.subdued,
? theme.text.default
: theme.text.subdued,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
@@ -974,41 +968,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={themeV2.text.default}>
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
</text>
</Show>
<Show when={!confirm() && !textual() && !externalField()}>
<text fg={themeV2.text.default}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<Show when={confirm() && fields().length > 0}>
<text fg={themeV2.text.default}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
</text>
</Show>
<text
fg={themeV2.text.default}
fg={theme.text.default}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (externalField()) acknowledgeExternal()
}}
>
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
<text fg={themeV2.text.default} onMouseUp={cancel}>
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
<text fg={theme.text.default} onMouseUp={cancel}>
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
</text>
</box>
<Show when={store.error}>
<text fg={themeV2.text.feedback.error.default}>{store.error}</text>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</Show>
</box>
</box>
+176 -166
View File
@@ -22,7 +22,7 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { ThemeContextProvider, useTheme } from "../../context/theme"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -133,7 +133,7 @@ export function Session() {
const paths = useTuiPaths()
const configState = useConfig()
const config = configState.data
const { themeV2 } = useTheme()
const theme = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
@@ -925,8 +925,8 @@ export function Session() {
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: themeV2.raise(themeV2.background.surface.offset),
foregroundColor: themeV2.border.default,
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
@@ -1101,7 +1101,7 @@ function TurnTokenUsage(props: {
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const config = useConfig()
const { themeV2 } = useTheme()
const theme = useTheme()
const verbose = () => config.data.debug?.turn_tokens === "verbose"
const steps = createMemo(() => {
let previousCache = props.previousCache
@@ -1141,15 +1141,15 @@ function TurnTokenUsage(props: {
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
<text width={INLINE_TOOL_ICON_WIDTH} fg={theme.text.subdued}>
</text>
<text fg={themeV2.text.subdued} attributes={TextAttributes.BOLD}>
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
Tokens
</text>
</box>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={themeV2.text.subdued} attributes={TextAttributes.ITALIC}>
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
{"Step".padEnd(columns().step + 2)}
{"New".padStart(columns().newTokens)}
{" "}
@@ -1161,7 +1161,7 @@ function TurnTokenUsage(props: {
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={verbose() && item.finish === "tool-call" ? undefined : themeV2.text.subdued}>
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
@@ -1173,7 +1173,7 @@ function TurnTokenUsage(props: {
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={themeV2.text.feedback.warning.default}>
<text fg={theme.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
@@ -1186,7 +1186,7 @@ function TurnTokenUsage(props: {
}
function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const nameWidth = () => Math.max(0, ...props.tools.map((tool) => tool.name.length)) + 2
return (
<Show when={props.tools.length > 0}>
@@ -1197,13 +1197,13 @@ function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
<text
width={nameWidth()}
flexShrink={0}
fg={themeV2.text.subdued}
fg={theme.text.subdued}
attributes={TextAttributes.BOLD}
>
{tool.name}
</text>
<text
fg={themeV2.text.subdued}
fg={theme.text.subdued}
attributes={TextAttributes.DIM}
wrapMode="word"
flexGrow={1}
@@ -1234,7 +1234,7 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
}
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const visible = createMemo(() => {
const current = props.messages.findLast(
@@ -1252,8 +1252,8 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
<Show when={visible() && shortcut()}>
{(value) => (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={themeV2.text.subdued}>
Press <span style={{ fg: themeV2.text.default }}>{value()}</span> to move running work to the background
<text fg={theme.text.subdued}>
Press <span style={{ fg: theme.text.default }}>{value()}</span> to move running work to the background
</text>
</box>
)}
@@ -1323,7 +1323,8 @@ function SessionReasoningGroupView(props: {
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1363,13 +1364,13 @@ function SessionReasoningGroupView(props: {
icon={expanded() ? "-" : "+"}
color={
!props.completed
? themeV2.text.default
? theme.text.default
: hover() || expanded()
? themeV2.text.feedback.warning.default
? theme.text.feedback.warning.default
: RGBA.fromValues(
themeV2.text.feedback.warning.default.r,
themeV2.text.feedback.warning.default.g,
themeV2.text.feedback.warning.default.b,
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
}
@@ -1412,7 +1413,7 @@ function SessionReasoningGroupView(props: {
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.raise(themeV2.background.surface.offset)}
borderColor={theme.raise(theme.background.surface.offset)}
paddingLeft={1}
>
<code
@@ -1422,7 +1423,7 @@ function SessionReasoningGroupView(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={themeV2.text.subdued}
fg={theme.text.subdued}
/>
</box>
</box>
@@ -1444,7 +1445,7 @@ function SessionGroupView(props: {
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const { themeV2 } = useTheme()
const theme = useTheme()
const ctx = use()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
@@ -1480,7 +1481,7 @@ function SessionGroupView(props: {
<Show when={grouped().length > 0}>
<InlineToolRow
icon={props.completed ? "→" : "✱"}
color={hover() ? themeV2.text.default : themeV2.text.subdued}
color={hover() ? theme.text.default : theme.text.subdued}
complete={props.completed}
pending={label()}
spinner={!props.completed}
@@ -1506,7 +1507,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const local = useLocal()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -1526,25 +1527,25 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.text.feedback.error.default}
borderColor={theme.text.feedback.error.default}
>
<text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
<span style={{ fg: themeV2.text.subdued }}> · interrupted</span>
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
</Show>
</text>
</box>
@@ -1554,7 +1555,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const { themeV2 } = useTheme()
const theme = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "model-switched")
@@ -1563,14 +1564,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
}
return (
<box paddingLeft={3}>
<text fg={themeV2.text.subdued}>{text()}</text>
<text fg={theme.text.subdued}>{text()}</text>
</box>
)
}
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const { themeV2 } = useTheme()
const theme = useTheme()
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
const source = () => stringValue(metadata()?.source)
const completion = () => source() === "subagent" || source() === "shell"
@@ -1590,15 +1591,15 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
const color = () => {
if (state() === "error") return themeV2.text.feedback.error.default
if (state() === "cancelled") return themeV2.text.feedback.warning.default
return themeV2.text.feedback.info.default
if (state() === "error") return theme.text.feedback.error.default
if (state() === "cancelled") return theme.text.feedback.warning.default
return theme.text.feedback.info.default
}
return (
<Show
when={completion()}
fallback={
<InlineToolRow icon="◈" color={themeV2.text.subdued} pending="Notice" complete={true}>
<InlineToolRow icon="◈" color={theme.text.subdued} pending="Notice" complete={true}>
{text()}
</InlineToolRow>
}
@@ -1606,7 +1607,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: themeV2.text.subdued }}>{suffix()}</span>
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
</text>
</box>
</Show>
@@ -1614,9 +1615,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
}
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<InlineToolRow icon="→" color={themeV2.text.subdued} pending="Skill" complete={true}>
<InlineToolRow icon="→" color={theme.text.subdued} pending="Skill" complete={true}>
Skill {props.message.name}
</InlineToolRow>
)
@@ -1624,14 +1625,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const status = () => props.message.status
const cancelled = () => props.message.status === "failed" && props.message.error.type === "aborted"
const text = () =>
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
const content = createMemo(() => text().trim())
const color = () =>
status() === "failed" && !cancelled() ? themeV2.text.feedback.error.default : themeV2.text.subdued
status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1663,8 +1665,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
content={content()}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={themeV2.markdown.text}
bg={themeV2.background.default}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
@@ -1673,15 +1675,15 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
}
function CompactionQueued() {
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<box flexDirection="row" alignItems="center">
<box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
<box border={["top"]} borderColor={theme.border.default} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<text fg={themeV2.text.subdued}></text>
<text fg={themeV2.text.subdued}>Compaction queued</text>
<text fg={theme.text.subdued}></text>
<text fg={theme.text.subdued}>Compaction queued</text>
</box>
<box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
<box border={["top"]} borderColor={theme.border.default} flexGrow={1} />
</box>
)
}
@@ -1702,7 +1704,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@@ -1727,15 +1729,15 @@ function RevertMessage(props: {
marginTop={1}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.background.default}
borderColor={theme.background.default}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{props.count} message{props.count === 1 ? "" : "s"} reverted
</text>
<Show when={props.files.length > 0}>
@@ -1743,7 +1745,7 @@ function RevertMessage(props: {
<For each={props.files}>
{(file) => (
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={themeV2.text.subdued}>{statusLabel(file.status)}</text>
<text fg={theme.text.subdued}>{statusLabel(file.status)}</text>
<FilePath
value={file.file}
maxWidth={Math.max(
@@ -1753,21 +1755,21 @@ function RevertMessage(props: {
(file.additions > 0 ? stringWidth(`+${file.additions}`) + 1 : 0) -
(file.deletions > 0 ? stringWidth(`-${file.deletions}`) + 1 : 0),
)}
fg={themeV2.text.default}
fg={theme.text.default}
/>
<Show when={file.additions > 0}>
<text fg={themeV2.diff.text.added}>+{file.additions}</text>
<text fg={theme.diff.text.added}>+{file.additions}</text>
</Show>
<Show when={file.deletions > 0}>
<text fg={themeV2.diff.text.removed}>-{file.deletions}</text>
<text fg={theme.diff.text.removed}>-{file.deletions}</text>
</Show>
</box>
)}
</For>
</box>
</Show>
<text fg={themeV2.text.subdued}>
<span style={{ fg: themeV2.text.default }}>{redoKey()}</span> or /redo to restore
<text fg={theme.text.subdued}>
<span style={{ fg: theme.text.default }}>{redoKey()}</span> or /redo to restore
</text>
</box>
</box>
@@ -1775,7 +1777,7 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
return (
@@ -1785,13 +1787,13 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.background.default}
borderColor={theme.background.default}
>
<text fg={themeV2.text.default}>$ {props.message.command}</text>
<text fg={theme.text.default}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={themeV2.text.subdued}>{output()}</text>
<text fg={theme.text.subdued}>{output()}</text>
</Show>
</box>
)
@@ -1802,7 +1804,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(
@@ -1816,7 +1820,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
<Show when={props.message.text.trim() || files().length}>
<box
border={["left"]}
borderColor={queued() ? themeV2.border.default : color()}
borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1839,27 +1843,27 @@ function UserMessage(props: { message: SessionMessageUser }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexShrink={0}
>
<text fg={themeV2.text.default}>{props.message.text}</text>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const label = file.mime === "application/x-directory" ? "dir" : "file"
return (
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<span
style={{
bg: themeV2.hue.accent[mode() === "light" ? 700 : 200],
fg: themeV2.background.default,
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
bold: true,
}}
>
{` ${label} `}
</span>
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span>
@@ -1878,7 +1882,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -1964,11 +1968,11 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.text.feedback.error.default}
borderColor={theme.text.feedback.error.default}
>
<text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
@@ -1976,12 +1980,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
@@ -1992,12 +1996,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3} marginTop={1}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
</text>
</box>
@@ -2007,7 +2011,7 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const pathFormatter = usePathFormatter()
const label = (part: SessionMessageAssistantTool) => {
const input = typeof part.state.input === "string" ? {} : part.state.input
@@ -2020,7 +2024,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={themeV2.text.subdued}
color={theme.text.subdued}
complete={!props.active}
pending="Exploring"
spinner={props.active}
@@ -2030,7 +2034,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={part.state.status === "error" ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
@@ -2047,7 +2051,8 @@ function ReasoningPart(props: {
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -2075,7 +2080,7 @@ function ReasoningPart(props: {
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.raise(themeV2.background.default)}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
@@ -2093,7 +2098,7 @@ function ReasoningPart(props: {
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.raise(themeV2.background.default)}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
@@ -2103,7 +2108,7 @@ function ReasoningPart(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={themeV2.text.subdued}
fg={theme.text.subdued}
/>
</box>
</box>
@@ -2125,16 +2130,16 @@ function ReasoningHeader(props: {
title: string | null
duration?: string
}) {
const { themeV2 } = useTheme()
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
themeV2.text.feedback.warning.default.r,
themeV2.text.feedback.warning.default.g,
themeV2.text.feedback.warning.default.b,
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: themeV2.text.feedback.warning.default
: theme.text.feedback.warning.default
return (
<Switch>
@@ -2169,7 +2174,8 @@ function ReasoningHeader(props: {
function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
@@ -2180,8 +2186,8 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={themeV2.markdown.text}
bg={themeV2.background.default}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
@@ -2270,7 +2276,8 @@ type ToolProps = {
part: SessionMessageAssistantTool
}
function GenericTool(props: ToolProps) {
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const output = createMemo(() => props.output?.trim() ?? "")
const args = createMemo(() => JSON.stringify(props.input, null, 2))
const [expanded, setExpanded] = createSignal(false)
@@ -2288,7 +2295,7 @@ function GenericTool(props: ToolProps) {
<Show when={Object.keys(props.input).length > 0}>
<box gap={1}>
<text>
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}> Input </span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Input </span>
</text>
<box paddingLeft={1}>
<code
@@ -2297,7 +2304,7 @@ function GenericTool(props: ToolProps) {
syntaxStyle={syntax()}
conceal={false}
drawUnstyledText={false}
fg={themeV2.text.default}
fg={theme.text.default}
/>
</box>
</box>
@@ -2306,13 +2313,13 @@ function GenericTool(props: ToolProps) {
{(value) => (
<box gap={1}>
<text>
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
Output{" "}
</span>
</text>
<box paddingLeft={1}>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{value()}
</text>
</box>
@@ -2349,7 +2356,7 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
onClick?: () => void
}) {
const { themeV2 } = useTheme()
const theme = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const [errorExpanded, setErrorExpanded] = createSignal(false)
@@ -2369,10 +2376,10 @@ function InlineTool(props: {
const clickable = createMemo(() => Boolean(props.onClick || failed()))
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return themeV2.text.feedback.warning.default
if (failed()) return themeV2.text.feedback.error.default
if (hover() && props.onClick) return themeV2.text.default
return themeV2.text.subdued
if (permission()) return theme.text.feedback.warning.default
if (failed()) return theme.text.feedback.error.default
if (hover() && props.onClick) return theme.text.default
return theme.text.subdued
})
return (
@@ -2380,7 +2387,7 @@ function InlineTool(props: {
icon={props.icon}
iconColor={props.iconColor}
color={fg()}
errorColor={themeV2.text.feedback.error.default}
errorColor={theme.text.feedback.error.default}
failed={failed()}
denied={Boolean(denied())}
error={error()}
@@ -2502,9 +2509,9 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El
}
function StatusBadge(props: { children: string }) {
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<text flexShrink={0} bg={themeV2.raise(themeV2.background.default)} fg={themeV2.text.subdued}>
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
{" "}
{props.children}{" "}
</text>
@@ -2524,13 +2531,13 @@ function BlockTool(props: BlockToolProps) {
const parentTheme = useTheme()
return (
<ThemeContextProvider context="elevated">
<BlockToolContent {...props} borderColor={parentTheme.themeV2.background.default} />
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
</ThemeContextProvider>
)
}
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const ctx = use()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
@@ -2543,7 +2550,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={props.borderColor}
onMouseOver={() => props.onClick && setHover(true)}
@@ -2561,12 +2568,12 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{title()}
</text>
}
>
<Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{title().replace(/^# /, "")}
</Spinner>
</Show>
@@ -2579,26 +2586,26 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
<text flexShrink={0} fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{path().label}
</text>
}
>
<Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{path().label.replace(/^# /, "")}
</Spinner>
</Show>
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}
fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}
/>
</box>
)}
</Show>
{props.children}
<Show when={error()}>
<text fg={themeV2.text.feedback.error.default}>{error()}</text>
<text fg={theme.text.feedback.error.default}>{error()}</text>
</Show>
</box>
)
@@ -2607,13 +2614,13 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const SHELL_DISPLAY_LIMIT = 1024 * 1024
function Shell(props: ToolProps) {
const { themeV2 } = useTheme()
const theme = useTheme()
const ctx = use()
const client = useClient()
const data = useData()
const pathFormatter = usePathFormatter()
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
@@ -2723,7 +2730,7 @@ function Shell(props: ToolProps) {
isRunning() || props.part.state.status === "streaming" ? (
<Spinner color={color()}>Writing command...</Spinner>
) : (
<text fg={themeV2.text.subdued}>Writing command...</text>
<text fg={theme.text.subdued}>Writing command...</text>
)
}
>
@@ -2731,14 +2738,14 @@ function Shell(props: ToolProps) {
when={isRunning()}
fallback={
<text>
<span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</text>
}
>
<Spinner color={color()}>
<span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</Spinner>
</Show>
</Show>
@@ -2751,7 +2758,8 @@ function Shell(props: ToolProps) {
}
function Write(props: ToolProps) {
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const pathFormatter = usePathFormatter()
const code = createMemo(() => {
return stringValue(props.input.content) ?? ""
@@ -2764,10 +2772,10 @@ function Write(props: ToolProps) {
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
>
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
<line_number fg={theme.text.subdued} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={themeV2.text.default}
fg={theme.text.default}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
content={code()}
@@ -2799,7 +2807,7 @@ function Glob(props: ToolProps) {
}
function Read(props: ToolProps) {
const { themeV2 } = useTheme()
const theme = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
@@ -2822,7 +2830,7 @@ function Read(props: ToolProps) {
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={themeV2.text.subdued}>
<text paddingLeft={3} fg={theme.text.subdued}>
Loaded {pathFormatter.format(filepath)}
</text>
</box>
@@ -2920,7 +2928,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
const { themeV2 } = useTheme()
const theme = useTheme()
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
@@ -2940,7 +2948,7 @@ function Execute(props: ToolProps) {
<>
<InlineTool
icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
color={hasRuntimeError() ? themeV2.text.feedback.error.default : undefined}
color={hasRuntimeError() ? theme.text.feedback.error.default : undefined}
spinner={isLoading()}
pending="execute"
complete={true}
@@ -2952,7 +2960,7 @@ function Execute(props: ToolProps) {
<box paddingLeft={3}>
<For each={outputPreview().split("\n")}>
{(line, index) => (
<text paddingLeft={3} fg={themeV2.text.feedback.error.default}>
<text paddingLeft={3} fg={theme.text.feedback.error.default}>
{index() === 0 ? "↳ " : " "}
{line}
</text>
@@ -2966,7 +2974,8 @@ function Execute(props: ToolProps) {
function Edit(props: ToolProps) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
@@ -2994,16 +3003,16 @@ function Edit(props: ToolProps) {
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={themeV2.text.default}
addedBg={themeV2.diff.background.added}
removedBg={themeV2.diff.background.removed}
contextBg={themeV2.diff.background.context}
addedSignColor={themeV2.diff.highlight.added}
removedSignColor={themeV2.diff.highlight.removed}
lineNumberFg={themeV2.diff.lineNumber.text}
lineNumberBg={themeV2.diff.background.context}
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
fg={theme.text.default}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</box>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
@@ -3028,7 +3037,8 @@ function Edit(props: ToolProps) {
function ApplyPatch(props: ToolProps) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const pathFormatter = usePathFormatter()
const files = createMemo(() => parseApplyPatchFiles(props.metadata.files))
const targets = createMemo(() => {
@@ -3068,7 +3078,7 @@ function ApplyPatch(props: ToolProps) {
<Show
when={file.type !== "delete"}
fallback={
<text fg={themeV2.diff.text.removed}>
<text fg={theme.diff.text.removed}>
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
</text>
}
@@ -3082,16 +3092,16 @@ function ApplyPatch(props: ToolProps) {
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={themeV2.text.default}
addedBg={themeV2.diff.background.added}
removedBg={themeV2.diff.background.removed}
contextBg={themeV2.diff.background.context}
addedSignColor={themeV2.diff.highlight.added}
removedSignColor={themeV2.diff.highlight.removed}
lineNumberFg={themeV2.diff.lineNumber.text}
lineNumberBg={themeV2.diff.background.context}
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
fg={theme.text.default}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</box>
</Show>
@@ -3114,7 +3124,7 @@ function ApplyPatch(props: ToolProps) {
<FilePath
value={file.resource}
maxWidth={Math.max(2, ctx.width - 3)}
fg={file.type === "delete" ? themeV2.diff.text.removed : themeV2.text.subdued}
fg={file.type === "delete" ? theme.diff.text.removed : theme.text.subdued}
/>
</BlockTool>
)}
@@ -3143,7 +3153,7 @@ function ApplyPatch(props: ToolProps) {
}
function Question(props: ToolProps) {
const { themeV2 } = useTheme()
const theme = useTheme()
const questions = createMemo(() => parseQuestions(props.input.questions))
const answers = createMemo(() => parseQuestionAnswers(props.metadata.answers))
const count = createMemo(() => questions().length)
@@ -3161,8 +3171,8 @@ function Question(props: ToolProps) {
<For each={questions()}>
{(q, i) => (
<box flexDirection="column">
<text fg={themeV2.text.subdued}>{q.question}</text>
<text fg={themeV2.text.default}>{format(answers()?.[i()])}</text>
<text fg={theme.text.subdued}>{q.question}</text>
<text fg={theme.text.default}>{format(answers()?.[i()])}</text>
</box>
)}
</For>
@@ -3188,7 +3198,7 @@ function Skill(props: ToolProps) {
}
function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
const { themeV2 } = useTheme()
const theme = useTheme()
const terminalEnvironment = useTuiTerminalEnvironment()
const errors = createMemo(() => {
const normalized = normalizePath(
@@ -3203,7 +3213,7 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
<box>
<For each={errors()}>
{(diagnostic) => (
<text fg={themeV2.text.feedback.error.default}>
<text fg={theme.text.feedback.error.default}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
</text>
)}
+59 -63
View File
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/client"
import { useClient } from "../../context/client"
import { SplitBorder } from "../../ui/border"
@@ -18,9 +18,9 @@ import { SimulationSemantics } from "../../simulation/semantics"
type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
const themeState = useTheme()
const themeV2 = themeState.themeV2
const syntax = themeState.syntax
const theme = useTheme()
const themes = useThemes()
const syntax = themes.currentSyntax
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -45,8 +45,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -58,16 +58,16 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={themeV2.text.default}
addedBg={themeV2.diff.background.added}
removedBg={themeV2.diff.background.removed}
contextBg={themeV2.diff.background.context}
addedSignColor={themeV2.diff.highlight.added}
removedSignColor={themeV2.diff.highlight.removed}
lineNumberFg={themeV2.diff.lineNumber.text}
lineNumberBg={themeV2.diff.background.context}
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
fg={theme.text.default}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</scrollbox>
</Show>
@@ -76,7 +76,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
when={props.patch}
fallback={
<box paddingLeft={1}>
<text fg={themeV2.text.subdued}>No diff provided</text>
<text fg={theme.text.subdued}>No diff provided</text>
</box>
}
>
@@ -86,8 +86,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -97,7 +97,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
streaming={true}
syntaxStyle={syntax()}
content={patch()}
fg={themeV2.text.subdued}
fg={theme.text.subdued}
/>
</scrollbox>
)}
@@ -128,7 +128,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
return { input: undefined, metadata: undefined }
})
const { themeV2 } = useTheme()
const theme = useTheme()
return (
<Switch>
@@ -140,7 +140,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
body={
<box paddingLeft={1} gap={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line, index) => <text fg={index() === 0 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
</For>
</box>
}
@@ -192,9 +192,9 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
) : props.request.action === "external_directory" ? (
<Show when={current.lines.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={themeV2.text.subdued}>Patterns</text>
<text fg={theme.text.subdued}>Patterns</text>
<box>
<For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
<For each={current.lines}>{(line) => <text fg={theme.text.default}>{line}</text>}</For>
</box>
</box>
</Show>
@@ -207,8 +207,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
props.request.action === "shell" ||
props.request.action === "subagent" ||
props.request.action === "task"
? themeV2.text.default
: themeV2.text.subdued
? theme.text.default
: theme.text.subdued
}
>
{line}
@@ -221,15 +221,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const header = () => (
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
<text fg={themeV2.text.default}>Permission required</text>
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
<text fg={theme.text.default}>Permission required</text>
</box>
<Show when={props.request.action !== "shell" && current.title}>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={themeV2.text.subdued} flexShrink={0}>
<text fg={theme.text.subdued} flexShrink={0}>
{current.icon}
</text>
<text fg={themeV2.text.default}>{current.title}</text>
<text fg={theme.text.default}>{current.title}</text>
</box>
</Show>
</box>
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
Keymap.createLayer(() => ({
@@ -329,18 +329,18 @@ function RejectPrompt(props: {
role: "dialog",
label: `Reject permission: ${props.action}`,
}))}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.text.feedback.error.default}
borderColor={theme.text.feedback.error.default}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={themeV2.text.feedback.error.default}>{"△"}</text>
<text fg={themeV2.text.default}>Reject permission</text>
<text fg={theme.text.feedback.error.default}>{"△"}</text>
<text fg={theme.text.default}>Reject permission</text>
</box>
<box paddingLeft={1}>
<text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
<text fg={theme.text.subdued}>Tell OpenCode what to do differently</text>
</box>
</box>
<box
@@ -350,7 +350,7 @@ function RejectPrompt(props: {
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={themeV2.raise(themeV2.background.default)}
backgroundColor={theme.raise(theme.background.default)}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
@@ -369,9 +369,9 @@ function RejectPrompt(props: {
val.traits = { status: "REJECT" }
}}
focused
textColor={themeV2.text.default}
focusedTextColor={themeV2.text.default}
cursorColor={themeV2.text.default}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
/>
<box
id="session.permission.reject.actions"
@@ -394,8 +394,8 @@ function RejectPrompt(props: {
}))}
onMouseUp={() => props.onConfirm(input.plainText)}
>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
<box
@@ -408,8 +408,8 @@ function RejectPrompt(props: {
}))}
onMouseUp={props.onCancel}
>
<text fg={themeV2.text.default}>
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
<text fg={theme.text.default}>
esc <span style={{ fg: theme.text.subdued }}>cancel</span>
</text>
</box>
</box>
@@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
@@ -534,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
label: props.semanticLabel ?? props.title,
expanded: store.expanded,
}))}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={themeV2.background.action.primary.focused}
borderColor={theme.background.action.primary.focused}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
@@ -554,8 +554,8 @@ function Prompt<const T extends Record<string, string>>(props: {
when={props.header}
fallback={
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
<text fg={themeV2.text.default}>{props.title}</text>
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
<text fg={theme.text.default}>{props.title}</text>
</box>
}
>
@@ -573,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={themeV2.raise(themeV2.background.default)}
backgroundColor={theme.raise(theme.background.default)}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
@@ -604,8 +604,8 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingRight={1}
backgroundColor={
option === store.selected
? themeV2.background.action.primary.focused
: themeV2.background.action.primary.default
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
@@ -614,11 +614,7 @@ function Prompt<const T extends Record<string, string>>(props: {
}}
>
<text
fg={
option === store.selected
? themeV2.text.action.primary.focused
: themeV2.text.action.primary.default
}
fg={option === store.selected ? theme.text.action.primary.focused : theme.text.action.primary.default}
>
{props.options[option]}
</text>
@@ -628,15 +624,15 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={themeV2.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
<text fg={theme.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
</text>
</Show>
<text fg={themeV2.text.default}>
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
</box>
+7 -7
View File
@@ -1,6 +1,6 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { useConfig } from "../../config"
import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
@@ -10,7 +10,7 @@ import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const data = useData()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -18,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
return (
<Show when={session()}>
<box
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
width={42}
height="100%"
paddingTop={1}
@@ -32,8 +32,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: themeV2.background.default,
foregroundColor: themeV2.scrollbar.default,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
@@ -45,11 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
title={session()!.title}
>
<box paddingRight={1}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
@@ -1,7 +1,7 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
@@ -42,7 +42,7 @@ export function SubagentFooter() {
}
})
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
@@ -57,18 +57,18 @@ export function SubagentFooter() {
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={themeV2.border.default}
borderColor={theme.border.default}
flexShrink={0}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>{subagentInfo()}</b>
</text>
<Show when={usage()}>
{(item) => (
<text fg={themeV2.text.subdued} wrapMode="none">
<text fg={theme.text.subdued} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
@@ -80,35 +80,31 @@ export function SubagentFooter() {
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={
hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
}
>
<text fg={themeV2.text.default}>
Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
<text fg={theme.text.default}>
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
}
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={themeV2.text.default}>
Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
<text fg={theme.text.default}>
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
}
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={themeV2.text.default}>
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
<text fg={theme.text.default}>
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
</text>
</box>
</box>
+7 -7
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export type DialogAlertProps = {
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
Keymap.createLayer(() => ({
mode: "modal",
@@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => {
props.onConfirm?.()
dialog.clear()
}}
>
<text fg={themeV2.text.action.primary.focused}>ok</text>
<text fg={theme.text.action.primary.focused}>ok</text>
</box>
</box>
</box>
+7 -7
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -60,15 +60,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"] as const}>
@@ -76,14 +76,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={key === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
<text fg={key === store.active ? theme.text.action.primary.focused : theme.text.subdued}>
{Locale.titlecase(props.label?.[key] ?? key)}
</text>
</box>
+24 -26
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For, Show } from "solid-js"
@@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
@@ -73,15 +73,15 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Export session
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={themeV2.text.default}>Export as:</text>
<text fg={theme.text.default}>Export as:</text>
<box flexDirection="row" gap={1}>
<For each={["markdown", "json"] as const}>
{(format) => (
@@ -90,20 +90,20 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
paddingRight={1}
backgroundColor={
store.active === format
? themeV2.background.formfield.focused
? theme.background.formfield.focused
: store.format === format
? themeV2.background.formfield.selected
: themeV2.background.formfield.default
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => selectFormat(format)}
>
<text
fg={
store.active === format
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.format === format
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
@@ -119,10 +119,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
gap={1}
backgroundColor={
store.active === "thinking"
? themeV2.background.formfield.focused
? theme.background.formfield.focused
: store.thinking
? themeV2.background.formfield.selected
: themeV2.background.formfield.default
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "thinking")
@@ -132,10 +132,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text
fg={
store.active === "thinking"
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.thinking
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.thinking ? "[x]" : "[ ]"}
@@ -143,10 +143,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text
fg={
store.active === "thinking"
? themeV2.text.formfield.focused
? theme.text.formfield.focused
: store.thinking
? themeV2.text.formfield.selected
: themeV2.text.formfield.default
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Include thinking
@@ -167,14 +167,12 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
paddingRight={4}
backgroundColor={
store.active === "export"
? themeV2.background.action.primary.focused
: themeV2.background.action.primary.default
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseUp={() => confirm("export")}
>
<text
fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
>
<text fg={store.active === "export" ? theme.text.action.primary.focused : theme.text.action.primary.default}>
Export
</text>
</box>
+7 -7
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const close = () => {
props.onClose?.()
@@ -27,24 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Session exported
</text>
<text fg={themeV2.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
<box>
<text fg={themeV2.text.default}>{props.path}</text>
<text fg={theme.text.default}>{props.path}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={close}
>
<text fg={themeV2.text.action.primary.focused}>Close</text>
<text fg={theme.text.action.primary.focused}>Close</text>
</box>
</box>
</box>
+7 -7
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "./dialog"
export function DialogHelp() {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
@@ -19,15 +19,15 @@ export function DialogHelp() {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Help
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
</box>
<box paddingBottom={1}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
</text>
</box>
@@ -35,10 +35,10 @@ export function DialogHelp() {
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={themeV2.background.action.primary.focused}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
>
<text fg={themeV2.text.action.primary.focused}>ok</text>
<text fg={theme.text.action.primary.focused}>ok</text>
</box>
</box>
</box>
+12 -12
View File
@@ -1,6 +1,6 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}
textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default}
placeholderColor={theme.text.subdued}
textColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
focusedTextColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.default}
cursorColor={props.busy ? theme.background.formfield.disabled : theme.text.default}
/>
<Show when={props.busy}>
<Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
<Spinner color={theme.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}>
<Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span>
<text fg={theme.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
</Show>
</Show>
+32 -33
View File
@@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -95,7 +95,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
type VisibleAction = (Action & { label: string }) | FooterHint
const dialog = useDialog()
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -522,10 +524,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!isActionItem(action.item))
return (
<text>
<span style={{ fg: themeV2.text.default }}>
<span style={{ fg: theme.text.default }}>
<b>{action.item.title}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>{action.item.label}</span>
<span style={{ fg: theme.text.subdued }}>{action.item.label}</span>
</text>
)
const item = action.item
@@ -534,16 +536,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box
flexDirection="row"
backgroundColor={active() ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={active() ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => trigger(item)}
>
<text
fg={
disabled()
? themeV2.text.action.primary.disabled
? theme.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.default
? theme.text.action.primary.focused
: theme.text.default
}
attributes={active() ? TextAttributes.BOLD : undefined}
>
@@ -552,10 +554,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text
fg={
disabled()
? themeV2.text.action.primary.disabled
? theme.text.action.primary.disabled
: active()
? themeV2.text.action.primary.focused
: themeV2.text.subdued
? theme.text.action.primary.focused
: theme.text.subdued
}
>
{" " + item.label}
@@ -569,11 +571,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
{props.titleView ?? (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{props.title}
</text>
)}
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -587,9 +589,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e)
})
}}
focusedBackgroundColor={themeV2.background.formfield.focused}
cursorColor={themeV2.text.formfield.focused}
focusedTextColor={themeV2.text.formfield.focused}
focusedBackgroundColor={theme.background.formfield.focused}
cursorColor={theme.text.formfield.focused}
focusedTextColor={theme.text.formfield.focused}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
@@ -600,7 +602,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={themeV2.text.subdued}
placeholderColor={theme.text.subdued}
/>
</box>
</Show>
@@ -614,14 +616,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No items available</text>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No results found</text>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}
</Show>
@@ -643,10 +645,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show
when={options[0]?.categoryView}
fallback={
<text
fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}
attributes={TextAttributes.BOLD}
>
<text fg={theme.hue.accent[mode() === "light" ? 800 : 200]} attributes={TextAttributes.BOLD}>
{category}
</text>
}
@@ -695,8 +694,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
backgroundColor={
active()
? actionFocused()
? themeV2.background.surface.overlay
: (option.bg ?? themeV2.background.action.primary.focused)
? theme.background.surface.overlay
: (option.bg ?? theme.background.action.primary.focused)
: RGBA.fromInts(0, 0, 0, 0)
}
>
@@ -725,7 +724,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text
fg={option.detailsColor ?? themeV2.text.subdued}
fg={option.detailsColor ?? theme.text.subdued}
wrapMode={option.detailsWrap ? "word" : "none"}
>
{option.detailsWrap
@@ -774,12 +773,12 @@ function Option(props: {
activeColor?: RGBA
onMouseOver?: () => void
}) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const text = createMemo(() => {
if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused
if (props.muted && (props.active || props.current)) return themeV2.text.subdued
if (props.current) return themeV2.text.formfield.selected
return themeV2.text.default
if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
if (props.muted && (props.active || props.current)) return theme.text.subdued
if (props.current) return theme.text.formfield.selected
return theme.text.default
})
return (
@@ -809,14 +808,14 @@ function Option(props: {
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active && !props.muted ? text() : themeV2.text.subdued }}>
<span style={{ fg: props.active && !props.muted ? text() : theme.text.subdued }}>
{" " + props.description}
</span>
</Show>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active && !props.muted ? text() : themeV2.text.subdued}>{props.footer}</text>
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
</box>
</Show>
</>
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
let dismiss = false
@@ -59,7 +59,7 @@ export function Dialog(
}}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
paddingTop={1}
>
{props.children}
+6 -6
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { themeV2 } = useTheme().contextual("overlay")
const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
return (
@@ -31,17 +31,17 @@ export function Toast() {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
borderColor={themeV2.text.feedback[current().variant].default}
backgroundColor={theme.background.default}
borderColor={theme.text.feedback[current().variant].default}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text.default}>
{current().title}
</text>
</Show>
<text fg={themeV2.text.default} wrapMode="word" width="100%">
<text fg={theme.text.default} wrapMode="word" width="100%">
{current().message}
</text>
</box>
@@ -4,10 +4,15 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { ThemeProvider } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import {
DiffViewerFileTree,
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createPluginTheme } from "../../../src/plugin/context"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -17,7 +22,7 @@ describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const lines = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
@@ -45,13 +50,13 @@ describe("DiffViewerFileTree", () => {
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff...")
@@ -67,7 +72,7 @@ describe("DiffViewerFileTree", () => {
const focused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
@@ -78,7 +83,7 @@ describe("DiffViewerFileTree", () => {
)),
)
const unfocused = visibleLines(
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
await renderFrame(() => <ThemedDiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
@@ -97,7 +102,13 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
expandedNodes={collapsed}
/>
)),
),
).toEqual(["▸ src/config"])
@@ -105,7 +116,7 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
<ThemedDiffViewerFileTree
files={files}
width={32}
loading={false}
@@ -118,6 +129,10 @@ describe("DiffViewerFileTree", () => {
})
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {
const mounted = Promise.withResolvers<void>()
const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 })
@@ -11,7 +11,7 @@ import type {
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@@ -21,6 +21,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createPluginTheme } from "../../../src/plugin/context"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -157,6 +158,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
}, createEventStream())
function Harness() {
let theme: ReturnType<typeof createPluginTheme>
const context = {
options: {},
client: createApi(transport.fetch),
@@ -164,6 +166,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
},
get theme() {
return theme
},
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
@@ -202,6 +207,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
void diffViewerPlugin.setup(context)
function Content() {
theme = createPluginTheme(useTheme(), useThemes())
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
+36 -21
View File
@@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@@ -27,17 +27,17 @@ test("uses an available mode while retaining the pinned preference", async () =>
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
return <text>{value.mode()}</text>
}
function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
if (!themes) throw new Error("Theme provider is not mounted")
return themes
}
const app = await testRender(
@@ -56,7 +56,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
app.renderer.start()
try {
await wait(() => theme?.ready === true)
await wait(() => themes?.ready === true)
expect(current().mode()).toBe("light")
expect(current().modes()).toEqual(["light"])
expect(current().supports("dark")).toBeFalse()
@@ -72,7 +72,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
expect(current().set("native")).toBeTrue()
await wait(() => current().selected === "native")
expect(current().modes()).toEqual(["dark"])
expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(current().current.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
} finally {
app.renderer.destroy()
}
@@ -83,13 +83,13 @@ test.each([
["mode merging", { version: 2, light: { mergeMode: true } }],
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let failure: ThemeError | undefined
let unsubscribe: (() => void) | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
unsubscribe = value.onError((error) => (failure = error))
return <text>{value.selected}</text>
}
@@ -107,8 +107,8 @@ test.each([
app.renderer.start()
try {
await wait(() => theme?.ready === true)
expect(theme?.selected).toBe("opencode")
await wait(() => themes?.ready === true)
expect(themes?.selected).toBe("opencode")
expect(failure?.name).toBe("invalid")
expect(failure?.error).toBeInstanceOf(Error)
expect(failure?.error.message.length).toBeGreaterThan(0)
@@ -118,17 +118,30 @@ test.each([
}
})
test("contextual themes fall back to a standalone theme's base view", async () => {
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
const standalone = {
version: 2,
standalone: true,
dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue },
dark: {
hue: selectTheme(DEFAULT_THEME, "dark").hue,
"@context:elevated": { text: { default: "#abcdef" } },
},
} as const
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
function Probe() {
function ContextProbe() {
theme = useTheme()
return <text>{theme.selected}</text>
return <text>{theme.text.default.toString()}</text>
}
function Probe() {
themes = useThemes()
return (
<ThemeContextProvider context="elevated">
<ContextProbe />
</ThemeContextProvider>
)
}
const app = await testRender(
@@ -144,10 +157,12 @@ test("contextual themes fall back to a standalone theme's base view", async () =
app.renderer.start()
try {
await wait(() => theme?.ready === true)
if (!theme) throw new Error("Theme provider is not mounted")
expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default)
expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default)
await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted")
if (!theme) throw new Error("Contextual theme is not mounted")
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.default).toBe(themes.contextual("elevated").text.default)
expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default)
} finally {
app.renderer.destroy()
}