Compare commits

...
9 Commits
121 changed files with 3098 additions and 1417 deletions
+21
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": {
@@ -859,6 +862,20 @@
"vite": "catalog:",
},
},
"packages/theme": {
"name": "@opencode-ai/theme",
"version": "0.0.0",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.4",
@@ -868,6 +885,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
@@ -1030,6 +1048,7 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
@@ -2075,6 +2094,8 @@
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"],
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
+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(
[
+14 -5
View File
@@ -14,10 +14,23 @@ const ServerParams = {
),
}
const PermissionParams = {
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
Flag.withDefault(false),
Flag.withHidden,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
...PermissionParams,
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
@@ -196,11 +209,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
...PermissionParams,
},
}),
Spec.make("service", {
@@ -59,6 +59,7 @@ export default Runtime.handler(Commands, (input) =>
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
config: {
path: config.path,
+1 -1
View File
@@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
file: [...input.file],
title: Option.getOrUndefined(input.title),
thinking: input.thinking,
auto: input.auto || input.yolo,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
}),
+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 })
}),
)
+39 -11
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) =>
@@ -189,8 +205,12 @@ export const Plugin = {
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
})
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
@@ -291,5 +311,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())
}),
)
}),
}
+11 -6
View File
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
: "printf body && exit 7"
const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
const progressOverflowCommand = (bytes: number, release: string) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
@@ -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,
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text.includes("output-start")).toBe(false)
expect(content.text.includes("output-end")).toBe(true)
expect(content).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
+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:"
}
+21 -2
View File
@@ -113,7 +113,22 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
export type Slot = (props: Record<string, any>) => JSX.Element
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "sidebar.content": {
readonly sessionID: string
}
readonly "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
export interface App {
readonly version: string
readonly channel: string
}
export type ToastVariant = "info" | "success" | "warning" | "error"
@@ -308,17 +323,21 @@ export interface Keymap {
export interface UI {
readonly dialog: Dialog
readonly toast: Toast
readonly format: {
path(value: string): string
}
readonly router: {
register(page: Page): () => void
navigate(destination: Destination): void
current(): Route
}
readonly slot: (name: string, render: Slot) => () => void
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
}
export interface Context {
readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly app: App
readonly renderer: CliRenderer
readonly client: OpenCodeClient
readonly data: Data
+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
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/theme",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/theme"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./tui": "./src/tui/index.ts",
"./tui/v1": "./src/tui/v1.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+77
View File
@@ -0,0 +1,77 @@
type OklchColor = {
l: number
c: number
h: number
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function hue(value: number) {
return ((value % 360) + 360) % 360
}
function linearToSrgb(value: number) {
if (value <= 0.0031308) return value * 12.92
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
}
function srgbToLinear(value: number) {
if (value <= 0.04045) return value / 12.92
return Math.pow((value + 0.055) / 1.055, 2.4)
}
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
const linearRed = srgbToLinear(red)
const linearGreen = srgbToLinear(green)
const linearBlue = srgbToLinear(blue)
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
const chroma = Math.sqrt(a * a + b * b)
const angle = Math.atan2(b, a) * (180 / Math.PI)
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
}
function oklchToRgb(color: OklchColor) {
const a = color.c * Math.cos((color.h * Math.PI) / 180)
const b = color.c * Math.sin((color.h * Math.PI) / 180)
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
const l = lRoot * lRoot * lRoot
const m = mRoot * mRoot * mRoot
const s = sRoot * sRoot * sRoot
return {
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
}
}
function fitOklch(color: OklchColor): OklchColor {
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
const rgb = oklchToRgb(base)
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
if (result) return result
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
const output = oklchToRgb(next)
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
}, undefined)
return fitted ?? { ...base, c: 0 }
}
export function oklchToHex(color: OklchColor) {
const rgb = oklchToRgb(fitOklch(color))
const toHex = (value: number) =>
Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, "0")
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
}
@@ -1,4 +1,4 @@
import type { HueName, ThemeDocument } from "./schema"
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
@@ -4,8 +4,8 @@ import type {
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
} from "./index.js"
import { ActionState } from "./schema.js"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
+57
View File
@@ -0,0 +1,57 @@
import type { ThemeTokensDefinition } from "./index.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
@@ -29,7 +29,7 @@ export {
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema"
} from "./schema.js"
export type {
Categorical,
@@ -42,7 +42,9 @@ export type {
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types"
export { DEFAULT_CATEGORICAL } from "./defaults"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
export { generateSyntax } from "./syntax.js"
@@ -1,8 +1,8 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import { expandTheme, expandTokens, mergeTheme } from "./expand"
import { fallback } from "./fallback"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
@@ -12,7 +12,7 @@ import {
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema"
} from "./schema.js"
import type {
ActionStateKey,
HueDefinition,
@@ -22,8 +22,8 @@ import type {
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
@@ -1,4 +1,4 @@
import { expandTheme, mergeTheme } from "./expand"
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
@@ -6,7 +6,7 @@ import type {
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index"
} from "./index.js"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
@@ -1,5 +1,5 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index"
import type { Mode, ResolvedThemeView } from "./index.js"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
@@ -9,7 +9,7 @@ import type {
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema"
} from "./schema.js"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeV1Json } from "../v1"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
import { HueStep } from "./schema"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
+76
View File
@@ -0,0 +1,76 @@
import type { RGBA } from "@opentui/core"
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
}
}
+1
View File
@@ -83,6 +83,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
+192 -116
View File
@@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
import { DevToolsBar } from "./component/devtools-bar"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
import { LocationProvider, useLocation } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { PermissionProvider } from "./context/permission"
@@ -65,8 +66,9 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { SessionTabs } from "./component/session-tabs"
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"
@@ -92,9 +94,26 @@ import { AttentionProvider } from "./context/attention"
registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
const appGlobalBindingCommands = ["session.list", "session.new"] as const
const sessionTabBindingCommands = [
"session.tab.next",
"session.tab.previous",
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
"session.tab.select.1",
"session.tab.select.2",
"session.tab.select.3",
"session.tab.select.4",
"session.tab.select.5",
"session.tab.select.6",
"session.tab.select.7",
"session.tab.select.8",
"session.tab.select.9",
] as const
const pinnedSessionBindingCommands = [
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -277,113 +296,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiLifecycleProvider
<TuiPathsProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiTerminalEnvironmentProvider
<TuiLifecycleProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
</EpilogueProvider>
@@ -419,12 +440,13 @@ function App(props: { pair?: DialogPairCredentials }) {
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const keymap = Keymap.use()
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()
@@ -623,9 +645,55 @@ function App(props: { pair?: DialogPairCredentials }) {
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
palette: undefined,
run: () => {
local.session.quickSwitch(i + 1)
},
enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1),
})),
{
name: "session.tab.next",
title: "Next open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1),
},
{
name: "session.tab.previous",
title: "Previous open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.next_unread",
title: "Next unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1),
},
{
name: "session.tab.previous_unread",
title: "Previous unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1),
},
{
name: "session.tab.close",
title: "Close current session tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.close(),
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to session tab ${i + 1}`,
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i),
})),
{
name: "model.list",
@@ -1004,6 +1072,18 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: appGlobalBindingCommands,
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: sessionTabs.enabled,
bindings: sessionTabBindingCommands,
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: () => !sessionTabs.enabled(),
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
@@ -1090,7 +1170,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
@@ -1099,16 +1179,15 @@ function App(props: { pair?: DialogPairCredentials }) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
copyOnSelectEnabled()
? () => Selection.copy(renderer, toast, clipboard)
: undefined
}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
@@ -1127,10 +1206,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
<PluginSlot name="app" input={{}} mode="all" />
</Show>
</box>
</box>
+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>
)
+12 -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"
@@ -93,6 +93,14 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Tabs",
category: "Session",
path: ["session", "tabs"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Layout",
category: "Diffs",
@@ -267,7 +275,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 +288,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"
@@ -15,14 +15,18 @@ import { useToast } from "../ui/toast"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
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 sessionTabs = useSessionTabs()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
@@ -77,6 +81,7 @@ export function DialogSessionList() {
})
const quickSwitchHint = createMemo(() => {
if (sessionTabs.enabled()) return
const first = shortcuts.get("session.quick_switch.1")
const last = shortcuts.get("session.quick_switch.9")
if (!first || !last) return
@@ -94,7 +99,7 @@ export function DialogSessionList() {
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
@@ -102,20 +107,20 @@ export function DialogSessionList() {
const directory = session.location.directory
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
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 +148,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>
@@ -162,7 +167,8 @@ export function DialogSessionList() {
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
hidden: sessionTabs.enabled(),
onTrigger: (option) => local.session.togglePin(option.value),
},
{
command: "session.delete",
+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>
)
+231
View File
@@ -0,0 +1,231 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
export function SessionTabs() {
const tabs = useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { mode } = useThemes()
const config = useConfig().data
const [hovered, setHovered] = createSignal<string>()
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
const statuses = createMemo(
() =>
new Map(
layout().tabs.map((tab) => {
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
...status,
complete: sessionTabComplete(status.unread, status.busy),
},
] as const
}),
),
)
const targets = createMemo(() => ({
widths: layout().widths,
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
}))
const motion = createAnimatable(targets(), {
enabled: () => config.animations ?? true,
transition: spring({ visualDuration: 0.1 }),
})
const identity = createMemo(() =>
layout()
.tabs.map((tab) => tab.sessionID)
.join(":"),
)
let signature = ""
let total = 0
createEffect(() => {
const next = targets()
const nextSignature = identity()
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
signature = nextSignature
total = layout().total
if (reset) return motion.jump(next)
motion.animate(next)
})
const visuals = createMemo(() => {
const current = signature === identity() && total === layout().total ? motion.value() : targets()
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
{
width: widths[index]!,
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
},
]),
)
})
return (
<box
height={1}
flexShrink={0}
position="relative"
flexDirection="row"
zIndex={1}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
const width = Math.min(this.width, buffer.width - x)
if (y < 0 || y >= buffer.height || width <= 0) return
buffer.fillRect(
x,
y,
width,
1,
RGBA.fromValues(
theme.background.default.r,
theme.background.default.g,
theme.background.default.b,
mode() === "light" ? 0.14 : 0.28,
),
)
}}
>
<Show when={layout().before > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().before}
</text>
</Show>
<For each={layout().tabs}>
{(tab) => {
const selected = () => activeID() === tab.sessionID
const status = () => statuses().get(tab.sessionID)!
const width = () => visuals().get(tab.sessionID)?.width ?? 1
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
const background = () => {
const base =
hovered() === tab.sessionID && !selected()
? theme.background.action.primary.hovered
: theme.background.default
return tint(base, theme.raise(theme.background.surface.offset), selection())
}
const pulseBackground = () => background()
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
const title = () => tab.title ?? "Untitled session"
const availableTitleWidth = () => Math.max(1, width() - 3)
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
const titleFades = createMemo(() => visibleTitle() !== title() && availableTitleWidth() > fadeWidth())
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
return tint(base, accent(), activity())
}
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
return (
<box
width={width()}
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => tabs.select(tab.sessionID)}
>
<TabPulse
enabled={config.animations ?? true}
active={status().busy}
complete={status().complete}
color={pulseColor()}
completionColor={accent()}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1}> </text>
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
</text>
<Show
when={titleFades()}
fallback={
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitle()}
</text>
}
>
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
pulseBackground(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
>
{character}
</span>
)}
</For>
</text>
</Show>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeColor()}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
)
}}
</For>
<Show when={layout().after > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().after}
</text>
</Show>
</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>
+207
View File
@@ -0,0 +1,207 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
import { tint } from "../theme/color"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
complete?: boolean
color?: RGBA
completionColor?: RGBA
backgroundColor?: RGBA
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_HEAD = 4
const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
}
const coast = (value: number) => {
const ramp = 0.2
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
return (value - ramp / 2) / (1 - ramp)
}
export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _color: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private completionPending = false
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
const active = options.active ?? false
super(ctx, { ...options, height: 1, live: enabled && active })
this._enabled = enabled
this._active = active
this._complete = options.complete ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.fadeClock = undefined
this.completionClock = undefined
this.completionPending = false
this.live = false
} else if (this._active) {
this.live = true
}
this.requestRender()
}
set active(value: boolean) {
if (value === this._active) return
this._active = value
if (!this._enabled) return
if (value) {
this.fadeClock = undefined
this.completionClock = undefined
this.completionPending = false
this.live = true
} else {
this.fadeClock = 0
this.completionPending = true
this.live = true
}
this.requestRender()
}
set complete(value: boolean) {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionClock = undefined
this.completionPending = false
}
if (value && this.completionPending) {
this.completionClock = 0
this.completionPending = false
this.live = this._enabled
}
this.requestRender()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
this.requestRender()
}
set backgroundColor(value: RGBA) {
if (value.equals(this._backgroundColor)) return
this._backgroundColor = value
this.requestRender()
}
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
if (this.fadeClock !== undefined) {
this.fadeClock += deltaTime
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
}
if (this.completionPending) {
if (this._complete) {
this.completionClock = 0
this.completionPending = false
} else if (this.fadeClock === undefined) {
this.completionPending = false
}
}
if (this.completionClock !== undefined) {
this.completionClock += deltaTime
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
}
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return
const runningOpacity = this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (runningOpacity === 0 && completionOpacity === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
for (let index = 0; index < this.width; index++) {
const intensity = Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity)
buffer.setCell(
this.screenX + index,
this.screenY,
" ",
RGBA.defaultForeground(),
tint(running, this._completionColor, completionOpacity * 0.18),
)
}
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
tab_pulse: typeof TabPulseRenderable
}
}
extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
enabled?: boolean
active: boolean
complete?: boolean
color: RGBA
completionColor?: RGBA
backgroundColor: RGBA
}) {
return (
<tab_pulse
position="absolute"
zIndex={0}
width="100%"
enabled={props.enabled ?? true}
active={props.active}
complete={props.complete ?? false}
color={props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
/>
)
}
@@ -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}`,
+3
View File
@@ -120,6 +120,9 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
tabs: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
mini: Schema.optional(
+29 -1
View File
@@ -87,6 +87,11 @@ export const Definitions = {
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
session_tab_close: keybind("<leader>w", "Close current session tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
@@ -97,7 +102,7 @@ export const Definitions = {
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
@@ -111,6 +116,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -289,6 +303,11 @@ export const CommandMap = {
session_move: "session.move",
session_new: "session.new",
session_list: "session.list",
session_tab_next: "session.tab.next",
session_tab_previous: "session.tab.previous",
session_tab_next_unread: "session.tab.next_unread",
session_tab_previous_unread: "session.tab.previous_unread",
session_tab_close: "session.tab.close",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
@@ -313,6 +332,15 @@ export const CommandMap = {
session_quick_switch_7: "session.quick_switch.7",
session_quick_switch_8: "session.quick_switch.8",
session_quick_switch_9: "session.quick_switch.9",
session_tab_select_1: "session.tab.select.1",
session_tab_select_2: "session.tab.select.2",
session_tab_select_3: "session.tab.select.3",
session_tab_select_4: "session.tab.select.4",
session_tab_select_5: "session.tab.select.5",
session_tab_select_6: "session.tab.select.6",
session_tab_select_7: "session.tab.select.7",
session_tab_select_8: "session.tab.select.8",
session_tab_select_9: "session.tab.select.9",
stash_delete: "stash.delete",
model_provider_list: "model.dialog.provider",
model_favorite_toggle: "model.dialog.favorite",
+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),
)
})
@@ -0,0 +1,108 @@
export type SessionTab = {
sessionID: string
title?: string
}
export type SessionTabUnread = "activity" | "error"
export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
return unread === "activity" && !busy
}
export const SESSION_TAB_WIDTH = 22
export const SESSION_TAB_MAX_WIDTH = 32
export const SESSION_TAB_MIN_WIDTH = 8
export const SESSION_TAB_OVERFLOW_WIDTH = 3
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
if (index === -1) return [...tabs, tab]
if (!tab.title || tabs[index]?.title === tab.title) return tabs
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
}
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
if (index === -1) return { tabs: [...tabs], next: undefined }
return {
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
}
}
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
return tabs[(start + direction + tabs.length) % tabs.length]
}
export function adaptiveSessionTabLayout(
tabs: readonly SessionTab[],
active: string | undefined,
available: number,
previousStart = 0,
) {
if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 }
const activeIndex = Math.max(
0,
tabs.findIndex((tab) => tab.sessionID === active),
)
const fit = (width: number) =>
Math.min(tabs.length, Math.max(1, 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH)))
const solve = (count: number, start: number, attempts: number): { count: number; start: number } => {
const nextStart = Math.min(
Math.max(0, activeIndex < start ? activeIndex : activeIndex >= start + count ? activeIndex - count + 1 : start),
tabs.length - count,
)
const markers =
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
const nextCount = fit(available - markers)
if (nextCount === count || attempts === 0) return { count, start: nextStart }
return solve(nextCount, nextStart, attempts - 1)
}
const solved = solve(fit(available), previousStart, 3)
const visible = tabs.slice(solved.start, solved.start + solved.count)
const before = solved.start
const after = tabs.length - solved.start - solved.count
const contentWidth = Math.max(
1,
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
)
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
if (roomy) {
const width = Math.floor(total / visible.length)
const remainder = total - width * visible.length
return {
tabs: visible,
widths: visible.map((_, index) => width + Number(index < remainder)),
before,
after,
start: solved.start,
total,
}
}
const inactiveWidth =
visible.length === 1
? 0
: Math.min(
SESSION_TAB_WIDTH,
Math.max(
SESSION_TAB_MIN_WIDTH,
Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)),
),
)
const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1)
return {
tabs: visible,
widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)),
before,
after,
start: solved.start,
total,
}
}
+251
View File
@@ -0,0 +1,251 @@
import { batch, createEffect, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import path from "path"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useData } from "./data"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useTuiPaths } from "./runtime"
import { useConfig } from "../config"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { isRecord } from "../util/record"
import {
closeSessionTab,
cycleSessionTab,
openSessionTab,
type SessionTab,
type SessionTabUnread,
} from "./session-tabs-model"
type PersistedState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
}
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
init: () => {
const route = useRoute()
const data = useData()
const event = useEvent()
const config = useConfig().data
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const enabled = () => config.session?.tabs ?? false
const state: {
pending: boolean
saving: boolean
snapshot: string
value?: PersistedState
} = { pending: false, saving: false, snapshot: "" }
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
ready: false,
tabs: [],
unread: {},
})
const root = (sessionID: string) => data.session.root(sessionID)
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: store.unread[session],
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
function save() {
if (!store.ready) {
state.pending = true
return
}
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
const snapshot = JSON.stringify(value)
if (snapshot === state.snapshot && !state.saving) return
state.value = value
state.pending = true
flush()
}
function flush() {
if (state.saving || !state.pending || !state.value) return
const value = state.value
const snapshot = JSON.stringify(value)
state.pending = false
if (snapshot === state.snapshot) return
state.saving = true
void writeJsonAtomic(filePath, value)
.then(() => {
state.snapshot = snapshot
})
.catch(() => {})
.finally(() => {
state.saving = false
flush()
})
}
function open(sessionID: string) {
const session = root(sessionID)
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
if (next === store.tabs) return { sessionID: session, changed: false }
setStore("tabs", reconcile(next))
return { sessionID: session, changed: true }
}
function clearUnread(sessionID: string) {
const session = root(sessionID)
if (!store.unread[session]) return false
setStore(
"unread",
produce((draft) => {
delete draft[session]
}),
)
return true
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
const session = root(sessionID)
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
if (store.unread[session] === unread) return
setStore("unread", session, unread)
save()
}
readJson<unknown>(filePath)
.then((value) => {
if (!isRecord(value)) return
const persisted = value
if (Array.isArray(persisted.tabs))
setStore(
"tabs",
persisted.tabs.flatMap((tab) => {
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
}),
)
if (persisted.unread && typeof persisted.unread === "object")
setStore(
"unread",
Object.fromEntries(
Object.entries(persisted.unread).filter(
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
),
),
)
})
.catch(() => {})
.finally(() => {
setStore("ready", true)
if (state.pending) save()
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
})
createEffect(() => {
if (!enabled()) return
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
const routeSessionID = route.data.sessionID
batch(() => {
const opened = open(routeSessionID)
const changed = clearUnread(opened.sessionID)
if (opened.changed || changed) untrack(save)
})
})
createEffect(() => {
if (!enabled() || !store.ready) return
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
batch(() => {
setStore("tabs", reconcile(next))
setStore("unread", reconcile(unread))
})
save()
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.error", (evt) => {
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
remove(evt.data.sessionID, enabled())
}),
)
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
const closed = closeSessionTab(store.tabs, target)
if (closed.tabs.length === store.tabs.length) return
batch(() => {
setStore("tabs", reconcile(closed.tabs))
clearUnread(target)
if (navigate && current() === target)
route.navigate(closed.next ? { type: "session", sessionID: closed.next } : { type: "home" })
})
save()
}
return {
enabled,
tabs() {
return store.tabs
},
current,
status,
select(sessionID: string) {
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
const previous = store.tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
return
}
remove(target, true)
},
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(store.tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = store.tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
}
},
})
+26 -21
View File
@@ -1,5 +1,6 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
import {
DEFAULT_THEMES,
addTheme,
@@ -14,12 +15,9 @@ import {
type Theme,
type ThemeDocumentSource,
} from "../theme"
import { generateSyntax } from "../theme/v2/syntax"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeDocument } from "../theme/v2/resolve"
import { themeModes } from "../theme/v2/select"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -98,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
@@ -310,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),
@@ -318,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),
@@ -357,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>
)
@@ -1,15 +1,12 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiApp, useTuiPaths } from "../../context/runtime"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
return (
@@ -52,7 +49,6 @@ function Mcp(props: { context: Plugin.Context }) {
}
function View(props: { context: Plugin.Context }) {
const app = useTuiApp()
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
@@ -74,12 +70,12 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{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>
)
}
@@ -1,18 +1,15 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
return (
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
<Show when={directory()}>
{(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,13 +10,11 @@ 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"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
@@ -84,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
@@ -739,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>
@@ -756,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>
@@ -770,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}
@@ -813,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>
@@ -873,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} />
@@ -890,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>
@@ -951,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 = [
{
@@ -1019,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>
@@ -1051,7 +1041,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
}
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1083,7 +1072,7 @@ function Commands(props: { context: Plugin.Context }) {
returnRoute,
},
})
dialog.clear()
props.context.ui.dialog.clear()
},
},
],
@@ -1,12 +1,8 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { useTheme } from "../../context/theme"
import { useDialog } from "../../ui/dialog"
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
Keymap.createLayer(() => ({
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
@@ -16,7 +12,7 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
dialog.clear()
props.context.ui.dialog.clear()
},
},
],
@@ -26,10 +22,10 @@ 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")
Keymap.createLayer(() => ({
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
@@ -43,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}
+62 -18
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,
@@ -13,7 +13,7 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import { useConfig } from "../config"
@@ -21,9 +21,9 @@ import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
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"
@@ -31,6 +31,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins"
export interface PackageResolver {
@@ -47,7 +48,7 @@ type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: (name: string) => ReadonlyArray<Slot>
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
@@ -75,8 +76,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle()
const app = useTuiApp()
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()
@@ -97,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()
@@ -120,7 +129,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
dialog.setCentered(options.centered ?? false)
},
clear() {
if (!activeDialog) return
dialog.clear()
},
alert(options) {
@@ -213,16 +221,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
context = {
options: item.options ?? {},
get location() {
return location.current
},
app: { version: app.version, channel: app.channel },
renderer,
client: client.api,
data,
attention,
theme: theme.themeV2,
theme: pluginTheme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@@ -235,11 +244,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, paths.home),
},
router: {
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
@@ -269,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
@@ -512,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")
@@ -530,7 +565,16 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
return <>{content()}</>
}
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return <For each={renderers()}>{(render) => render(props.input)}</For>
}
+1 -2
View File
@@ -85,11 +85,10 @@ export function Home() {
/>
</pluginRuntime.Slot>
</box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
</box>
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" />
<PluginSlot name="home.footer" input={{}} mode="replace" />
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
@@ -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>,
@@ -98,12 +98,6 @@ export function Composer(props: ComposerProps) {
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
{
bind: "<leader>down",
title: "Toggle composer",
group: "Composer",
run: close,
},
],
}))
@@ -113,8 +107,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 +119,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 +130,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 +140,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 +150,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>
+13 -11
View File
@@ -5,11 +5,13 @@ import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
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()
const mcp = createMemo(
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
)
@@ -52,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={permissions().length > 0}>
<text fg={themeV2.text.feedback.warning.default}>
<span style={{ fg: themeV2.text.feedback.warning.default }}></span> {permissions().length} Permission
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<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>
File diff suppressed because it is too large Load Diff
+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>
+9 -9
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,20 +45,20 @@ 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>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<PluginSlot name="sidebar.footer" />
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
</box>
</box>
</Show>
@@ -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>
@@ -1,6 +1,6 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedThemeView } from "./index"
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
return {
+1 -3
View File
@@ -1,9 +1,7 @@
import { Schema } from "effect"
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
import { ThemeDocument } from "./v2/schema"
import { migrateV1 } from "./v2/v1-migrate"
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
export { resolveThemeDocument, type ThemeDocument }
+2 -74
View File
@@ -1,4 +1,5 @@
import { RGBA, SyntaxStyle } from "@opentui/core"
import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1"
import aura from "./assets/aura.json" with { type: "json" }
import ayu from "./assets/ayu.json" with { type: "json" }
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
@@ -33,80 +34,7 @@ import vercel from "./assets/vercel.json" with { type: "json" }
import vesper from "./assets/vesper.json" with { type: "json" }
import zenburn from "./assets/zenburn.json" with { type: "json" }
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
aura,
-57
View File
@@ -1,57 +0,0 @@
import type { ThemeTokensDefinition } from "./index"
import { ActionVariant, FeedbackKind } from "./schema"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
-52
View File
@@ -1,52 +0,0 @@
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "./component"
import type { ContextKey, Mode, ResolvedTheme } from "./index"
type ThemeRuntime = {
readonly resolved: Accessor<ResolvedTheme>
readonly mode: Accessor<Mode>
readonly component: ComponentTheme
}
const ThemeContext = createContext<ThemeRuntime>()
export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) {
const resolved = () => props.theme
const mode = () => props.mode ?? "light"
return createComponent(ThemeContext.Provider, {
value: { resolved, mode, component: createComponentTheme(resolved, mode) },
get children() {
return props.children
},
})
}
export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
const parent = runtime()
const context = () => {
const value = parent.resolved().contexts[props.context]
if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
return value
}
context()
return createComponent(ThemeContext.Provider, {
value: { resolved: parent.resolved, mode: parent.mode, component: createComponentTheme(context, parent.mode) },
get children() {
return props.children
},
})
}
export function useTheme() {
return runtime().component
}
export function useResolvedTheme() {
return runtime().resolved
}
function runtime() {
const context = useContext(ThemeContext)
if (!context) throw new Error("Theme context must be used within a ThemeProvider")
return context
}
+235
View File
@@ -0,0 +1,235 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
type AnimatableValue = number | readonly number[]
type AnimatableTarget = Record<string, AnimatableValue>
type Ease = (progress: number) => number
type SpringTransition = {
type: "spring"
visualDuration: number
restDelta: number
restSpeed: number
}
type TweenTransition = {
type: "tween"
duration: number
ease: Ease
}
type Transition = SpringTransition | TweenTransition
type ValueState = {
scalar: boolean
value: number[]
target: number[]
velocity: number[]
from: number[]
}
type AnimationTask = (now: number) => boolean
const tasks = new Set<AnimationTask>()
let timer: ReturnType<typeof setInterval> | undefined
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
return {
type: "spring",
visualDuration: options.visualDuration,
restDelta: options.restDelta ?? 0.002,
restSpeed: options.restSpeed ?? 0.002,
}
}
export function tween(options: { duration: number; ease?: Ease }): Transition {
return {
type: "tween",
duration: options.duration,
ease: options.ease ?? smoothstep,
}
}
export function createAnimatable<T extends AnimatableTarget>(
initial: T,
options: {
transition: Transition
enabled?: Accessor<boolean>
},
) {
const enabled = options.enabled ?? (() => true)
const state = new Map<string, ValueState>()
const [value, setValue] = createSignal(clone(initial), { equals: false })
let target = clone(initial)
let started = performance.now()
let previous = started
rebuild(initial)
const step: AnimationTask = (now) => {
if (!enabled()) {
jump(target)
return false
}
const delta = Math.min(0.05, (now - previous) / 1_000)
previous = now
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
if (!moving) {
jump(target)
return false
}
setValue(() => read())
return true
}
function animate(next: T) {
if (sameTarget(next)) return
target = clone(next)
if (!enabled() || !sameShape(next)) return jump(next)
started = performance.now()
previous = started
for (const [key, current] of state) {
current.from = [...current.value]
current.target = values(next[key]!)
}
if (settled()) return jump(next)
schedule(step)
}
function jump(next: T) {
target = clone(next)
unschedule(step)
rebuild(next)
setValue(() => clone(next))
}
function stop() {
unschedule(step)
}
function rebuild(next: T) {
state.clear()
for (const [key, nextValue] of Object.entries(next)) {
const value = values(nextValue)
state.set(key, {
scalar: typeof nextValue === "number",
value,
target: [...value],
velocity: value.map(() => 0),
from: [...value],
})
}
started = performance.now()
previous = started
}
function sameShape(next: T) {
const entries = Object.entries(next)
if (entries.length !== state.size) return false
return entries.every(([key, nextValue]) => {
const current = state.get(key)
if (!current || current.scalar !== (typeof nextValue === "number")) return false
return current.value.length === values(nextValue).length
})
}
function sameTarget(next: T) {
if (!sameShape(next)) return false
return Object.entries(next).every(([key, value]) =>
values(value).every((part, index) => part === state.get(key)!.target[index]),
)
}
function settled() {
return [...state.values()].every(
(current) =>
current.value.every((value, index) => value === current.target[index]) &&
current.velocity.every((velocity) => velocity === 0),
)
}
function advanceSpring(delta: number) {
const transition = options.transition
if (transition.type !== "spring") return false
const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2)
let moving = false
for (const current of state.values()) {
current.value.forEach((value, index) => {
const target = current.target[index]!
const velocity = current.velocity[index]!
const offset = value - target
const decay = Math.exp(-frequency * delta)
const nextVelocity = velocity + frequency * offset
current.value[index] = target + (offset + nextVelocity * delta) * decay
current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay
moving ||=
Math.abs(current.value[index]! - target) > transition.restDelta ||
Math.abs(current.velocity[index]!) > transition.restSpeed
})
}
return moving
}
function advanceTween(now: number) {
const transition = options.transition
if (transition.type !== "tween") return false
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
const eased = transition.ease(progress)
for (const current of state.values())
current.value.forEach((_, index) => {
const from = current.from[index]!
current.value[index] = from + (current.target[index]! - from) * eased
current.velocity[index] = 0
})
return progress < 1
}
function read() {
return Object.fromEntries(
[...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]),
) as T
}
createEffect(() => {
if (enabled()) return
jump(target)
})
onCleanup(stop)
return { value, animate, jump }
}
function values(value: AnimatableValue) {
return typeof value === "number" ? [value] : [...value]
}
function clone<T extends AnimatableTarget>(target: T) {
return Object.fromEntries(
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
) as T
}
function schedule(task: AnimationTask) {
tasks.add(task)
if (timer) return
timer = setInterval(tick, 16)
}
function unschedule(task: AnimationTask) {
tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
function tick() {
const now = performance.now()
for (const task of tasks) if (!task(now)) tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
+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}

Some files were not shown because too many files have changed in this diff Show More