Compare commits

..

2 Commits

Author SHA1 Message Date
James Long e784dd1196 chore: scope session sorting to tui 2026-07-29 16:42:40 +00:00
James Long 8137d189b0 fix(session): sort by latest activity 2026-07-29 16:36:19 +00:00
92 changed files with 677 additions and 1370 deletions
-3
View File
@@ -600,7 +600,6 @@
"zod": "catalog:",
},
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -612,14 +611,12 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
-118
View File
@@ -1,118 +0,0 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
generated migration must not drop the table.
## Truncate
Truncate these pre-launch V2 tables before applying schema changes:
- `event`
- `event_sequence`
- `session_message`
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
and `part` rows rather than retaining its pre-launch V2 contents.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
before migrated history. The `event` table remains empty.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Verification
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
- Preserved rows and encoded values remain unchanged.
- Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
- Dropped tables no longer exist.
- New tables exist and are empty.
- The final schema has no ungenerated changes.
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: { boundary: { type: "through" } },
body: {},
})
})
+5 -19
View File
@@ -6,7 +6,6 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
@@ -57,39 +56,26 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
if (left.path > right.path) return 1
return 0
})
const ranked = rankListings(listings)
const pinned = new Set(
namespaceEntries
.filter((entry) => entry.pinned)
.map((entry) => listings.find((listing) => listing.path === entry.path))
.filter((listing) => listing !== undefined),
)
return {
name,
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
selectionIndex: 0,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
}
})
const active = new Set(namespaces)
let remaining =
budget -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
let remaining = budget
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
namespace.selectionIndex += 1
remaining -= candidate.cost
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
}
}
+1 -3
View File
@@ -6,9 +6,7 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
## Search
+7 -16
View File
@@ -33,7 +33,7 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
@@ -138,14 +138,7 @@ export const create = (
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
}
function runtime(
@@ -156,7 +149,11 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
const path = qualifiedName(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const path =
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -167,12 +164,6 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
-2
View File
@@ -2,7 +2,6 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,7 +10,6 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
-3
View File
@@ -296,9 +296,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
shell: {
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
-4
View File
@@ -326,10 +326,6 @@ export function fromPromise(plugin: Plugin) {
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
+6
View File
@@ -657,6 +657,12 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
+19 -46
View File
@@ -12,8 +12,6 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -35,7 +33,6 @@ type Active = {
done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void>
timeout?: (duration: number) => Effect.Effect<void>
kill?: () => Effect.Effect<void>
}
/**
@@ -48,10 +45,7 @@ type Active = {
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E, R>
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -60,8 +54,6 @@ export interface Interface {
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
// Stops a running command while retaining its terminal state and output.
readonly kill: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
@@ -76,7 +68,6 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
@@ -123,12 +114,6 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
yield* removeSession(id)
})
const kill = Effect.fn("Shell.kill")(function* (id: Shell.ID) {
const session = yield* require(id)
if (session.kill) yield* session.kill()
return yield* Deferred.await(session.done)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
@@ -187,34 +172,24 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const cwd = input.cwd ?? location.directory
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
command: input.command,
cwd,
shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
@@ -228,9 +203,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
ChildProcess.make(shell, args, {
cwd,
env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
@@ -322,9 +297,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
)
})
session.kill = () => finish("exited", undefined, handle.kill().pipe(Effect.catch(() => Effect.void)))
yield* session.timeout(invocation.timeout)
yield* session.timeout(input.timeout)
runFork(
handle.exitCode.pipe(
@@ -346,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, kill, output, remove })
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
@@ -354,7 +327,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
})
}
+32 -38
View File
@@ -146,40 +146,34 @@ export const Plugin = {
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [invocation.command],
save: [invocation.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
@@ -204,7 +198,7 @@ export const Plugin = {
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `Command exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
@@ -224,19 +218,19 @@ export const Plugin = {
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.kill(info.id).pipe(Effect.ignore)),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.callID,
type: name,
title: info.command,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -250,7 +244,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
-2
View File
@@ -16,7 +16,6 @@ describe("CodeMode", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
@@ -28,7 +27,6 @@ describe("CodeMode", () => {
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
+1 -30
View File
@@ -2,11 +2,10 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
})
const lookup = entry(
@@ -47,30 +46,6 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [
entry("alpha.first", "First", undefined, true),
entry("beta.second", "Second", undefined, true),
]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
"alpha.first",
"beta.second",
])
})
test("spends the budget remaining after pinned tools on unpinned tools", () => {
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
@@ -92,7 +67,6 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
expect(instructions).not.toContain("surrounding top-level agent tools")
})
@@ -102,9 +76,6 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
@@ -44,9 +44,6 @@ describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
-21
View File
@@ -42,25 +42,4 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("mutates shell creation input", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (event) =>
Effect.sync(() => {
event.command = "echo changed"
}),
)
const event = {
command: "echo original",
cwd: "/tmp",
timeout: 0,
shell: "/bin/sh",
env: {},
}
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
expect(event.command).toBe("echo changed")
}),
)
})
-3
View File
@@ -86,9 +86,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
shell: overrides.shell ?? {
hook: () => Effect.die("unused shell.hook"),
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
+24
View File
@@ -171,6 +171,30 @@ describe("Session.create", () => {
}),
)
it.effect("orders sessions by their latest prompt", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const active = yield* session.create({ location, title: "active" })
const newer = yield* session.create({ location, title: "newer" })
yield* db
.update(SessionTable)
.set({ time_created: -2, time_updated: -2 })
.where(eq(SessionTable.id, active.id))
.run()
yield* db
.update(SessionTable)
.set({ time_created: -1, time_updated: -1 })
.where(eq(SessionTable.id, newer.id))
.run()
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -67,7 +67,7 @@ const transform = (
) =>
service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: options ?? tool.options }),
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
),
)
+1 -1
View File
@@ -231,7 +231,7 @@ const permission = Layer.succeed(
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: options ?? tool.options }),
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
),
)
const echo = Layer.effectDiscard(
+1 -1
View File
@@ -22,7 +22,7 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
[
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
-38
View File
@@ -480,44 +480,6 @@ describe("ShellTool", () => {
),
)
it.live("retains partial output when a foreground command is interrupted", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const shell = yield* Shell.Service
const scope = yield* Scope.Scope
const waiting = yield* executeTool(
registry,
call({ command: steadyProgressCommand }, "call-interrupt-output"),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
const waitForShell = (remaining = 1000): Effect.Effect<ShellSchema.ID, Error> =>
Effect.gen(function* () {
const job = yield* jobs.get("call-interrupt-output")
const shellID = job?.metadata?.shellID
if (typeof shellID === "string") return ShellSchema.ID.make(shellID)
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* waitForShell(remaining - 1)
})
const id = yield* waitForShell()
yield* Effect.sleep(Duration.millis(100))
yield* Fiber.interrupt(waiting)
expect((yield* shell.get(id)).status).toBe("exited")
expect((yield* shell.output(id)).output).toContain("steady")
yield* shell.remove(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("returns the shell id for a background command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
-5
View File
@@ -30,16 +30,12 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opencode-ai/theme": {
"optional": true
},
"@opentui/core": {
"optional": true
},
@@ -54,7 +50,6 @@
}
},
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
-2
View File
@@ -10,7 +10,6 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -27,7 +26,6 @@ export interface Context {
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
-17
View File
@@ -1,17 +0,0 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
-2
View File
@@ -9,7 +9,6 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -26,7 +25,6 @@ export interface Context {
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
-17
View File
@@ -1,17 +0,0 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
+1 -13
View File
@@ -19,19 +19,8 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
}
interface LocationCollection<Value> {
list(location?: LocationRef): Value[] | undefined
@@ -357,8 +346,7 @@ export interface Context {
readonly client: OpenCodeClient
readonly data: Data
readonly attention: Attention
readonly theme: ResolvedTheme
readonly theme: any
readonly keymap: Keymap
readonly storage: Storage
readonly ui: UI
}
+2 -13
View File
@@ -19,23 +19,12 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
interface BaseOptions {
export interface Options {
readonly namespace?: string
readonly codemode?: boolean
readonly permission?: string
}
export type Options = BaseOptions &
(
| {
readonly codemode?: true
readonly pinned?: boolean
}
| {
readonly codemode: boolean
readonly pinned?: never
}
)
export type ValueSchema<A = unknown> =
| Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
+1 -2
View File
@@ -33,7 +33,6 @@ export {
export type {
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
@@ -41,7 +40,7 @@ export type {
ResolvedActionState,
ResolvedFormfieldState,
ResolvedTheme,
ResolvedThemeTokens,
ResolvedThemeView,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
+17 -19
View File
@@ -15,12 +15,11 @@ import {
} from "./schema.js"
import type {
ActionStateKey,
ContextName,
HueDefinition,
HueScale,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeTokens,
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
@@ -65,17 +64,16 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
const contexts = Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, override]) => {
const contextual = contextualize(base, override as ThemeTokensDefinition)
return [key, resolveView(contextual, hue, categorical, hueSteps)]
}),
)
return { ...resolved, contextual } as ResolvedTheme
return { ...resolved, contexts } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -134,15 +132,15 @@ function contextualActions(
function resolveView(
definition: ThemeTokensDefinition,
hue: ResolvedThemeTokens["hue"],
categorical: ResolvedThemeTokens["categorical"],
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
hue: ResolvedThemeView["hue"],
categorical: ResolvedThemeView["categorical"],
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
): ResolvedThemeView {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
}
function compileHueSteps(hue: ResolvedThemeTokens["hue"]): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
@@ -203,7 +201,7 @@ function resolveHue(definition: HueDefinition) {
return Object.fromEntries(
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
) as ResolvedThemeTokens["hue"]
) as ResolvedThemeView["hue"]
}
function createResolver(source: Record<string, unknown>) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeTokens } from "./index.js"
import type { Mode, ResolvedThemeView } from "./index.js"
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown
+4 -5
View File
@@ -7,6 +7,7 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema.js"
@@ -19,7 +20,7 @@ export type Categorical = readonly HueScale[]
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeTokens = {
export type ResolvedThemeView = {
readonly hue: Hue
readonly categorical: Categorical
readonly source: (color: RGBA) => HueSource | undefined
@@ -62,8 +63,6 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
export type ResolvedTheme = ResolvedThemeView & {
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
}
-6
View File
@@ -20,7 +20,6 @@
"./context/exit": "./src/context/exit.tsx",
"./context/log": "./src/context/log.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/storage": "./src/context/storage.tsx",
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./theme/discovery": "./src/theme/discovery.ts",
@@ -72,11 +71,6 @@
"bun": "./src/editor-zed-sqlite.bun.ts",
"node": "./src/editor-zed-sqlite.node.ts",
"default": "./src/editor-zed-sqlite.bun.ts"
},
"#runtime-plugin-support": {
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
}
},
"dependencies": {
+91 -94
View File
@@ -90,7 +90,6 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
registerOpencodeSpinner()
@@ -304,106 +303,104 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
worktree: global.data + "/worktree",
}}
>
<StorageProvider>
<TuiLifecycleProvider
<TuiLifecycleProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiTerminalEnvironmentProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
}}
>
<TuiTerminalEnvironmentProvider
<TuiStartupProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme, useThemes } from "../context/theme"
import { useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@@ -71,7 +71,7 @@ extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const themes = useThemes()
const theme = useTheme("elevated")
const theme = themes.contextual("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
+6 -6
View File
@@ -36,7 +36,7 @@ export function DevToolsBar() {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const elevatedTheme = themes.contextual("elevated")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
return (
<box
@@ -461,7 +461,7 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
@@ -470,7 +470,7 @@ function PanelTitle(props: ParentProps) {
}
function Row(props: { label: string; value: string }) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
@@ -481,7 +481,7 @@ function Row(props: { label: string; value: string }) {
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -101,14 +101,6 @@ export const settings: Setting[] = [
values: [false, true],
labels: ["off", "on"],
},
{
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "cwd",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
{
title: "Layout",
category: "Diffs",
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
@@ -64,7 +64,7 @@ export function DialogIntegration(
) {
const data = useData()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
@@ -303,8 +303,8 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
@@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
return (
@@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -561,7 +561,7 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
+5 -5
View File
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@@ -20,7 +20,7 @@ function statusError(status: McpServer["status"]) {
}
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
@@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
+2 -2
View File
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@@ -38,7 +38,7 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useTheme, useThemes } from "../context/theme"
import { useThemes } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -22,7 +22,7 @@ export function DialogSessionList() {
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = themes.contextual("elevated")
const mode = themes.mode
const client = useClient()
const local = useLocal()
+2 -2
View File
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
+2 -2
View File
@@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
@@ -8,7 +8,7 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useTheme("overlay")
const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
+3 -18
View File
@@ -216,34 +216,19 @@ export function Prompt(props: PromptProps) {
})
Keymap.createLayer(() => ({
mode: "global",
enabled: props.sessionID !== undefined,
commands: [
{
id: "session.cd",
title: "Change working directory",
slash: { name: "cd", arguments: true },
run: async (input) => {
const sessionID = props.sessionID
if (!sessionID) return
if (!input?.trim()) {
toast.show({ message: "Directory is required", variant: "error" })
return
}
const sessionID = props.sessionID
if (!sessionID) {
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return undefined
})
if (!location) return
move.setDirectory(location.directory, location.directory !== location.project.directory)
currentLocation.set(location)
return
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
@@ -158,10 +158,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setCreating(false)
}
function setDirectory(directory: string, subdirectory: boolean) {
setDestination({ type: "directory", directory, subdirectory })
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
@@ -180,7 +176,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
pending,
pendingNew,
progress,
setDirectory,
startSubmit,
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
return (
<box
+48 -134
View File
@@ -1,5 +1,5 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@@ -7,24 +7,20 @@ import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
seedSessionTabMotion,
sessionTabOverflowWidth,
SESSION_TAB_OVERFLOW_WIDTH,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
status(sessionID: string): SessionTabsStatus
}
@@ -36,11 +32,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
@@ -79,38 +73,21 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
let signature = ""
let total = 0
// createComputed runs before render effects, so seeded widths are visible on the first frame
// of a membership change instead of flashing the final layout.
createComputed(() => {
createEffect(() => {
const next = targets()
const nextSignature = identity()
const changed = Boolean(signature) && signature !== nextSignature
const resized = Boolean(total) && total !== layout().total
const previous = signature
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
signature = nextSignature
total = layout().total
if (!changed && !resized) return motion.animate(next)
// Identity-stable total changes are terminal resizes and still jump.
if (!changed) return motion.jump(next)
const seeded = seedSessionTabMotion(
previous.split(":"),
layout().tabs.map((tab) => tab.sessionID),
untrack(motion.value),
next,
)
if (!seeded) return motion.jump(next)
motion.jump(seeded)
if (reset) return motion.jump(next)
motion.animate(next)
})
const activeIndex = createMemo(() => layout().tabs.findIndex((tab) => tab.sessionID === activeID()))
const visuals = createMemo(() => {
const current = signature === identity() && total === layout().total ? motion.value() : targets()
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
const active = activeIndex()
const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0)
// Absorb only rounding slack; membership animations leave a real gap while widths grow into place.
if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
@@ -123,21 +100,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
)
})
// Map an absolute pointer column to the items index of the visible slot beneath it.
const slotAt = (x: number) => {
if (!strip) return undefined
const stripX = x - strip.screenX
let edge = layout().before > 0 ? sessionTabOverflowWidth(layout().before) : 0
for (const [index, width] of layout().widths.entries()) {
edge += width
if (stripX < edge) return layout().before + index
}
return layout().before + layout().widths.length - 1
}
return (
<box
ref={(element) => (strip = element)}
height={1}
flexShrink={0}
position="relative"
@@ -163,7 +127,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
>
<Show when={layout().before > 0}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().before}
</text>
</Show>
@@ -174,79 +138,38 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const width = () => visuals().get(tab.sessionID)?.width ?? 1
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
const dragged = () => dragging() === tab.sessionID
const background = createMemo(() => {
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
// A dragged tab lifts to full selected elevation while it is held.
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
})
const pulseColor = () => tint(background(), theme.text.default, 0.45)
const feedbackColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return undefined
const background = () => {
const base =
hovered() === tab.sessionID && !selected()
? theme.background.action.primary.hovered
: theme.background.default
return tint(base, theme.raise(theme.background.surface.offset), selection())
}
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const pulseBackground = () => background()
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
const title = () => tab.title ?? "Untitled session"
const [outgoingTitle, setOutgoingTitle] = createSignal<string>()
const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) })
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, title())
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const availableTitleWidth = () => Math.max(1, width() - 3)
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const outgoingTitleParts = createMemo(() => {
const outgoing = outgoingTitle()
if (outgoing === undefined) return undefined
return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth()))
})
// A new title wipes in from the left over the previous one.
const displayedParts = createMemo(() => {
const front = wipe.value().front
const parts = visibleTitleParts()
const previous = outgoingTitleParts()
if (previous === undefined || front >= 1) return parts
const cut = Math.round(front * Math.max(parts.length, previous.length))
return [...parts.slice(0, cut), ...previous.slice(cut)]
})
const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH))
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
const numberColor = () => {
const feedback = feedbackColor()
if (feedback) return feedback
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
return tint(base, accent(), activity())
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@@ -255,48 +178,40 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
onMouseDragEnd={release}
onMouseUp={() => tabs.select(tab.sessionID)}
>
<TabPulse
enabled={animations()}
active={status().busy && !status().attention}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
color={pulseColor()}
glowColor={glowColor()}
glowColor={accent()}
completionColor={accent()}
backgroundColor={background()}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1} selectable={false}>
{" "}
<text width={1}> </text>
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tabNumber()}
</text>
<text
width={availableTitleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
<Show
when={titleFades()}
fallback={
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitle()}
</text>
}
>
<Show when={titleFades()} fallback={displayedParts().join("")}>
{displayedParts().slice(0, -FADE_WIDTH).join("")}
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
background(),
pulseBackground(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
@@ -305,15 +220,14 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
</span>
)}
</For>
</Show>
</text>
</text>
</Show>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -327,8 +241,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
</For>
<Show when={layout().after > 0}>
<text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.subdued} selectable={false}>
{" " + layout().after}
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().after}
</text>
</Show>
</box>
@@ -1,9 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
let wait: NodeJS.Timeout | undefined
+50 -137
View File
@@ -6,7 +6,6 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
active?: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color?: RGBA
glowColor?: RGBA
completionColor?: RGBA
@@ -21,15 +20,6 @@ const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 500
const EDGE_FLASH_OPACITY = 0.1
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
const GLOW_IGNITION_ATTACK = 0.3
const GLOW_FADE_OUT = 200
const GLOW_BREATHE_PERIOD = 3_600
const GLOW_BREATHE_RISE = 0.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
@@ -43,15 +33,10 @@ const coast = (value: number) => {
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
return (value - ramp / 2) / (1 - ramp)
}
const fadeOut = (progress: number) => 1 - smootherstep(progress)
/** Rise to peak over the attack fraction, then settle to rest over the remainder. */
const attackDecay = (progress: number, attack: number, peak: number, rest: number) =>
progress < attack
? peak * smootherstep(clamp(progress / attack))
: peak - (peak - rest) * smootherstep(clamp((progress - attack) / (1 - attack)))
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
export const glowIgnitionLevel = (progress: number) =>
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
@@ -76,64 +61,19 @@ export function blendTabPulseColor(
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
}
/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
class Envelope {
private clock: number | undefined
private scale = 1
constructor(
private duration: number,
private shape: (progress: number) => number,
) {}
start(scale = 1) {
if (this.clock !== undefined) return
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
advance(delta: number) {
if (this.clock === undefined) return
this.clock += delta
if (this.clock >= this.duration) this.clock = undefined
}
get active() {
return this.clock !== undefined
}
level() {
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
}
}
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _glow: boolean
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private breatheClock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private completionPending = false
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity)
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
private renderColor = RGBA.fromInts(0, 0, 0)
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
@@ -144,36 +84,21 @@ class TabPulseRenderable extends Renderable {
this._active = active
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
private get breathing() {
return this._enabled && this._glow && this._breathe
}
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
private glowLevel() {
if (!this._glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.fadeClock = undefined
this.completionClock = undefined
this.completionPending = false
this.breatheClock = 0
this.live = false
} else if (this._active || this.breathing) {
} else if (this._active) {
this.live = true
}
this.requestRender()
@@ -184,16 +109,15 @@ class TabPulseRenderable extends Renderable {
this._active = value
if (!this._enabled) return
if (value) {
this.runFade.stop()
this.completionPulse.stop()
this.fadeClock = undefined
this.completionClock = undefined
this.completionPending = false
this.live = true
} else {
this.runFade.start()
this.fadeClock = 0
this.completionPending = true
this.live = true
}
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
}
@@ -201,38 +125,20 @@ class TabPulseRenderable extends Renderable {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionPulse.stop()
this.completionClock = undefined
this.completionPending = false
}
if (value && this.completionPending) {
this.completionClock = 0
this.completionPending = false
if (this._enabled) {
this.completionPulse.start()
this.live = true
}
this.live = this._enabled
}
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
this._glow = value
this.ignition.stop()
this.breatheClock = 0
if (this._enabled && value) {
this.glowOff.stop()
this.ignition.start()
this.live = true
}
this.requestRender()
}
set breathe(value: boolean) {
if (value === this._breathe) return
this._breathe = value
this.breatheClock = 0
if (this.breathing) this.live = true
this.requestRender()
}
@@ -262,52 +168,61 @@ class TabPulseRenderable extends Renderable {
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
if (this._active || this.runFade.active) this.clock += deltaTime
if (this.breathing) this.breatheClock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
if (this.fadeClock !== undefined) {
this.fadeClock += deltaTime
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
}
if (this.completionPending) {
if (this._complete) {
this.completionClock = 0
this.completionPending = false
this.completionPulse.start()
} else if (!this.runFade.active) {
} else if (this.fadeClock === undefined) {
this.completionPending = false
}
}
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
if (this.completionClock !== undefined) {
this.completionClock += deltaTime
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
}
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
const completion = this.completionPulse.level() * COMPLETION_OPACITY
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
const glowLevel = this.glowLevel()
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return
const runningOpacity = !this._enabled
? 0
: this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
!this._enabled || this.completionClock === undefined
? 0
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
for (let index = 0; index < this.width; index++) {
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
running === 0
? 0
: Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
running
const intensity = Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
const running = intensity * 0.14 * runningOpacity
const completion = completionOpacity * 0.18
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._completionColor,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
Math.max(sweep, flash),
glow,
running,
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
@@ -328,7 +243,6 @@ export function TabPulse(props: {
active: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
glowColor?: RGBA
completionColor?: RGBA
@@ -343,7 +257,6 @@ export function TabPulse(props: {
active={props.active}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
completionColor={props.completionColor ?? props.color}
+2 -18
View File
@@ -2,10 +2,8 @@ export * as Config from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Schema } from "effect"
import { createContext, onCleanup, type JSX, useContext } from "solid-js"
import { createContext, type JSX, useContext } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { watch } from "fs"
import path from "path"
import { TuiKeybind } from "./keybind"
export interface Interface {
@@ -129,9 +127,6 @@ export const Info = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share session tabs globally or keep a separate set for each working directory",
}),
}),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(
@@ -240,23 +235,12 @@ export function ConfigProvider(props: {
}) {
const [config, setConfig] = createStore(props.config)
const host = props.service
const apply = (info: Info) => setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
const update = async (update: (draft: any) => void) => {
if (!host) throw new Error("Config updates are not available")
const info = await host.update(update)
apply(info)
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
return info
}
let reload = Promise.resolve()
const watcher = host?.path
? watch(path.dirname(host.path), () => {
reload = reload
.then(() => host.get())
.then(apply)
.catch(() => {})
})
: undefined
onCleanup(() => watcher?.close())
return (
<ConfigContext.Provider value={{ data: config, path: host?.path, update }}>{props.children}</ConfigContext.Provider>
)
+2
View File
@@ -404,6 +404,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
}
case "session.input.admitted":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "time", "updated", event.created)
addPending({
id: event.data.inputID,
sessionID: event.data.sessionID,
+28 -3
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js"
import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
@@ -287,7 +287,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
@@ -299,7 +306,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => {
if (!isModelValid(model)) return
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
const exists = modelStore.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
@@ -448,6 +462,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = createSession()
createEffect(() => {
const value = agent.current()
if (!value?.model) return
if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return
toast.show({
variant: "warning",
message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`,
duration: 3000,
})
})
const result = {
model,
agent,
+4 -46
View File
@@ -17,8 +17,7 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b
export const SESSION_TAB_WIDTH = 22
export const SESSION_TAB_MAX_WIDTH = 32
export const SESSION_TAB_MIN_WIDTH = 8
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "12 " and " 12".
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
export const SESSION_TAB_OVERFLOW_WIDTH = 3
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
@@ -36,15 +35,6 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
}
}
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
const to = Math.max(0, Math.min(tabs.length - 1, index))
if (from === -1 || from === to) return tabs
const next = tabs.filter((tab) => tab.sessionID !== sessionID)
next.splice(to, 0, tabs[from])
return next
}
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
@@ -77,38 +67,6 @@ export function moveSessionTabHistory(
return { history: { ...history, index: target.index }, sessionID: target.sessionID }
}
export type SessionTabMotionValues = {
widths: number[]
selections: number[]
activities: number[]
}
/**
* Seed width motion for a visible-tab membership change: retained tabs keep their current animated
* values and first-seen tabs grow in from zero width. Returns undefined when nothing is retained,
* meaning the window was fully replaced and the strip should jump.
*/
export function seedSessionTabMotion(
previous: readonly string[],
ids: readonly string[],
current: SessionTabMotionValues,
next: SessionTabMotionValues,
): SessionTabMotionValues | undefined {
const positions = ids.map((id) => previous.indexOf(id))
if (positions.every((position) => position === -1)) return undefined
return {
widths: positions.map((position, index) =>
position === -1 ? 0 : (current.widths[position] ?? next.widths[index] ?? 0),
),
selections: positions.map(
(position, index) => (position === -1 ? next.selections[index] : current.selections[position]) ?? 0,
),
activities: positions.map(
(position, index) => (position === -1 ? next.activities[index] : current.activities[position]) ?? 0,
),
}
}
export function adaptiveSessionTabLayout(
tabs: readonly SessionTab[],
active: string | undefined,
@@ -144,8 +102,8 @@ export function adaptiveSessionTabLayout(
tabs.length - count,
)
const markers =
(nextStart > 0 ? sessionTabOverflowWidth(nextStart) : 0) +
(nextStart + count < tabs.length ? sessionTabOverflowWidth(tabs.length - nextStart - count) : 0)
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
const nextCount = fit(available - markers)
if (nextCount === count || attempts === 0) return { count, start: nextStart }
return solve(nextCount, nextStart, attempts - 1)
@@ -156,7 +114,7 @@ export function adaptiveSessionTabLayout(
const after = tabs.length - solved.start - solved.count
const contentWidth = Math.max(
1,
available - (before > 0 ? sessionTabOverflowWidth(before) : 0) - (after > 0 ? sessionTabOverflowWidth(after) : 0),
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
)
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
+132 -84
View File
@@ -1,16 +1,18 @@
import { createEffect, onCleanup } from "solid-js"
import { batch, createEffect, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import path from "path"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useData } from "./data"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { useConfig } from "../config"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { isRecord } from "../util/record"
import {
closeSessionTab,
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
@@ -19,18 +21,11 @@ import {
type SessionTabUnread,
} from "./session-tabs-model"
type TabsState = {
type PersistedState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
}
type PersistedState = {
global: TabsState
cwd: Record<string, TabsState>
}
const empty = (): TabsState => ({ tabs: [], unread: {} })
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
init: () => {
@@ -38,32 +33,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const data = useData()
const event = useEvent()
const config = useConfig().data
const paths = useTuiPaths()
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const enabled = () => config.tabs?.enabled ?? false
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
initial: {
global: empty(),
cwd: {},
},
key: "sessionID",
const state: {
pending: boolean
saving: boolean
snapshot: string
value?: PersistedState
} = { pending: false, saving: false, snapshot: "" }
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
ready: false,
tabs: [],
unread: {},
})
const fallback = empty()
let history: SessionTabHistory = { entries: [], index: -1 }
function state() {
if (config.tabs?.scope === "global") return store.global
return store.cwd[paths.cwd] ?? fallback
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "cwd"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
)
}
const root = (sessionID: string) => data.session.root(sessionID)
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const status = (sessionID: string) => {
@@ -71,7 +55,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: state().unread[session],
unread: store.unread[session],
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
@@ -79,54 +63,125 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
}
function save() {
if (!store.ready) {
state.pending = true
return
}
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
const snapshot = JSON.stringify(value)
if (snapshot === state.snapshot && !state.saving) return
state.value = value
state.pending = true
flush()
}
function flush() {
if (state.saving || !state.pending || !state.value) return
const value = state.value
const snapshot = JSON.stringify(value)
state.pending = false
if (snapshot === state.snapshot) return
state.saving = true
void writeJsonAtomic(filePath, value)
.then(() => {
state.snapshot = snapshot
})
.catch(() => {})
.finally(() => {
state.saving = false
flush()
})
}
function open(sessionID: string) {
const session = root(sessionID)
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
if (next === store.tabs) return { sessionID: session, changed: false }
setStore("tabs", reconcile(next))
return { sessionID: session, changed: true }
}
function clearUnread(sessionID: string) {
const session = root(sessionID)
if (!store.unread[session]) return false
setStore(
"unread",
produce((draft) => {
delete draft[session]
}),
)
return true
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
const session = root(sessionID)
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
if (state().unread[session] === unread) return
update((draft) => {
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
draft.unread[session] = unread
})
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
if (store.unread[session] === unread) return
setStore("unread", session, unread)
save()
}
readJson<unknown>(filePath)
.then((value) => {
if (!isRecord(value)) return
const persisted = value
if (Array.isArray(persisted.tabs))
setStore(
"tabs",
persisted.tabs.flatMap((tab) => {
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
}),
)
if (persisted.unread && typeof persisted.unread === "object")
setStore(
"unread",
Object.fromEntries(
Object.entries(persisted.unread).filter(
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
),
),
)
})
.catch(() => {})
.finally(() => {
setStore("ready", true)
if (state.pending) save()
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
})
createEffect(() => {
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const title = data.session.get(sessionID)?.title
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
delete draft.unread[sessionID]
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
const routeSessionID = route.data.sessionID
batch(() => {
const opened = open(routeSessionID)
history = recordSessionTabHistory(history, opened.sessionID)
const changed = clearUnread(opened.sessionID)
if (opened.changed || changed) untrack(save)
})
})
createEffect(() => {
if (!enabled()) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
if (!enabled() || !store.ready) return
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
draft.tabs = draft.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
batch(() => {
setStore("tabs", reconcile(next))
setStore("unread", reconcile(unread))
})
save()
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
@@ -145,25 +200,26 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
const closed = closeSessionTab(state().tabs, target)
if (closed.tabs.length === state().tabs.length) return
const closed = closeSessionTab(store.tabs, target)
if (closed.tabs.length === store.tabs.length) return
const selected = navigate && current() === target
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
: { history, sessionID: undefined }
const next = previous.sessionID ?? closed.next
history = previous.history
update((draft) => {
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
batch(() => {
setStore("tabs", reconcile(closed.tabs))
clearUnread(target)
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
save()
}
return {
enabled,
tabs() {
return state().tabs
return store.tabs
},
current,
status,
@@ -175,29 +231,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
const previous = state().tabs.at(-1)
const previous = store.tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
return
}
remove(target, true)
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
})
},
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
const tab = cycleSessionTab(store.tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
)
@@ -205,13 +253,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
history(direction: 1 | -1) {
if (!enabled()) return
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
history = next.history
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
const tab = store.tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
}
-97
View File
@@ -1,97 +0,0 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
import { writeJsonAtomic } from "../util/persistence"
import { useTuiApp, useTuiPaths } from "./runtime"
type Options<Value extends object> = {
readonly initial: Value
/** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */
readonly key?: string
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
}
function clone<Value extends object>(value: Value) {
const json = JSON.stringify(value)
if (json === undefined) throw new TypeError("Storage values must be JSON-compatible objects")
const result = JSON.parse(json) as Value
if (typeof result !== "object" || result === null) throw new TypeError("Storage values must be objects")
return result as Value
}
function segment(value: string) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) || value === "." || value === "..")
throw new TypeError(`Invalid storage segment: ${value}`)
return value
}
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
const storage: Storage = {
store<Value extends object>(key: string, options: Options<Value>) {
const file = path.join(directory, segment(key) + ".json")
const existing = entries.get(file)
if (existing) return existing.value as Entry<Value>
const load = () => {
try {
return clone(JSON.parse(readFileSync(file, "utf8")) as Value)
} catch {
return clone(options.initial)
}
}
const [store, setStore] = createStore(load())
const merge = (next: Value) => reconcile(next, { key: options.key })
const reload = () => batch(() => setStore(merge(load())))
const update = (mutation: (draft: Value) => void) =>
Flock.withLock(
file,
async () => {
const draft = load()
mutation(draft)
const next = clone(draft)
await writeJsonAtomic(file, next)
batch(() => setStore(merge(next)))
},
{ dir: locks },
)
const entry = [store, update] as const
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
return {
storage,
close: () => watcher.close(),
}
}
const Context = createContext<Storage>()
export function StorageProvider(props: ParentProps) {
const result = createStorage(useTuiPaths().state, useTuiApp().channel)
onCleanup(result.close)
return <Context.Provider value={result.storage}>{props.children}</Context.Provider>
}
export function useStorage() {
const storage = useContext(Context)
if (!storage) throw new Error("StorageProvider is missing")
return storage
}
+16 -25
View File
@@ -1,12 +1,6 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import {
generateSyntax,
resolveThemeDocument,
themeModes,
type ResolvedTheme,
type ContextName,
} from "@opencode-ai/theme/tui"
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
import {
DEFAULT_THEMES,
addTheme,
@@ -101,9 +95,10 @@ type State = {
ready: boolean
}
type ContextName = "elevated" | "overlay"
type Themes = {
current: ComponentTheme
currentTokens: Accessor<ResolvedTheme>
contextual(context: ContextName): ComponentTheme
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
@@ -120,12 +115,6 @@ type Themes = {
readonly ready: boolean
}
type ThemeContextValue = {
current: ComponentTheme["contextual"][ContextName]
themes: Themes
readonly ready: boolean
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
@@ -138,7 +127,7 @@ subscribeThemes((themes) => setStore("themes", themes))
const themeContext = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer()
const configState = useConfig()
const config = configState.data
@@ -320,14 +309,21 @@ const themeContext = createSimpleContext({
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
}
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextsV2[context]
}
const service: Themes = {
current,
currentTokens: valuesV2,
currentSyntax,
contextual,
get selected() {
return store.active
},
@@ -372,20 +368,15 @@ const themeContext = createSimpleContext({
export function useThemes() {
return themeContext.use().themes
}
export function useTheme(): ComponentTheme
export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName]
export function useTheme(context?: ContextName) {
const value = themeContext.use()
return context ? value.themes.current.contextual[context] : value.current
export function useTheme() {
return themeContext.use().current
}
export const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
const value = themeContext.use()
const themes = useThemes()
return (
<themeContext.context.Provider
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
>
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
{props.children}
</themeContext.context.Provider>
)
@@ -18,7 +18,6 @@ import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { useThemes } from "../../context/theme"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -84,7 +83,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = props.context.theme
const currentSyntax = useThemes().currentSyntax
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@@ -836,7 +834,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={currentSyntax()}
syntaxStyle={theme.syntaxStyle()}
showLineNumbers={true}
width="100%"
wrapMode="char"
@@ -943,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{
@@ -2,7 +2,6 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
@@ -45,8 +44,8 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
const [tabs, setTabs] = createSignal<SessionTab[]>(FIXTURE_TABS.slice(0, 6))
const elevatedTheme = props.context.theme.contextual("elevated")
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
const [active, setActive] = createSignal<string | undefined>("fixture-2")
const [animations, setAnimations] = createSignal(true)
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
@@ -62,9 +61,6 @@ function Scrap(props: { context: Plugin.Context }) {
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
move(sessionID, index) {
setTabs((current) => moveSessionTab(current, sessionID, index))
},
select(sessionID) {
setActive(sessionID)
},
@@ -86,7 +82,7 @@ function Scrap(props: { context: Plugin.Context }) {
const items = tabs()
if (items.length === 0) return
const index = items.findIndex((tab) => tab.sessionID === active())
controller.select(items[(index + direction + items.length) % items.length].sessionID)
controller.select(items[(index + direction + items.length) % items.length]!.sessionID)
}
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
const sessionID = active()
+25 -10
View File
@@ -16,7 +16,7 @@ import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import "#runtime-plugin-support"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
@@ -24,7 +24,7 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useThemes } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -32,11 +32,12 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins"
import { discoverTuiPlugins } from "./discovery"
ensureRuntimePluginSupport()
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
@@ -89,11 +90,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const app = useTuiApp()
const paths = useTuiPaths()
const location = useLocation()
const theme = useTheme()
const themes = useThemes()
const pluginTheme = createPluginTheme(theme, themes)
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const storage = useStorage()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -222,9 +224,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api,
data,
attention,
get theme() {
return themes.currentTokens()
},
theme: pluginTheme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@@ -234,9 +234,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
active: keymapState.active,
mode: keymap.mode,
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,
@@ -528,6 +525,24 @@ function isPlugin(value: unknown): value is Plugin.Definition {
)
}
type PluginTheme = ReturnType<typeof useTheme> & {
contextual(context: "elevated" | "overlay"): PluginTheme
syntaxStyle(): ReturnType<ReturnType<typeof useThemes>["currentSyntax"]>
}
export function createPluginTheme(theme: ReturnType<typeof useTheme>, themes: ReturnType<typeof useThemes>): PluginTheme {
return new Proxy(theme as PluginTheme, {
get(target, property, receiver) {
if (property === "contextual") {
return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes)
}
if (property === "syntaxStyle") return themes.currentSyntax
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver)
return Reflect.get(themes, property, themes)
},
})
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")
@@ -1,3 +0,0 @@
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
ensureRuntimePluginSupport()
@@ -1 +0,0 @@
// OpenTUI's runtime plugin transform uses Bun's plugin API. Node loads precompiled plugins without it.
@@ -1,7 +1,7 @@
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../../../context/theme"
import { useThemes } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border"
import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab"
@@ -39,7 +39,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,
+2 -2
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { useTheme, useThemes } from "../../context/theme"
import { useThemes } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
@@ -45,7 +45,7 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = themes.contextual("elevated")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
+5 -5
View File
@@ -1494,7 +1494,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const local = useLocal()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -1691,7 +1691,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@@ -1764,7 +1764,7 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
return (
@@ -1792,7 +1792,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const themes = useThemes()
const theme = useTheme("elevated")
const theme = themes.contextual("elevated")
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
@@ -1869,7 +1869,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -297,7 +297,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
Keymap.createLayer(() => ({
@@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
+2 -2
View File
@@ -1,6 +1,6 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
@@ -8,7 +8,7 @@ import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const data = useData()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -1,7 +1,7 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
@@ -42,7 +42,7 @@ export function SubagentFooter() {
}
})
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
+17 -24
View File
@@ -1,48 +1,41 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
const create = (view: Accessor<ResolvedThemeTokens>) => ({
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
return {
get hue() {
return view().hue
return current().hue
},
get categorical() {
return view().categorical
return current().categorical
},
get text() {
return view().text
return current().text
},
get background() {
return view().background
return current().background
},
get border() {
return view().border
return current().border
},
get scrollbar() {
return view().scrollbar
return current().scrollbar
},
get diff() {
return view().diff
return current().diff
},
get syntax() {
return view().syntax
return current().syntax
},
get markdown() {
return view().markdown
return current().markdown
},
source: (color: RGBA) => view().source(color),
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
})
return Object.assign(create(current), {
contextual: {
elevated: create(() => current().contextual.elevated),
overlay: create(() => current().contextual.overlay),
},
})
source: (color: RGBA) => current().source(color),
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
}
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+2 -2
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export type DialogAlertProps = {
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
Keymap.createLayer(() => ({
mode: "modal",
+2 -2
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For, Show } from "solid-js"
@@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
+2 -2
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const close = () => {
props.onClose?.()
+2 -2
View File
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "./dialog"
export function DialogHelp() {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
+2 -2
View File
@@ -1,6 +1,6 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
+3 -3
View File
@@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -96,7 +96,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = themes.contextual("elevated")
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -773,7 +773,7 @@ function Option(props: {
activeColor?: RGBA
onMouseOver?: () => void
}) {
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const text = createMemo(() => {
if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
if (props.muted && (props.active || props.current)) return theme.text.subdued
+2 -2
View File
@@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
let dismiss = false
+2 -2
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const theme = useTheme("overlay")
const theme = useThemes().contextual("overlay")
const dimensions = useTerminalDimensions()
return (
@@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
import {
@@ -12,6 +12,7 @@ import {
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createPluginTheme } from "../../../src/plugin/context"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -129,7 +130,7 @@ describe("DiffViewerFileTree", () => {
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {
@@ -11,7 +11,7 @@ import type {
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@@ -21,6 +21,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createPluginTheme } from "../../../src/plugin/context"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -157,7 +158,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
}, createEventStream())
function Harness() {
let theme: ReturnType<ReturnType<typeof useThemes>["currentTokens"]>
let theme: ReturnType<typeof createPluginTheme>
const context = {
options: {},
client: createApi(transport.fetch),
@@ -206,7 +207,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
void diffViewerPlugin.setup(context)
function Content() {
theme = useThemes().currentTokens()
theme = createPluginTheme(useTheme(), useThemes())
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
@@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@@ -129,11 +129,9 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
} as const
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
let explicit: ReturnType<typeof useTheme> | undefined
function ContextProbe() {
theme = useTheme()
explicit = useTheme("elevated")
return <text>{theme.text.default.toString()}</text>
}
@@ -162,11 +160,9 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted")
if (!theme) throw new Error("Contextual theme is not mounted")
if (!explicit) throw new Error("Explicit contextual theme is not mounted")
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme).toBe(explicit)
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
expect(theme.text.default).toBe(themes.contextual("elevated").text.default)
expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default)
} finally {
app.renderer.destroy()
}
+1 -13
View File
@@ -1,11 +1,6 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
unreadGlowIntensity,
} from "../../src/component/tab-pulse"
import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("completion pulse rises quickly and fades over the remaining duration", () => {
@@ -16,13 +11,6 @@ test("completion pulse rises quickly and fades over the remaining duration", ()
expect(completionPulseOpacity(1)).toBe(0)
})
test("glow ignition overshoots the resting level and settles back to it", () => {
expect(glowIgnitionLevel(0)).toBe(0)
expect(glowIgnitionLevel(0.3)).toBeCloseTo(1.5)
expect(glowIgnitionLevel(0.6)).toBeGreaterThan(1)
expect(glowIgnitionLevel(1)).toBe(1)
})
test("unread glow peaks behind the tab number and fades to the normal background", () => {
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
@@ -3,65 +3,13 @@ import {
adaptiveSessionTabLayout,
closeSessionTab,
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
expect(moveSessionTab(tabs, "c", -5).map((tab) => tab.sessionID)).toEqual(["c", "a", "b"])
expect(moveSessionTab(tabs, "b", 99).map((tab) => tab.sessionID)).toEqual(["a", "c", "b"])
expect(moveSessionTab(tabs, "b", 1)).toBe(tabs)
expect(moveSessionTab(tabs, "missing", 0)).toBe(tabs)
})
test("open seeding keeps survivors and grows the new tab from zero", () => {
const seeded = seedSessionTabMotion(
["a", "b"],
["a", "b", "c"],
{ widths: [35, 35], selections: [1, 0], activities: [0, 1] },
{ widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 1, 0] },
)
expect(seeded).toEqual({ widths: [35, 35, 0], selections: [1, 0, 0], activities: [0, 1, 0] })
})
test("close seeding keeps survivors at their current animated widths", () => {
const seeded = seedSessionTabMotion(
["a", "b", "c"],
["a", "c"],
{ widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 0, 1] },
{ widths: [35, 35], selections: [1, 0], activities: [0, 1] },
)
expect(seeded).toEqual({ widths: [24, 23], selections: [1, 0], activities: [0, 1] })
})
test("window shifts keep retained tabs and grow revealed ones", () => {
const seeded = seedSessionTabMotion(
["a", "b", "c"],
["b", "c", "d"],
{ widths: [22, 8, 8], selections: [1, 0, 0], activities: [0, 0, 0] },
{ widths: [8, 8, 22], selections: [0, 0, 1], activities: [0, 0, 0] },
)
expect(seeded).toEqual({ widths: [8, 8, 0], selections: [0, 0, 1], activities: [0, 0, 0] })
})
test("fully replaced windows jump instead of seeding", () => {
const values = { widths: [22], selections: [1], activities: [0] }
expect(seedSessionTabMotion(["a"], ["z"], values, values)).toBeUndefined()
})
test("overflow markers reserve room for a gap beside their digits", () => {
expect(sessionTabOverflowWidth(5)).toBe(3)
expect(sessionTabOverflowWidth(12)).toBe(4)
})
test("opens each session once and refreshes its title", () => {
const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
expect(tabs).toEqual([{ sessionID: "a", title: "New" }])
+17 -19
View File
@@ -1,18 +1,17 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
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")))
const [mode, setMode] = createSignal<"light" | "dark">("light")
const theme = createComponentTheme(resolved, mode)
const [context, setContext] = createSignal<ContextName>()
const current = () => {
const name = context()
return name ? theme.contextual[name] : theme
}
const [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => {
const key = context()
return key ? (resolved().contexts[key] ?? resolved()) : resolved()
}, mode)
expect(theme.text.default).toBe(resolved().text.default)
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
@@ -51,21 +50,20 @@ test("provides reactive properties, states, contexts, and color operations", ()
expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
expect(theme.diff.text.added).toBe(resolved().diff.text.added)
setContext("elevated")
expect("contexts" in current()).toBeFalse()
expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
expect(current().background.action.primary.focused).toBe(
resolved().contextual.elevated.background.action.primary.focused,
setContext("@context:elevated")
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.background.action.primary.focused).toBe(
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
)
expect(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(current().background.formfield.selected).toBe(
resolved().contextual.elevated.background.formfield.selected,
expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(theme.background.formfield.selected).toBe(
resolved().contexts["@context:elevated"]!.background.formfield.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("dark")
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600])
})
+15 -15
View File
@@ -37,7 +37,7 @@ test("validates and resolves categorical hues in configured order", () => {
expect(theme.categorical[0]).toBe(theme.hue.accent)
expect(theme.categorical[1]).toBe(theme.hue.red)
expect(theme.categorical[2]).toBe(theme.hue.interactive)
expect(theme.contextual.elevated.categorical).toBe(theme.categorical)
expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
})
@@ -65,29 +65,29 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
lightTheme.hue.interactive[500],
)
expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
lightTheme.hue.interactive[500],
)
expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
darkTheme.hue.interactive[400],
)
expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
})
test("resolves base hue aliases and rejects circular hue aliases", () => {
@@ -228,8 +228,8 @@ test("resolves elevated hover surfaces from direct colors", () => {
"light",
)
expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
})
test("resolves transparent colors", () => {
@@ -271,7 +271,7 @@ test("context overrides rewire semantic references and apply state precedence",
},
})
const theme = resolveTheme(definition)
const overlay = theme.contextual.elevated
const overlay = theme.contexts["@context:elevated"]!
expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
@@ -43,11 +43,11 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
test("references generated hues from matching token colors", () => {