mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93c733c5f2 | |||
| 8df03aa1bc | |||
| 8ce850e142 | |||
| 3f30203b72 |
@@ -10,7 +10,7 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
|
||||
+32
-1
@@ -3,8 +3,9 @@
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||
import { RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||
@@ -20,6 +21,10 @@ const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
@@ -200,6 +205,32 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
|
||||
## Testing
|
||||
|
||||
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
|
||||
the requests sent by code under test:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const result = yield* program
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
```
|
||||
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
@@ -15,11 +15,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Effect.Effect<ImageResponse, AIError> =>
|
||||
): Effect.Effect<ImageResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
}) as Effect.Effect<ImageResponse, AIError>
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { LLMClient } from "./route/client"
|
||||
import { LLMClient, Service } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -151,10 +151,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
*/
|
||||
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
|
||||
options: GenerateObjectOptions<S, SelectedLanguageModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
|
||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
|
||||
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
|
||||
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
|
||||
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
|
||||
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
|
||||
if ("schema" in options) {
|
||||
const { schema, ...rest } = options
|
||||
|
||||
@@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, AIError>
|
||||
)
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
}) as Effect.Effect<LLMResponse, AIError>
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
Image,
|
||||
ImageClient,
|
||||
ImageInput,
|
||||
ImageModel,
|
||||
type ImageModelOptions,
|
||||
@@ -7,8 +9,13 @@ import {
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../src"
|
||||
import type { Service } from "../src/image-client"
|
||||
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
type GoogleLikeOptions = {
|
||||
readonly aspectRatio?: "1:1" | "16:9"
|
||||
readonly imageSize?: "1K" | "2K"
|
||||
@@ -146,6 +153,9 @@ const request = Image.request({
|
||||
})
|
||||
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||
void typedRequest
|
||||
const generated = ImageClient.generate(request)
|
||||
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, Service>>
|
||||
void (true satisfies GenerateRequirements)
|
||||
|
||||
// @ts-expect-error Image requests no longer expose a common count option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
type LLMClientService,
|
||||
type LanguageModel,
|
||||
type LanguageModelProviderOptions,
|
||||
type ProviderOptions,
|
||||
} from "../src"
|
||||
import { OpenAIChat } from "../src/protocols"
|
||||
|
||||
interface ExampleOptions {
|
||||
@@ -15,9 +21,19 @@ const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://example.com/v1" } })
|
||||
.model<ExampleProviderOptions>({ id: "example" })
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
|
||||
|
||||
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
|
||||
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
|
||||
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
|
||||
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
@@ -25,12 +41,20 @@ LLM.request({
|
||||
providerOptions: { example: { mode: "slow" } },
|
||||
})
|
||||
|
||||
LLM.generateObject({
|
||||
const generatedObject = LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
schema: Schema.Struct({ answer: Schema.String }),
|
||||
providerOptions: { example: { mode: "thorough" } },
|
||||
})
|
||||
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
|
||||
|
||||
const generatedDynamicObject = LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
jsonSchema: { type: "object" },
|
||||
})
|
||||
type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, LLMClientService>>
|
||||
|
||||
LLM.generateObject({
|
||||
model,
|
||||
@@ -44,4 +68,8 @@ declare const generic: LanguageModel
|
||||
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
|
||||
|
||||
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
|
||||
void options
|
||||
void (options satisfies LanguageModelProviderOptions<typeof model>)
|
||||
void (true satisfies GenerateRequirements)
|
||||
void (true satisfies StreamClientRequirements)
|
||||
void (true satisfies GenerateObjectRequirements)
|
||||
void (true satisfies GenerateDynamicObjectRequirements)
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Plugin = define({
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.map((content) => (content ? decode(file, content) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
|
||||
@@ -191,31 +191,22 @@ export const layer = Layer.effect(
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
// Object identity preserves registry provenance when a hook moves a definition to a new key.
|
||||
const toolNamesByDefinition = new Map<object, string>()
|
||||
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
||||
const hookTools = Object.fromEntries(
|
||||
toolDefinitions.map((tool) => {
|
||||
const definition = { description: tool.description, input: { ...tool.inputSchema } }
|
||||
toolNamesByDefinition.set(definition, tool.name)
|
||||
return [tool.name, definition]
|
||||
}),
|
||||
)
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
tools: hookTools,
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
||||
),
|
||||
})
|
||||
const executableTools = new Map<string, string>()
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
const registeredName = toolNamesByDefinition.get(tool)
|
||||
const registered = registeredName ? toolsByName.get(registeredName) : undefined
|
||||
if (!registered || !registeredName) return []
|
||||
executableTools.set(name, registeredName)
|
||||
return [{ ...registered, name, description: tool.description, inputSchema: tool.input }]
|
||||
const registered = toolsByName.get(name)
|
||||
return registered
|
||||
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
|
||||
: []
|
||||
})
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -268,14 +259,10 @@ export const layer = Layer.effect(
|
||||
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
const registeredName = executableTools.get(executeInput.call.name)
|
||||
if (!registeredName && toolsByName.has(executeInput.call.name))
|
||||
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
|
||||
return tools
|
||||
.execute({
|
||||
...executeInput,
|
||||
call: { ...executeInput.call, name: registeredName ?? executeInput.call.name },
|
||||
})
|
||||
.execute(executeInput)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -266,6 +266,7 @@ permissions:
|
||||
Use native v2 fields.`,
|
||||
)
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "empty.md"), "")
|
||||
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
|
||||
})
|
||||
const agents = yield* Agent.Service
|
||||
@@ -295,6 +296,7 @@ Use native v2 fields.`,
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -887,42 +887,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a tool renamed by a session context hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
const tool = event.tools.echo
|
||||
if (!tool) return
|
||||
event.tools.renamed_echo = tool
|
||||
delete event.tools.echo
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Use the renamed tool")
|
||||
yield* TestLLM.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(executions).toEqual(["renamed"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use the renamed tool" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-renamed",
|
||||
state: { status: "completed", content: [{ type: "text", text: "renamed" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -301,6 +301,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("rejects a workdir that stops being a directory during approval", () =>
|
||||
@@ -472,6 +473,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
@@ -538,7 +540,7 @@ describe("ShellTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
@@ -557,6 +559,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("returns the shell id for a background command", () =>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type SessionTab,
|
||||
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, unreadGlowIntensity } from "./tab-pulse"
|
||||
@@ -554,21 +554,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glowColor = () => feedbackColor() ?? accent()
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
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
|
||||
if (previous === NEW_SESSION_TAB_TITLE) {
|
||||
setOutgoingTitle(undefined)
|
||||
wipe.jump({ front: 1 })
|
||||
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
|
||||
@@ -577,20 +562,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
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 titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
)
|
||||
@@ -603,8 +574,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
|
||||
const position = index - (displayedParts().length - FADE_WIDTH)
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||
@@ -677,8 +648,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
attributes={bold()}
|
||||
>
|
||||
<Show when={glows() || titleFades()} fallback={displayedParts().join("")}>
|
||||
<For each={displayedParts()}>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user