mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 21:56:17 +00:00
Compare commits
9
Commits
watcher-binding
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754ea99d86 | ||
|
|
37a1b80d5a | ||
|
|
f95d04fea0 | ||
|
|
08b80da931 | ||
|
|
f6fb1a7cdd | ||
|
|
5bcc0016a6 | ||
|
|
771174b5c3 | ||
|
|
44cd984589 | ||
|
|
c445d98188 |
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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:"),
|
||||
})
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { PluginContextProvider, usePlugin } from "./solid.js"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -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 {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user