Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline bf112cb49c fix(tui): open rendered links on click 2026-08-14 17:54:09 +00:00
Kit Langton 1d44d56d9c fix(tui): use semantic form tokens (#42599) 2026-08-14 17:49:06 +00:00
10 changed files with 82 additions and 43 deletions
+7
View File
@@ -19,6 +19,13 @@
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
## TUI Theme Tokens
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
+1 -3
View File
@@ -65,10 +65,8 @@ const appArchive = await buildAppArchive(Script.channel)
// text that ships inside the bundle.
async function assertTextImportsInlined(bundlePath: string) {
const bundle = await readFile(bundlePath, "utf8")
const snapshotMarker = (await readFile("../core/src/models-dev/snapshot.gz.base64.txt", "utf8")).slice(0, 64)
const markers = [
{ marker: snapshotMarker, source: "compressed models-dev snapshot" },
{ marker: '"zhipuai"', source: "uncompressed models-dev snapshot", forbidden: true },
{ marker: '"zhipuai"', source: "models-dev snapshot" },
{ marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
{ marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
]
@@ -20,9 +20,5 @@ if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length
process.exit(1)
}
const target = new URL("../src/models-dev/snapshot.txt", import.meta.url)
const compressed = new URL("../src/models-dev/snapshot.gz.base64.txt", import.meta.url)
const gzip = Bun.gzipSync(text, { level: 9 })
await Promise.all([Bun.write(target, text), Bun.write(compressed, gzip.toBase64())])
console.log(
`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes, ${gzip.length} bytes gzip) to ${Bun.fileURLToPath(target)}`,
)
await Bun.write(target, text)
console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`)
+8 -15
View File
@@ -11,7 +11,7 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { KV } from "./kv.js"
import snapshotGzip from "./models-dev/snapshot.gz.base64.txt" with { type: "text" }
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -544,24 +544,17 @@ const Cache = Schema.Struct({
})
const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, refreshed via
// `bun run script/update-models-snapshot.ts`. Decompressed, decoded, and
// normalized once per isolate: one isolate can host many runtimes (Cloudflare
// colocates Durable Object instances), so per-runtime work would multiply the
// cost.
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. Decoded and normalized once per
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: Schema.decodeUnknownEffect(Schema.Uint8ArrayFromBase64)(snapshotGzip).pipe(
Effect.flatMap((bytes) =>
Effect.promise(() =>
new Response(
new Blob([Uint8Array.from(bytes)]).stream().pipeThrough(new DecompressionStream("gzip")),
).text(),
),
),
Effect.flatMap(decodeCatalog),
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
return bundledCache
File diff suppressed because one or more lines are too long
+1 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref, Schema } from "effect"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@@ -13,16 +13,6 @@ import { it } from "./lib/effect"
const cacheKey = "models-dev:catalog"
test("compressed snapshot matches the reviewable source", async () => {
const source = await Bun.file(new URL("../src/models-dev/snapshot.txt", import.meta.url)).text()
const encoded = await Bun.file(new URL("../src/models-dev/snapshot.gz.base64.txt", import.meta.url)).text()
const bytes = Schema.decodeUnknownSync(Schema.Uint8ArrayFromBase64)(encoded)
const restored = await new Response(
new Blob([Uint8Array.from(bytes)]).stream().pipeThrough(new DecompressionStream("gzip")),
).text()
expect(restored).toBe(source)
})
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
expect(Model.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" })
+8 -1
View File
@@ -44,6 +44,7 @@ import {
type TuiApp,
} from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
import { linkAt } from "./ui/link"
import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing"
@@ -1270,7 +1271,13 @@ function App(props: { pair?: DialogPairCredentials }) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
onMouseUp={(event) => {
if (copyOnSelectEnabled()) Selection.copy(renderer, toast, clipboard)
if (event.defaultPrevented || event.button !== MouseButton.LEFT || event.isDragging) return
const href = linkAt(renderer.currentRenderBuffer, event.x, event.y)
if (!href) return
open(href).catch(() => {})
}}
>
<box
flexGrow={1}
+16 -4
View File
@@ -904,7 +904,13 @@ export function FormPrompt(props: {
<text
width={4}
flexShrink={0}
fg={picked() ? theme.text.feedback.success.default : theme.text.subdued}
fg={
active()
? theme.text.formfield.focused
: picked()
? theme.text.formfield.selected
: theme.text.subdued
}
>
[{picked() ? "✓" : " "}]
</text>
@@ -914,7 +920,7 @@ export function FormPrompt(props: {
</text>
</box>
<Show when={!multi()}>
<text fg={theme.text.feedback.success.default}>{picked() ? " ✓" : ""}</text>
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={row.description}>
@@ -953,7 +959,13 @@ export function FormPrompt(props: {
<text
width={4}
flexShrink={0}
fg={customChecked() ? theme.text.feedback.success.default : theme.text.subdued}
fg={
other()
? theme.text.formfield.focused
: customChecked()
? theme.text.formfield.selected
: theme.text.subdued
}
>
[{customChecked() ? "✓" : " "}]
</text>
@@ -966,7 +978,7 @@ export function FormPrompt(props: {
{input() || "Type your own answer"}
</text>
<Show when={!multi() && customPicked()}>
<text fg={theme.text.feedback.success.default}></text>
<text fg={theme.text.formfield.selected}></text>
</Show>
</>
}
+11 -2
View File
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import { getLinkId, type OptimizedBuffer, type RGBA } from "@opentui/core"
import open from "open"
export interface LinkProps {
@@ -24,7 +24,8 @@ export function Link(props: LinkProps) {
bg={props.bg}
width={props.width}
wrapMode={props.wrapMode}
onMouseUp={() => {
onMouseUp={(event) => {
event.stopPropagation()
open(props.href).catch(() => {})
}}
>
@@ -32,3 +33,11 @@ export function Link(props: LinkProps) {
</text>
)
}
export function linkAt(buffer: OptimizedBuffer, x: number, y: number) {
if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) return
const id = getLinkId(buffer.buffers.attributes[y * buffer.width + x] ?? 0)
if (!id) return
const lib = buffer.lib as typeof buffer.lib & { linkGetUrl(id: number): string }
return lib.linkGetUrl(id) || undefined
}
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test"
import { StyledText, TextRenderable } from "@opentui/core"
import { createTestRenderer, setRendererCapabilities } from "@opentui/core/testing"
import { linkAt } from "../../src/ui/link"
test("resolves terminal hyperlink metadata at the clicked cell", async () => {
const app = await createTestRenderer({ width: 60, height: 4 })
setRendererCapabilities(app.renderer, { hyperlinks: true })
const href = "file:///tmp/example.ts#L12"
app.renderer.root.add(
new TextRenderable(app.renderer, {
content: new StyledText([{ __isChunk: true, text: "example", link: { url: href } }]),
width: "100%",
height: 1,
}),
)
try {
await app.waitForFrame((frame) => frame.includes("example"))
const buffer = app.renderer.currentRenderBuffer
const links = Array.from({ length: buffer.width * buffer.height }, (_, index) =>
linkAt(buffer, index % buffer.width, Math.floor(index / buffer.width)),
)
expect(links).toContain(href)
} finally {
app.renderer.destroy()
}
})