Compare commits

...

12 Commits

Author SHA1 Message Date
James Long b51c2ac056 refactor(theme): flatten TUI V1 types 2026-07-28 17:42:45 +00:00
James Long 6af9ae84ff refactor(tui): remove unused theme provider 2026-07-28 17:41:47 +00:00
James Long c0fbe920d3 feat(theme): extract TUI theme package 2026-07-28 17:26:00 +00:00
Kit Langton e556aca833 refactor(core): simplify plugin reload loop (#39356) 2026-07-28 12:21:29 -04:00
opencode-agent[bot] 73bd8a264b fix(app): keep new tab button visible (#39366)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:18:26 +00:00
opencode-agent[bot] 077338fcc8 fix(app): hide delete for provided servers (#39363)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:00:47 +00:00
Dax Raad b671a77145 fix(tui): simplify form field labels 2026-07-28 11:56:11 -04:00
Dax Raad 30d09a7d7e fix(core): improve web search consent flow 2026-07-28 11:54:44 -04:00
Dax Raad ee02fb4fce Websearch tweaks 2026-07-28 11:10:33 -04:00
Dax Raad 010133f6df feat(tui): expand v2 plugin context 2026-07-28 11:04:16 -04:00
opencode-agent[bot] 3b0d8f0e6f fix(tui): clear rehydrated compaction state (#39336)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-28 16:04:11 +02:00
Simon Klee 4d59b059ee feat(tui): add verbose turn token usage (#39281) 2026-07-28 13:57:19 +02:00
67 changed files with 1368 additions and 621 deletions
+18
View File
@@ -859,6 +859,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 +882,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 +1045,7 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
@@ -2075,6 +2091,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"],
@@ -562,6 +562,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
@@ -649,13 +650,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.controller.canRemove(key)}>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -21,6 +21,7 @@ export const ServerRowMenu: Component<{
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
canRemove={props.controller.canRemove(key)}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
@@ -47,6 +48,7 @@ export const ServerRowMenuView: Component<{
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
canRemove: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
@@ -84,10 +86,10 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
<Show when={props.canRemove}>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
</Show>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
+19 -21
View File
@@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
+16
View File
@@ -178,6 +178,17 @@ export function resolveServerList(input: {
return [...deduped.values()]
}
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
@@ -312,6 +323,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
function canRemove(key: ServerConnection.Key) {
return canRemoveServer({ key, provided: props.servers, stored: store.list })
}
const isReady = Object.assign(
createMemo(() => ready() && !!state.active),
{ promise: ready.promise },
@@ -350,6 +365,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
canRemove,
scope,
projects: {
...projects,
@@ -60,6 +60,7 @@ export function createHomeProjectsController(home: HomeController) {
defaultKey: serverManagement.defaultKey,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
@@ -47,6 +47,7 @@ export type HomeProjectsViewProps = {
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
@@ -192,6 +193,7 @@ function HomeServerRow(props: {
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
@@ -277,6 +279,7 @@ function HomeServerRow(props: {
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
@@ -24,6 +24,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
+23 -46
View File
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
@@ -230,10 +230,8 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Deferred.make<void>()
let observed = 0
let applied = -1
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
@@ -265,25 +263,19 @@ const layer = Layer.effect(
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
yield* lock.withPermit(
Effect.gen(function* () {
if (applied >= target) return
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
applied = target
}),
)
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
})
const sourceChanges = config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
@@ -295,31 +287,16 @@ const layer = Layer.effect(
const busUpdates = bus
.subscribe([Event.Updated, SdkPlugins.Updated])
.pipe(Stream.mapEffect(() => Effect.sync(() => ++observed)))
const updates = yield* Stream.merge(busUpdates, sourceChanges).pipe(
Stream.toQueue({ capacity: 1, strategy: "sliding" }),
)
const signals = yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(updates)).pipe(
Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }),
)
const attempt = (target: number) =>
activate(target).pipe(
Effect.map(() => observed === target),
Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))),
)
yield* signals.pipe(
Stream.runForEach((target) =>
activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* signals.pipe(
yield* Stream.concat(Stream.succeed(0), Stream.merge(busUpdates, sourceChanges)).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
Stream.mapEffect(attempt),
Stream.filter((settled) => settled),
Stream.take(1),
Stream.runDrain,
Effect.andThen(Deferred.succeed(ready, undefined)),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Deferred.await(ready) })
+101 -77
View File
@@ -32,85 +32,109 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
{
name,
options: { codemode: false },
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a provider so the agent can search the web",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
title: "Provider",
description: "OpenCode will use your choice for future searches.",
type: "string",
required: true,
custom: false,
options: [
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
{ value: "__disable__", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const answer = response.answer.provider
if (answer === "__disable__") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", answer)
return yield* ctx.websearch.query(input)
draft.add({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
},
],
})
: undefined
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", providerID)
return yield* ctx.websearch.query(input)
})
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
},
),
),
}),
)
.pipe(Effect.orDie)
+147 -16
View File
@@ -30,6 +30,15 @@ const webSearchToolNode = makeLocationNode({
const sessionID = Session.ID.make("ses_websearch_test")
const assertions: Permission.AssertInput[] = []
const queries: WebSearch.Input[] = []
const formRequests: Form.CreateInput[] = []
const values = new Map<string, KV.Value>()
const providers = [
{ id: WebSearch.ID.make("exa"), name: "Exa" },
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
]
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -38,6 +47,11 @@ let result = new WebSearch.Response({
beforeEach(() => {
assertions.length = 0
queries.length = 0
formRequests.length = 0
values.clear()
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -60,11 +74,15 @@ const websearch = Layer.succeed(
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed([]),
providers: () => Effect.succeed(providers),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.sync(() => {
Effect.gen(function* () {
queries.push(input)
const stored = values.get("websearch:provider")
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
return result
}),
}),
@@ -73,7 +91,11 @@ const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
@@ -84,22 +106,19 @@ const form = Layer.succeed(
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
get: (key) => Effect.succeed(values.get(key)),
set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]),
[
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[Image.node, imagePassthrough],
],
),
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[Image.node, imagePassthrough],
]),
)
describe("WebSearchTool registration", () => {
@@ -202,4 +221,116 @@ describe("WebSearchTool registration", () => {
})
}),
)
it.effect("asks once and uses the default provider when web search is first enabled", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(values.get("websearch:provider")).toBe("exa")
expect(queries).toHaveLength(2)
expect(formRequests).toEqual([
{
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: "Allow web search via Exa",
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
},
])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(formRequests).toHaveLength(1)
expect(queries).toHaveLength(3)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
providerRequired = true
formResponses.push(
{ status: "answered", answer: { choice: "choose" } },
{ status: "answered", answer: { provider: "parallel" } },
)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
expect(values.get("websearch:provider")).toBe("parallel")
expect(queries).toHaveLength(2)
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
})
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "error" })
expect(values.get("websearch:provider")).toBe(false)
expect(queries).toHaveLength(1)
}),
)
})
+139 -3
View File
@@ -19,7 +19,7 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> {
@@ -115,6 +115,118 @@ export interface Page {
export type Slot = (props: Record<string, any>) => JSX.Element
export type ToastVariant = "info" | "success" | "warning" | "error"
export interface ToastOptions {
readonly title?: string
readonly message: string
readonly variant?: ToastVariant
readonly duration?: number
}
export interface Toast {
show(options: ToastOptions): void
}
export type AttentionWhen = "always" | "focused" | "blurred"
export type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
export type AttentionNotification =
| boolean
| {
readonly when?: AttentionWhen
}
export type AttentionSound =
| boolean
| {
readonly name?: AttentionSoundName
readonly volume?: number
readonly when?: AttentionWhen
}
export interface AttentionNotifyOptions {
readonly title?: string
readonly message: string
readonly notification?: AttentionNotification
readonly sound?: AttentionSound
}
export type AttentionNotifySkipReason =
| "attention_disabled"
| "empty_message"
| "blurred"
| "focused"
| "focus_unknown"
| "renderer_destroyed"
export interface AttentionNotifyResult {
readonly ok: boolean
readonly notification: boolean
readonly sound: boolean
readonly skipped?: AttentionNotifySkipReason
}
export interface Attention {
notify(options: AttentionNotifyOptions): Promise<AttentionNotifyResult>
}
export type DialogSize = "medium" | "large" | "xlarge"
export interface DialogOptions {
readonly size?: DialogSize
readonly centered?: boolean
}
export interface DialogAlertOptions {
readonly title: string
readonly message: string
}
export interface DialogConfirmOptions {
readonly title: string
readonly message: string
readonly label?: {
readonly confirm?: string
readonly cancel?: string
}
}
export interface DialogPromptOptions {
readonly title: string
readonly description?: string
readonly placeholder?: string
readonly value?: string
}
export interface DialogSelectOption<Value> {
readonly title: string
readonly value: Value
readonly description?: string
readonly category?: string
readonly disabled?: boolean
}
export interface DialogSelectOptions<Value> {
readonly title: string
readonly placeholder?: string
readonly options: readonly DialogSelectOption<Value>[]
readonly current?: Value
}
export interface Dialog {
/** Shows a dialog and returns a function that closes it. */
show(render: () => JSX.Element, onClose?: () => void): () => void
/** Sets the presentation options for this plugin's active dialog. */
set(options: DialogOptions): void
/** Closes this plugin's active dialog. */
clear(): void
alert(options: DialogAlertOptions): Promise<void>
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
prompt(options: DialogPromptOptions): Promise<string | undefined>
select<Value>(options: DialogSelectOptions<Value>): Promise<Value | undefined>
}
export interface KeymapCommand {
/** Stable command and config keybind identifier. Omit for an inline command. */
readonly id?: string
@@ -158,13 +270,32 @@ export interface KeymapLayer {
readonly bindings?: readonly string[]
}
export interface KeymapPending {
readonly key: string
readonly token?: string
}
export interface KeymapActive {
readonly key: string
readonly title?: string
readonly description?: string
readonly group?: string
readonly continues: boolean
}
export interface Keymap {
/** Creates a reactive keymap layer owned by the calling component. */
layer(input: () => KeymapLayer): void
/** Dispatches a reachable command by ID. */
dispatch(id: string, input?: string): void
/** Returns the formatted shortcut for a registered command. */
shortcut(id: string): string | undefined
/** Returns every formatted shortcut for a registered command. */
shortcuts(id: string): readonly string[]
/** Returns the currently reachable commands. Reactive when read in a Solid computation. */
commands(): readonly KeymapCommand[]
/** Returns the pending key sequence. Reactive when read in a Solid computation. */
pending(): readonly KeymapPending[]
/** Returns bindings reachable from the pending key sequence. Reactive when read in a Solid computation. */
active(): readonly KeymapActive[]
/** Controls mutually exclusive OpenCode input modes. */
readonly mode: {
/** Returns the active mode. */
@@ -175,6 +306,8 @@ export interface Keymap {
}
export interface UI {
readonly dialog: Dialog
readonly toast: Toast
readonly router: {
register(page: Page): () => void
navigate(destination: Destination): void
@@ -186,8 +319,11 @@ export interface UI {
export interface Context {
readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly renderer: CliRenderer
readonly client: OpenCodeClient
readonly data: Data
readonly attention: Attention
readonly theme: any
readonly keymap: Keymap
readonly ui: UI
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/theme",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/theme"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./tui": "./src/tui/index.ts",
"./tui/v1": "./src/tui/v1.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+77
View File
@@ -0,0 +1,77 @@
type OklchColor = {
l: number
c: number
h: number
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function hue(value: number) {
return ((value % 360) + 360) % 360
}
function linearToSrgb(value: number) {
if (value <= 0.0031308) return value * 12.92
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
}
function srgbToLinear(value: number) {
if (value <= 0.04045) return value / 12.92
return Math.pow((value + 0.055) / 1.055, 2.4)
}
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
const linearRed = srgbToLinear(red)
const linearGreen = srgbToLinear(green)
const linearBlue = srgbToLinear(blue)
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
const chroma = Math.sqrt(a * a + b * b)
const angle = Math.atan2(b, a) * (180 / Math.PI)
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
}
function oklchToRgb(color: OklchColor) {
const a = color.c * Math.cos((color.h * Math.PI) / 180)
const b = color.c * Math.sin((color.h * Math.PI) / 180)
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
const l = lRoot * lRoot * lRoot
const m = mRoot * mRoot * mRoot
const s = sRoot * sRoot * sRoot
return {
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
}
}
function fitOklch(color: OklchColor): OklchColor {
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
const rgb = oklchToRgb(base)
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
if (result) return result
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
const output = oklchToRgb(next)
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
}, undefined)
return fitted ?? { ...base, c: 0 }
}
export function oklchToHex(color: OklchColor) {
const rgb = oklchToRgb(fitOklch(color))
const toHex = (value: number) =>
Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, "0")
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
}
@@ -1,4 +1,4 @@
import type { HueName, ThemeDocument } from "./schema"
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
@@ -4,8 +4,8 @@ import type {
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
} from "./index.js"
import { ActionState } from "./schema.js"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
+57
View File
@@ -0,0 +1,57 @@
import type { ThemeTokensDefinition } from "./index.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
@@ -29,7 +29,7 @@ export {
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema"
} from "./schema.js"
export type {
Categorical,
@@ -42,7 +42,9 @@ export type {
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types"
export { DEFAULT_CATEGORICAL } from "./defaults"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
export { generateSyntax } from "./syntax.js"
@@ -1,8 +1,8 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import { expandTheme, expandTokens, mergeTheme } from "./expand"
import { fallback } from "./fallback"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
@@ -12,7 +12,7 @@ import {
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema"
} from "./schema.js"
import type {
ActionStateKey,
HueDefinition,
@@ -22,8 +22,8 @@ import type {
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
@@ -1,4 +1,4 @@
import { expandTheme, mergeTheme } from "./expand"
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
@@ -6,7 +6,7 @@ import type {
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index"
} from "./index.js"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
@@ -1,5 +1,5 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index"
import type { Mode, ResolvedThemeView } from "./index.js"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
@@ -9,7 +9,7 @@ import type {
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema"
} from "./schema.js"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeV1Json } from "../v1"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
import { HueStep } from "./schema"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
+76
View File
@@ -0,0 +1,76 @@
import type { RGBA } from "@opentui/core"
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
}
}
+1
View File
@@ -83,6 +83,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
+15 -12
View File
@@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
registerOpencodeSpinner()
@@ -346,18 +347,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
@@ -77,6 +77,7 @@ export function DevToolsBar() {
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
const turnTokens = () => config.data.debug?.turn_tokens ?? false
const verboseTurnTokens = () => turnTokens() === "verbose"
const offEscape = keymap.intercept(
"key",
@@ -380,6 +381,16 @@ export function DevToolsBar() {
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</box>
<For each={groups()}>
{(group) => (
+2 -2
View File
@@ -254,8 +254,8 @@ export const settings: Setting[] = [
category: "Debug",
path: ["debug", "turn_tokens"],
default: false,
values: [false, true],
labels: ["off", "on"],
values: [false, true, "verbose"],
labels: ["off", "on", "verbose"],
keywords: ["tokens", "usage", "debug"],
},
]
+3 -1
View File
@@ -159,7 +159,9 @@ export const Info = Schema.Struct({
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }),
turn_tokens: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("verbose")])).annotate({
description: "Show per-turn token usage diagnostics, optionally with tool call inputs",
}),
}),
).annotate({ description: "Debugging settings" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
+24
View File
@@ -0,0 +1,24 @@
import type { Attention } from "@opencode-ai/plugin/tui/context"
import { useRenderer } from "@opentui/solid"
import { createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createTuiAttention } from "../attention"
import { useConfig } from "../config"
const AttentionContext = createContext<Attention>()
export function AttentionProvider(props: ParentProps) {
const config = useConfig()
const attention = createTuiAttention({
renderer: useRenderer(),
config: config.data,
update: config.update,
})
onCleanup(() => attention.dispose())
return <AttentionContext.Provider value={attention}>{props.children}</AttentionContext.Provider>
}
export function useAttention() {
const attention = useContext(AttentionContext)
if (!attention) throw new Error("AttentionProvider is missing")
return attention
}
+6
View File
@@ -761,6 +761,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.ended":
setStore(
"session",
"pending",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.type !== "compaction"),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
+53 -8
View File
@@ -1,4 +1,4 @@
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context"
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import {
@@ -255,13 +255,19 @@ function useShortcuts() {
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
return new Map(
commands.map((id) => [
id,
{
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
},
]),
commands.map((id) => {
const current = bindings.get(id) ?? []
return [
id,
{
first: formatKeySequence(current[0]?.sequence, formatOptions(value.config)),
all: formatCommandBindings(current, formatOptions(value.config)),
list: current
.map((binding) => formatKeySequence(binding.sequence, formatOptions(value.config)))
.filter((shortcut): shortcut is string => shortcut !== undefined),
},
]
}),
)
})
return {
@@ -271,6 +277,9 @@ function useShortcuts() {
all(id: string) {
return shortcuts().get(id)?.all
},
list(id: string) {
return shortcuts().get(id)?.list ?? []
},
}
}
@@ -328,6 +337,41 @@ function useActiveKeys() {
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
}
function useState() {
const value = useValue()
const commands = useCommands()
const pending = usePendingSequence()
const active = useActiveKeys()
return {
commands,
pending: (): readonly KeymapPending[] =>
pending().map((item) => ({
key: formatKeySequence([item], formatOptions(value.config)) ?? "",
...(item.tokenName ? { token: item.tokenName } : {}),
})),
active: (): readonly KeymapActive[] =>
active().map((item) => ({
key:
formatKeySequence(
[{ stroke: item.stroke, display: item.display, tokenName: item.tokenName }],
formatOptions(value.config),
) ?? "",
...(typeof item.commandAttrs?.title === "string" ? { title: item.commandAttrs.title } : {}),
...(typeof item.bindingAttrs?.desc === "string"
? { description: item.bindingAttrs.desc }
: typeof item.commandAttrs?.desc === "string"
? { description: item.commandAttrs.desc }
: {}),
...(typeof item.commandAttrs?.category === "string"
? { group: item.commandAttrs.category }
: typeof item.bindingAttrs?.group === "string"
? { group: item.bindingAttrs.group }
: {}),
continues: item.continues,
})),
}
}
function useValue() {
const value = useContext(Context)
if (!value) throw new Error("Keymap.Provider is missing")
@@ -344,6 +388,7 @@ export const Keymap = {
useCommands,
usePendingSequence,
useActiveKeys,
useState,
} as const
function createMode(keymap: OpenTuiKeymap) {
+2 -4
View File
@@ -1,5 +1,6 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
import {
DEFAULT_THEMES,
addTheme,
@@ -14,12 +15,9 @@ import {
type Theme,
type ThemeDocumentSource,
} from "../theme"
import { generateSyntax } from "../theme/v2/syntax"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeDocument } from "../theme/v2/resolve"
import { themeModes } from "../theme/v2/select"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
+1 -2
View File
@@ -1,6 +1,5 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
import type { PluginRuntime } from "../plugin/runtime"
import Notifications from "./system/notifications"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
@@ -11,7 +10,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
}
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [Notifications, PluginManager, WhichKey]
return [PluginManager, WhichKey]
}
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
@@ -2,13 +2,11 @@ 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 { useTheme } from "../../context/theme"
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 { themeV2 } = useTheme()
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
@@ -16,13 +14,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
function Mcp(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
@@ -30,25 +27,31 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={themeV2.text.default}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
<span style={{ fg: themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}> </span>
<span
style={{
fg:
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
}}
>
{" "}
</span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={themeV2.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/status</text>
</box>
</Show>
)
}
function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const app = useTuiApp()
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
@@ -76,7 +79,7 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={themeV2.text.subdued}>{app.version}</text>
<text fg={props.context.theme.text.subdued}>{app.version}</text>
</box>
</box>
)
@@ -83,7 +83,7 @@ function diffSourceLabel(mode: DiffMode) {
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = useDialog()
const dialog = props.context.ui.dialog
const themeState = useTheme()
const themeV2 = themeState.themeV2
const params = () => {
@@ -141,7 +141,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const switchFocusShortcut = shortcut("diff.switch_focus")
const nextHunkShortcut = shortcut("diff.next_hunk")
const previousHunkShortcut = shortcut("diff.previous_hunk")
@@ -703,7 +703,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
})
const openSwitchDiffDialog = () => {
dialog.replace(() => (
dialog.show(() => (
<DialogSelect
title="Switch source"
skipFilter={true}
@@ -711,7 +711,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
current={mode()}
options={switchDiffOptions().map((option) => ({
...option,
onSelect(dialog) {
onSelect() {
dialog.clear()
props.context.ui.router.navigate({
type: "plugin",
@@ -729,8 +729,8 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
const openHelpDialog = () => {
dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
dialog.setSize("large")
dialog.show(() => <DiffViewerHelpDialog context={props.context} />)
dialog.set({ size: "large" })
}
props.context.keymap.layer(() => ({
@@ -952,7 +952,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme().contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{
shortcut: () => "q",
@@ -1,21 +1,19 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context"
import type { OpenCodeEvent } from "@opencode-ai/client"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:notifications"
type SessionError = Extract<OpenCodeEvent, { type: "session.error" }>["data"]["error"]
function notify(
api: TuiPluginApi,
context: Plugin.Context,
sessionID: string | undefined,
message: string,
sound: TuiAttentionSoundName,
sound: AttentionSoundName,
title?: string,
) {
const session = sessionID ? api.state.session.get(sessionID) : undefined
const session = sessionID ? context.data.session.get(sessionID) : undefined
const isSubagent = session?.parentID !== undefined
void api.attention.notify({
void context.attention.notify({
title: title ?? session?.title,
message,
notification: isSubagent ? false : { when: "blurred" },
@@ -32,101 +30,74 @@ function sessionErrorMessage(error: SessionError) {
return "Session error"
}
const tui: TuiPlugin = async (api) => {
const errored = new Set<string>()
const terminal = new Set<string>()
const forms = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
export default Plugin.define({
id: "opencode.notifications",
setup(context) {
const errored = new Set<string>()
const terminal = new Set<string>()
const forms = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
api.event.on("form.created", (event) => {
if (forms.has(event.data.form.id)) return
forms.add(event.data.form.id)
notify(
api,
event.data.form.sessionID,
"Input needs response",
"question",
event.data.form.title,
)
})
api.event.on("form.replied", (event) => {
forms.delete(event.data.id)
})
api.event.on("form.cancelled", (event) => {
forms.delete(event.data.id)
})
api.event.on("question.asked", (event) => {
if (questions.has(event.data.id)) return
questions.add(event.data.id)
notify(api, event.data.sessionID, "Question needs input", "question")
})
api.event.on("question.replied", (event) => {
questions.delete(event.data.requestID)
})
api.event.on("question.rejected", (event) => {
questions.delete(event.data.requestID)
})
api.event.on("permission.asked", (event) => {
if (permissions.has(event.data.id)) return
permissions.add(event.data.id)
notify(api, event.data.sessionID, "Permission needs input", "permission")
})
api.event.on("permission.replied", (event) => {
permissions.delete(event.data.requestID)
})
const started = (sessionID: string) => {
errored.delete(sessionID)
terminal.delete(sessionID)
}
const ended = (sessionID: string) => {
if (terminal.has(sessionID)) return
terminal.add(sessionID)
if (errored.has(sessionID)) {
const started = (sessionID: string) => {
errored.delete(sessionID)
return
terminal.delete(sessionID)
}
const ended = (sessionID: string) => {
if (terminal.has(sessionID)) return
terminal.add(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
}
const session = context.data.session.get(sessionID)
notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
const session = api.state.session.get(sessionID)
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
const dispose = [
context.data.on("form.created", (event) => {
if (forms.has(event.data.form.id)) return
forms.add(event.data.form.id)
notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title)
}),
context.data.on("form.replied", (event) => forms.delete(event.data.id)),
context.data.on("form.cancelled", (event) => forms.delete(event.data.id)),
context.data.on("question.asked", (event) => {
if (questions.has(event.data.id)) return
questions.add(event.data.id)
notify(context, event.data.sessionID, "Question needs input", "question")
}),
context.data.on("question.replied", (event) => questions.delete(event.data.requestID)),
context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)),
context.data.on("permission.asked", (event) => {
if (permissions.has(event.data.id)) return
permissions.add(event.data.id)
notify(context, event.data.sessionID, "Permission needs input", "permission")
}),
context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)),
context.data.on("session.execution.started", (event) => started(event.data.sessionID)),
context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)),
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
context.data.on("session.execution.failed", (event) => {
const sessionID = event.data.sessionID
if (errored.has(sessionID)) {
ended(sessionID)
return
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
ended(sessionID)
}),
context.data.on("session.error", (event) => {
const sessionID = event.data.sessionID
if (!sessionID) return
if (context.data.session.status(sessionID) !== "running") return
if (errored.has(sessionID)) return
errored.add(sessionID)
notify(context, sessionID, sessionErrorMessage(event.data.error), "error")
}),
]
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
api.event.on("session.execution.failed", (event) => {
const sessionID = event.data.sessionID
if (errored.has(sessionID)) {
ended(sessionID)
return
}
errored.add(sessionID)
notify(api, sessionID, event.data.error.message, "error")
ended(sessionID)
})
api.event.on("session.error", (event) => {
const sessionID = event.data.sessionID
if (!sessionID) return
if (api.state.session.status(sessionID)?.type !== "busy") return
if (errored.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
return () => dispose.reverse().forEach((cleanup) => cleanup())
},
})
+2
View File
@@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
@@ -12,6 +13,7 @@ export const builtins = [
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
Scrap,
DiffViewer,
]
+141 -2
View File
@@ -13,8 +13,9 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, Slot, 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"
import { useClient } from "../context/client"
import { useData } from "../context/data"
@@ -22,6 +23,14 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { builtins } from "./builtins"
export interface PackageResolver {
@@ -57,14 +66,20 @@ type Registration = {
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const renderer = useRenderer()
const client = useClient()
const data = useData()
const route = useRoute()
const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle()
const location = useLocation()
const theme = useTheme()
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -82,20 +97,144 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
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?.()
})
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
}
},
set(options) {
if (!activeDialog) return
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
if (!activeDialog) return
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
let settled = false
const done = () => {
if (settled) return
settled = true
resolve()
}
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
let settled = false
const done = (result: boolean | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
let settled = false
const done = (result: string | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
let settled = false
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
const toastApi: Toast = {
show(options) {
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
options: item.options ?? {},
get location() {
return location.current
},
renderer,
client: client.api,
data,
attention,
theme: theme.themeV2,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcut: shortcuts.get,
shortcuts: shortcuts.list,
commands: keymapState.commands,
pending: keymapState.pending,
active: keymapState.active,
mode: keymap.mode,
},
ui: {
dialog: dialogApi,
toast: toastApi,
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
+1 -5
View File
@@ -746,11 +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()!)}
{answerField()!.required ? " (required)" : ""}
{multi() ? " (select all that apply)" : ""}
</text>
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
</box>
<Show when={textual() ? answerField()!.key : undefined} keyed>
<box paddingLeft={1}>
+53 -2
View File
@@ -1104,6 +1104,7 @@ function TurnTokenUsage(props: {
}) {
const config = useConfig()
const { themeV2 } = useTheme()
const verbose = () => config.data.debug?.turn_tokens === "verbose"
const steps = createMemo(() => {
let previousCache = props.previousCache
return props.messageIDs.flatMap((messageID) => {
@@ -1123,6 +1124,7 @@ function TurnTokenUsage(props: {
return [
{
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
tools: verbose() ? message.content.filter((part) => part.type === "tool") : [],
newTokens,
cached: message.tokens.cache.read,
total,
@@ -1138,7 +1140,7 @@ function TurnTokenUsage(props: {
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
}))
return (
<Show when={config.data.debug?.turn_tokens === true && steps().length > 0}>
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
@@ -1161,7 +1163,7 @@ function TurnTokenUsage(props: {
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={themeV2.text.subdued}>
<text fg={verbose() && item.finish === "tool-call" ? undefined : themeV2.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
@@ -1171,6 +1173,7 @@ function TurnTokenUsage(props: {
{" "}
{item.total.toLocaleString().padStart(columns().total)}
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={themeV2.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
@@ -1184,6 +1187,54 @@ function TurnTokenUsage(props: {
)
}
function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
const { themeV2 } = useTheme()
const nameWidth = () => Math.max(0, ...props.tools.map((tool) => tool.name.length)) + 2
return (
<Show when={props.tools.length > 0}>
<box paddingLeft={2} flexDirection="column">
<For each={props.tools}>
{(tool) => (
<box flexDirection="row">
<text
width={nameWidth()}
flexShrink={0}
fg={themeV2.text.subdued}
attributes={TextAttributes.BOLD}
>
{tool.name}
</text>
<text
fg={themeV2.text.subdued}
attributes={TextAttributes.DIM}
wrapMode="word"
flexGrow={1}
minWidth={0}
>
{turnTokenToolSummary(tool)}
</text>
</box>
)}
</For>
</box>
</Show>
)
}
function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
const data = tool.state.input
if (typeof data === "string") return data
const primaryKey = ["command", "id", "pattern", "url", "query", "path", "description", "code"].find(
(key) => key in data,
)
const input = Object.entries(data).filter(([, value]) =>
["string", "number", "boolean"].includes(typeof value),
)
const primary = input.find(([key]) => key === primaryKey)?.[1]
const details = input.filter(([key]) => key !== primaryKey).map(([key, value]) => `${key}: ${String(value)}`)
return [primary === undefined ? "" : String(primary), ...details].filter(Boolean).join(" ")
}
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
+1 -1
View File
@@ -41,7 +41,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
const config = useConfig()
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const turnTokens = () => config.data.debug?.turn_tokens === true
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)
function reduce() {
const messages = data.session.message.list(sessionID())
@@ -1,6 +1,6 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedThemeView } from "./index"
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
return {
+1 -3
View File
@@ -1,9 +1,7 @@
import { Schema } from "effect"
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
import { ThemeDocument } from "./v2/schema"
import { migrateV1 } from "./v2/v1-migrate"
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
export { resolveThemeDocument, type ThemeDocument }
+2 -74
View File
@@ -1,4 +1,5 @@
import { RGBA, SyntaxStyle } from "@opentui/core"
import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1"
import aura from "./assets/aura.json" with { type: "json" }
import ayu from "./assets/ayu.json" with { type: "json" }
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
@@ -33,80 +34,7 @@ import vercel from "./assets/vercel.json" with { type: "json" }
import vesper from "./assets/vesper.json" with { type: "json" }
import zenburn from "./assets/zenburn.json" with { type: "json" }
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
aura,
-57
View File
@@ -1,57 +0,0 @@
import type { ThemeTokensDefinition } from "./index"
import { ActionVariant, FeedbackKind } from "./schema"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
-52
View File
@@ -1,52 +0,0 @@
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "./component"
import type { ContextKey, Mode, ResolvedTheme } from "./index"
type ThemeRuntime = {
readonly resolved: Accessor<ResolvedTheme>
readonly mode: Accessor<Mode>
readonly component: ComponentTheme
}
const ThemeContext = createContext<ThemeRuntime>()
export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) {
const resolved = () => props.theme
const mode = () => props.mode ?? "light"
return createComponent(ThemeContext.Provider, {
value: { resolved, mode, component: createComponentTheme(resolved, mode) },
get children() {
return props.children
},
})
}
export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
const parent = runtime()
const context = () => {
const value = parent.resolved().contexts[props.context]
if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
return value
}
context()
return createComponent(ThemeContext.Provider, {
value: { resolved: parent.resolved, mode: parent.mode, component: createComponentTheme(context, parent.mode) },
get children() {
return props.children
},
})
}
export function useTheme() {
return runtime().component
}
export function useResolvedTheme() {
return runtime().resolved
}
function runtime() {
const context = useContext(ThemeContext)
if (!context) throw new Error("Theme context must be used within a ThemeProvider")
return context
}
+6 -3
View File
@@ -11,7 +11,10 @@ export type DialogConfirmProps = {
message: string
onConfirm?: () => void
onCancel?: () => void
label?: string
label?: {
confirm?: string
cancel?: string
}
}
export type DialogConfirmResult = boolean | undefined
@@ -81,7 +84,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
}}
>
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
{Locale.titlecase(props.label?.[key] ?? key)}
</text>
</box>
)}
@@ -91,7 +94,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
@@ -1,27 +1,17 @@
import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client"
import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
type Session = NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
type Session = { id: string; title: string; parentID?: string }
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const notifications: AttentionNotifyOptions[] = []
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
const session = (
id: string,
title: string,
parentID?: string,
): Session => ({
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
slug: id,
projectID: "project",
directory: "/workspace",
...(parentID && { parentID }),
version: "0.0.0-test",
time: { created: 0, updated: 0 },
})
const sessions: Record<string, Session> = {
session: session("session", "Demo session"),
@@ -30,41 +20,35 @@ async function setup() {
timeout: session("timeout", "Timeout session"),
}
await Notifications.tui(
createTuiPluginApi({
attention: {
async notify(input) {
notifications.push(input)
return { ok: true, notification: true, sound: true }
},
await Notifications.setup({
attention: {
async notify(input: AttentionNotifyOptions) {
notifications.push(input)
return { ok: true, notification: true, sound: true }
},
event: {
on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (event: OpenCodeEvent) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
handlers.set(
type,
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
)
}
},
},
data: {
on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (event: OpenCodeEvent) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
handlers.set(
type,
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
)
}
},
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
status: () => ({ type: "busy" }),
},
session: {
get: (sessionID: string) => sessions[sessionID],
status: () => "running" as const,
},
}),
undefined,
{} as never,
)
},
} as unknown as Context)
return {
notifications,
@@ -139,31 +123,31 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
}
}
const questionNotification: TuiAttentionNotifyInput = {
const questionNotification: AttentionNotifyOptions = {
title: "Demo session",
message: "Question needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const formNotification: TuiAttentionNotifyInput = {
const formNotification: AttentionNotifyOptions = {
title: "Input requested",
message: "Input needs response",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const titledFormNotification: TuiAttentionNotifyInput = {
const titledFormNotification: AttentionNotifyOptions = {
...formNotification,
title: "Confirm deployment",
}
const globalFormNotification: TuiAttentionNotifyInput = {
const globalFormNotification: AttentionNotifyOptions = {
...formNotification,
title: "demo-mcp is requesting input",
}
const permissionNotification: TuiAttentionNotifyInput = {
const permissionNotification: AttentionNotifyOptions = {
title: "Demo session",
message: "Permission needs input",
notification: { when: "blurred" },
+11 -5
View File
@@ -171,19 +171,25 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
},
dispatch() {},
shortcut: () => undefined,
shortcuts: () => [],
mode: { current: () => "base", push: () => () => {} },
},
ui: {
dialog: {
show: () => () => {},
set() {},
clear() {},
},
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {}
return () => {}
},
navigate(destination: Destination) {
current = destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
current =
destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
},
current: () => current,
},
@@ -2,10 +2,9 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import { selectTheme } from "../../../src/theme/v2/select"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
+26
View File
@@ -76,6 +76,32 @@ test("formats navigation keys as arrows", async () => {
}
})
test("returns every formatted command shortcut", async () => {
let read = () => [] as readonly string[]
function Harness() {
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
commands: [{ id: "demo.command", bind: "x,y", run() {} }],
}))
read = () => shortcuts.list("demo.command")
return <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
expect(read()).toEqual(["x", "y"])
} finally {
app.renderer.destroy()
}
})
test("global commands stay reachable when the mode changes", async () => {
const calls: string[] = []
let exercise = () => {}
+2 -5
View File
@@ -1,11 +1,8 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { createComponentTheme } from "../../../src/theme/v2/component"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import { resolveTheme } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
import type { ContextKey } from "../../../src/theme/v2"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui"
import { createComponentTheme } from "../../../src/theme/component"
test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
+8 -4
View File
@@ -1,10 +1,14 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
DEFAULT_THEME,
resolveTheme,
resolveThemeDocument,
selectTheme,
type Mode,
type ThemeDefinition,
} from "@opencode-ai/theme/tui"
import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import type { Mode, ThemeDefinition } from "../../../src/theme/v2"
import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
const light = selectTheme(DEFAULT_THEME, "light")
const dark = selectTheme(DEFAULT_THEME, "dark")
+9 -2
View File
@@ -1,6 +1,13 @@
import { expect, test } from "bun:test"
import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
import {
selectTheme,
selectThemeMode,
supportsThemeMode,
themeModes,
type HueDefinition,
type ThemeDefinition,
type ThemeDocument,
} from "@opencode-ai/theme/tui"
const hue = {} as HueDefinition
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
const text = {
default: "$hue.neutral.900",
@@ -1,9 +1,13 @@
import { expect, test } from "bun:test"
import {
DEFAULT_CATEGORICAL,
DEFAULT_THEME,
migrateV1,
resolveThemeDocument,
selectThemeMode,
themeModes,
} from "@opencode-ai/theme/tui"
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
import { resolveThemeDocument } from "../../../src/theme/v2/resolve"
import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
test("migrates resolved V1 modes into V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
+1
View File
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
+2 -2
View File
@@ -2,7 +2,7 @@
import { Schema, SchemaAST } from "effect"
import { format } from "prettier"
import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
const root = requireObject(ThemeDefinition.ast)
@@ -64,7 +64,7 @@ ${JSON.stringify(example, null, 2)}
## Token reference
This reference is generated from the Effect schema in
\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update
\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
this section through \`bun run generate\`.
### Hue tokens
@@ -30,7 +30,7 @@
## Token reference
This reference is generated from the Effect schema in
`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update
`@opencode-ai/theme/tui`. Changes to the runtime schema update
this section through `bun run generate`.
### Hue tokens
+3
View File
@@ -38,6 +38,9 @@ await prepareReleaseFiles()
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`