Compare commits

...
35 changed files with 319 additions and 133 deletions
+3 -2
View File
@@ -112,11 +112,12 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: |
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test
GITHUB_ACTIONS=false bun turbo test --concurrency=3
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
@@ -108,6 +108,22 @@ test("non-Git folders show their status without offering worktree actions", asyn
).toBeEnabled()
})
test("project and Git status wrap only when the mobile row runs out of space", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openDraft(page, "main", { git: false, name: "summary-project-with-a-long-name" })
const project = page.locator('[data-action="prompt-project"]')
const git = page.locator('[data-slot="prompt-git-status"]')
const verticalOffset = async () => {
const [projectBox, gitBox] = await Promise.all([project.boundingBox(), git.boundingBox()])
if (!projectBox || !gitBox) return Number.POSITIVE_INFINITY
return Math.abs(projectBox.y + projectBox.height / 2 - gitBox.y - gitBox.height / 2)
}
await expect.poll(verticalOffset).toBeLessThanOrEqual(3)
await page.setViewportSize({ width: 320, height: 844 })
await expect.poll(verticalOffset).toBeGreaterThan(3)
})
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
const mock = await openDraft(page, "create", { currentDirectory: workspace })
await page.getByRole("button", { name: "New worktree", exact: true }).click()
@@ -308,13 +324,13 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
async function openDraft(
page: Page,
worktree = "main",
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string; name?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
name: options.name ?? "summary-project",
vcs: options.git === false ? undefined : "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
+1 -1
View File
@@ -120,7 +120,7 @@ export function NewSessionView(props: {
<PromptProjectAddButton controller={props.project} />
</Show>
<Show when={props.project.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
<PromptProjectSelector controller={props.project} placement="bottom" />
<Show
when={props.workspace.bar.visible()}
@@ -397,7 +397,10 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<div
data-slot="prompt-git-status"
class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"
>
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
+13 -3
View File
@@ -55,7 +55,7 @@ try {
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
await waitForPlugin(info.url, headers, plugin)
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
signal: AbortSignal.timeout(5_000),
@@ -139,7 +139,13 @@ async function waitForReady(url: string, headers: HeadersInit) {
}
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), milliseconds)
process.exited.then(() => {
clearTimeout(timeout)
resolve(true)
})
})
}
function pluginSource() {
@@ -159,11 +165,15 @@ async function pluginIDs(url: string, headers: HeadersInit) {
)
}
async function waitForPlugin(url: string, headers: HeadersInit) {
async function waitForPlugin(url: string, headers: HeadersInit, plugin: string) {
const deadline = Date.now() + 10_000
let attempt = 0
while (Date.now() < deadline) {
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
// Native watchers may coalesce a single creation edge. Keep changing valid source so
// the smoke proves that a later native event is delivered.
if (++attempt % 10 === 0) await fs.writeFile(plugin, `${pluginSource()}// watcher retry ${attempt}\n`)
}
throw new Error("Compiled service did not discover the created plugin")
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info, SchemaURL } from "./schema"
import { Info, normalizeLegacyTabs, SchemaURL } from "./schema"
export * from "./schema"
@@ -119,7 +119,7 @@ function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
(result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}),
{},
),
),
+8
View File
@@ -8,3 +8,11 @@ export const Info = Schema.Struct({
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
export function normalizeLegacyTabs(info: Info | undefined) {
if (info?.tabs?.enabled === undefined) return info
const tabs = { ...info.tabs }
tabs.mode ??= tabs.enabled ? "on" : "off"
delete tabs.enabled
return { ...info, tabs }
}
+22 -2
View File
@@ -78,7 +78,7 @@ test("merges inline CLI config content over the global config", async () => {
await Bun.write(
file,
JSON.stringify({
tabs: { enabled: true, scope: "global" },
tabs: { mode: "on", scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
@@ -105,7 +105,7 @@ test("merges inline CLI config content over the global config", async () => {
}),
)
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.tabs).toEqual({ mode: "off", scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
@@ -116,6 +116,26 @@ test("merges inline CLI config content over the global config", async () => {
}
})
test("reads the legacy tabs toggle without rewriting it", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
await Bun.write(file, JSON.stringify({ tabs: { enabled: false } }))
const config = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).tabs).toEqual({ mode: "off" })
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config.tabs).toEqual({ mode: "off" })
expect(await Bun.file(file).json()).toEqual({ tabs: { enabled: false }, animations: false })
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+9 -4
View File
@@ -145,8 +145,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
assignments, object literal keys, and destructuring or parameter defaults.
- [x] Built-in functions are objects too, with `name` and `length` (`Math.max.length === 2`,
`Array.prototype.push.name === "push"`).
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [x] A named function expression's name is bound read-only inside its own body; assigning to it throws a
`TypeError`, as in strict mode.
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
rather than at the call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -196,7 +199,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode. `delete` of a
non-reference (`delete 0`, `delete f()`) evaluates the operand and is `true`; `delete x` on a variable throws.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
- [ ] ToPrimitive on object operands: operators, `Error(message)`, `Date` arguments, and `parseInt` radix should call
@@ -239,7 +243,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like
any function.
- [x] `Promise.withResolvers()`: the same promise and resolver callables as the constructor, as a `{ promise, resolve,
reject }` object.
reject }` object.
- [x] `Promise.try(fn, ...args)`: calls `fn` synchronously; a throw rejects, a return fulfils, and a returned promise or
thenable is adopted.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
@@ -437,6 +441,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
- [x] `new Map()` from synchronous iterables of entries.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
`forEach` is live: entries deleted during the walk are skipped and entries added are visited, as in JS.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
@@ -74,6 +74,7 @@ import {
IteratorObj,
type Cursor,
has,
hidden,
hasPrototype,
keys,
Native,
@@ -454,8 +455,9 @@ class Frame<R> {
node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression,
name = node.type === "ArrowFunctionExpression" ? "" : (node.id?.name ?? ""),
): Fn {
return new Fn(
this.ctx.builtins.Function,
const builtins = this.ctx.builtins
const fn = new Fn(
builtins.Function,
name,
node.params,
node.body,
@@ -463,6 +465,14 @@ class Frame<R> {
node.async,
node.generator,
)
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
if (node.generator)
define(fn, "prototype", new Obj(node.async ? builtins.AsyncGenerator : builtins.Generator), hidden)
// The body of a named function expression sees its own name, read-only.
if (node.type === "FunctionExpression" && node.id) {
fn.capturedScopes.push(new Map([[node.id.name, { mutable: false, value: fn, initialized: true }]]))
}
return fn
}
// NamedEvaluation: an anonymous function definition takes the name of what it is assigned to.
@@ -473,10 +483,11 @@ class Frame<R> {
return this.evaluateExpression(node)
}
// Repeated `function` declarations and `var` clashes are legal: the last declaration wins.
private hoistFunctions(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
for (const node of statements) {
if (node.type !== "FunctionDeclaration") continue
this.scopes.declare(node.id.name, this.createFunction(node), true, node)
this.scopes.current().set(node.id.name, { mutable: true, value: this.createFunction(node), initialized: true })
}
}
@@ -1679,7 +1690,7 @@ class Frame<R> {
return yield* invocation.evaluateExpression(fn.body)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn))
if (!fn.async) return run
return this.ctx.pending.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.ctx, value, self)),
@@ -1687,11 +1698,8 @@ class Frame<R> {
})
}
private createGenerator(
invocation: Frame<R>,
run: Effect.Effect<Value, unknown, R>,
asynchronous: boolean,
): GeneratorObj {
private createGenerator(invocation: Frame<R>, run: Effect.Effect<Value, unknown, R>, fn: Fn): GeneratorObj {
const asynchronous = fn.async
const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 }
invocation.generatorState = state
invocation.generatorAsync = asynchronous
@@ -1759,12 +1767,8 @@ class Frame<R> {
}
return Deferred.await(request.response)
}
const generator = new GeneratorObj(
asynchronous ? builtins.AsyncGenerator : builtins.Generator,
asynchronous,
request,
)
return generator
const proto = get(fn, "prototype")
return new GeneratorObj(proto instanceof Obj ? proto : builtins.Generator, asynchronous, request)
}
private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect<void, never, R> {
@@ -2109,9 +2113,9 @@ class Frame<R> {
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? argument.expression : argument
if (target.type !== "MemberExpression") {
throw typeError("Only data fields may be deleted.", argument)
}
if (target.type === "Identifier") throw typeError("Only data fields may be deleted.", argument)
// `delete <non-reference>` evaluates the operand and is true, as in JS.
if (target.type !== "MemberExpression") return Effect.map(this.evaluateExpression(target), () => true)
return Effect.map(this.getMemberReference(target), (reference) => {
if (reference === OptionalShortCircuit) return true
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
+1 -1
View File
@@ -159,7 +159,7 @@ export class Fn extends Callable {
name: string,
readonly parameters: ReadonlyArray<Pattern>,
readonly body: BlockStatement | Expression,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
readonly capturedScopes: Array<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
) {
+2 -2
View File
@@ -188,7 +188,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
return undefined
})
},
@@ -386,7 +386,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
for (const item of target.set.values()) yield* apply([item, item, target])
return undefined
})
},
+42 -31
View File
@@ -211,6 +211,32 @@ const termForms = (term: string): Array<string> => {
return forms
}
const rank = (entries: ReadonlyArray<SearchEntry>, query: string): Array<SearchEntry> => {
const terms = tokenize(query).map(termForms)
return entries
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
)
.map(({ entry }) => entry)
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
_tag: "CodeModeTool",
description: "Search available tools",
@@ -238,32 +264,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
: scoped.find(
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
)
.map(({ entry }) => entry)
const ranked = exact !== undefined ? [exact] : rank(scoped, query)
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
...description,
path: toolExpression(description.path),
@@ -329,13 +330,23 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
return Array.from(node.children.keys())
}
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>, index: ReadonlyArray<SearchEntry>): Tool<R> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
"The tool may have been removed or renamed. Use search to find available tools.",
])
const name = segments.join(".")
const ns = segments.length > 1 && root.children.has(segments[0]) ? segments[0] : undefined
const closest = rank(
ns ? index.filter((entry) => entry.description.path.startsWith(`${ns}.`)) : index,
ns ? segments.slice(1).join(" ") : name,
)[0]
throw new ToolRuntimeError(
"UnknownTool",
closest
? `Unknown tool '${name}'. Did you mean ${toolExpression(closest.description.path)}?`
: `Unknown tool '${name}'.`,
["Use search to find available tools."],
)
}
if (node.tool === undefined) {
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
@@ -440,7 +451,7 @@ export const make = <R>(
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
return executeTool(segments.join("."), resolve(root, path), args)
return executeTool(segments.join("."), resolve(root, path, prepared.searchIndex), args)
}),
}
}
+13
View File
@@ -218,6 +218,19 @@ describe("property deletion", () => {
).toEqual([true, true, { keep: 1 }])
})
test("a non-reference operand is evaluated and the result is true; a variable cannot be deleted", async () => {
expect(
await value(`
let called = false
const results = [delete 0, delete null, delete { x: 1 }, delete void 0, delete (() => { called = true })()]
let variable = 1
let failure
try { delete variable } catch (error) { failure = error.constructor.name }
return [results, called, failure]
`),
).toEqual([[true, true, true, true, true], true, "TypeError"])
})
test("evaluates computed object and key expressions once", async () => {
expect(
await value(`
+13
View File
@@ -947,6 +947,19 @@ describe("Map", () => {
})
describe("Set", () => {
test("forEach is live on Map and Set: deleted entries are skipped and added ones visited", async () => {
expect(
await value(`
const m = new Map([[1, "a"], [2, "b"]])
const s = new Set([1, 2])
const seen = []
m.forEach((v, k) => { seen.push(k); if (k === 1) { m.delete(2); m.set(3, "c") } })
s.forEach((v) => { seen.push(v); if (v === 1) { s.delete(2); s.add(3) } })
return seen
`),
).toEqual([1, 3, 1, 3])
})
test("add/has/delete/size with chaining", async () => {
expect(
await value(`
@@ -336,11 +336,6 @@ language/statements/function/S13.2_A1_T1.js # #1: __func.prototype !== undefine
language/statements/function/S13.2_A1_T2.js # #1: __func.prototype !== undefined
language/statements/function/S13.2_A4_T1.js # Unknown identifier '…'.
language/statements/function/S13.2_A4_T2.js # #1: typeof __gunc.prototype === '…'. Actual: typeof __gunc.prototype ===undefined
language/statements/function/S13_A3_T1.js # Unknown identifier '…'.
language/statements/function/S13_A6_T1.js # Identifier '…' has already been declared.
language/statements/function/S14_A2.js # Unknown identifier '…'.
language/statements/function/S14_A5_T1.js # Identifier '…' has already been declared.
language/statements/function/S14_A5_T2.js # Identifier '…' has already been declared.
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
@@ -390,9 +385,6 @@ language/statements/generators/dstr/obj-ptrn-prop-id-init-throws.js # Expected
language/statements/generators/dstr/obj-ptrn-prop-id-init-unresolvable.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/generators/dstr/obj-ptrn-prop-obj-value-null.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/obj-ptrn-prop-obj-value-undef.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/has-instance.js # The right-hand side of '…' has no '…' object.
language/statements/generators/prototype-typeof.js # Expected SameValue(«"undefined"», «"object"») to be true
language/statements/generators/prototype-uniqueness.js # Expected true but got false
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
+6 -3
View File
@@ -106,9 +106,12 @@ describe("callable namespaces", () => {
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
expect(diagnostic.suggestions).toEqual([
"The tool may have been removed or renamed. Use search to find available tools.",
])
expect(diagnostic.suggestions).toEqual(["Use search to find available tools."])
})
test("an unknown tool names the closest match", async () => {
const diagnostic = await failure(runtime, `return await tools.issues["get-list"]({})`)
expect(diagnostic.message).toBe("Unknown tool 'issues.get-list'. Did you mean tools.issues.list?")
})
test("a namespace without its own tool stays non-callable", async () => {
@@ -234,6 +234,44 @@ describe("var semantics beyond Test262", () => {
})
})
describe("function declarations and expressions", () => {
test("the last of repeated function declarations wins, and a var may share the name", async () => {
expect(
await value(`
function f() { return 1 }
const first = f()
function f() { return 2 }
var g = 1
function g() { return 3 }
return [first, f(), typeof g]
`),
).toEqual([2, 2, "number"])
})
test("a named function expression sees its own name inside its body, read-only", async () => {
expect(
await value(`
const fact = function inner(n) { return n <= 1 ? 1 : n * inner(n - 1) }
const reassign = function inner() { inner = 5 }
let failure
try { reassign() } catch (error) { failure = error.constructor.name }
return [fact(4), typeof inner, failure]
`),
).toEqual([24, "undefined", "TypeError"])
})
test("generator functions have their own prototype", async () => {
expect(
await value(`
function* g() {}
async function* ag() {}
function f() {}
return [g() instanceof g, ag() instanceof ag, g.prototype === ag.prototype, typeof g.prototype, typeof f.prototype]
`),
).toEqual([true, true, false, "object", "undefined"])
})
})
describe("switch case function hoisting", () => {
test("function declarations are visible across all cases before their statement runs", async () => {
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
@@ -17,6 +17,12 @@ const channels = [
{ channel: "prod", appId: "ai.opencode.desktop" },
] as const
test("signs the macOS app without signing the DMG", async () => {
const config = (await import("./electron-builder.config.ts?mac-signing")).default as Configuration
expect(config.mac?.sign).toBeFunction()
expect(config.dmg?.sign).not.toBe(true)
})
for (const channel of channels) {
test(`disables security code AutoFill by default for ${channel.channel}`, async () => {
const previous = process.env.OPENCODE_CHANNEL
@@ -123,9 +123,6 @@ const getBase = (appId: string): Configuration => ({
notarize: true,
target: ["dmg", "zip"],
},
dmg: {
sign: true,
},
protocols: {
name: "OpenCode",
schemes: ["opencode"],
+4 -5
View File
@@ -121,12 +121,11 @@ export const settings: Setting[] = [
keywords: ["approve", "accept", "permission requests"],
},
{
title: "Enabled",
title: "Mode",
category: "Tabs",
path: ["tabs", "enabled"],
default: true,
values: [false, true],
labels: ["off", "on"],
path: ["tabs", "mode"],
default: "auto",
values: ["off", "on", "auto"],
},
{
title: "Scope",
+14 -4
View File
@@ -173,8 +173,11 @@ export const Info = Schema.Struct({
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
Schema.Struct({
mode: Schema.optional(Schema.Literals(["auto", "on", "off"])).annotate({
description: "Use session tabs always, never, or when the terminal environment supports them",
}),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
description: "Legacy tab toggle; use mode instead",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
@@ -261,6 +264,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
tps: boolean
}
tabs: {
mode: "auto" | "on" | "off"
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
@@ -268,7 +272,12 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
}
}
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
export function resolve(
input: Info,
options: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> },
): Resolved {
const tabsMode =
input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off")
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds["terminal.suspend"] = "none"
@@ -310,7 +319,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
},
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
mode: tabsMode,
enabled: tabsMode === "on" || (tabsMode === "auto" && (options.environment ?? process.env).HERDR_ENV !== "1"),
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
indicators: input.tabs?.indicators ?? "status",
@@ -327,7 +337,7 @@ const ConfigContext = createContext<{
export function ConfigProvider(props: {
config: Resolved
service?: Interface
options?: { terminalSuspend: boolean }
options?: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> }
children: JSX.Element
}) {
const [config, setConfig] = createStore(props.config)
@@ -1,9 +1,9 @@
import { Plugin } from "@opencode/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { DialogMcp } from "../../component/dialog-mcp"
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
export function SidebarMcp(props: { context: Plugin.Context; sessionID: string }) {
const [view, updateView] = props.context.storage.store("view", { initial: { open: true } })
const theme = props.context.theme
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
@@ -23,13 +23,22 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<box
flexDirection="row"
gap={1}
onMouseDown={() => {
if (list().length <= 2) return
void updateView((draft) => {
draft.open = !draft.open
}).catch((error) => console.error("Failed to persist MCP sidebar state", error))
}}
>
<Show when={list().length > 2}>
<text fg={theme.text.base}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text.base}>{view.open ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text.base}>
<b>MCP</b>
<Show when={!open()}>
<Show when={!view.open}>
<span style={{ fg: theme.text.muted }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
@@ -37,7 +46,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Show>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<Show when={list().length <= 2 || view.open}>
<For each={list()}>
{(item) => (
<box
@@ -88,7 +97,7 @@ export default Plugin.define({
setup(context) {
context.ui.slot({
append: "sidebar.content",
render: (props) => <View context={context} sessionID={props.sessionID} />,
render: (props) => <SidebarMcp context={context} sessionID={props.sessionID} />,
})
},
})
+10 -10
View File
@@ -123,7 +123,7 @@ test.each(["dismissed", "refreshing"])(
const locations: string[] = []
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false } },
config: { animations: false, tabs: { mode: "off" } },
fetch: (url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
@@ -447,7 +447,7 @@ test("vertical session tabs switch to horizontal below readable content width",
state: state.path,
config: {
animations: false,
tabs: { enabled: true, layout: "vertical", indicators: "status" },
tabs: { mode: "on", layout: "vertical", indicators: "status" },
session: { sidebar: "hide" },
},
args: { sessionID: session.id },
@@ -485,7 +485,7 @@ test("narrow vertical session tabs collapse to a compact rail with the terminal"
state: state.path,
config: {
animations: false,
tabs: { enabled: true, layout: "vertical", indicators: "status" },
tabs: { mode: "on", layout: "vertical", indicators: "status" },
session: { sidebar: "hide" },
},
args: { sessionID: session.id },
@@ -526,7 +526,7 @@ test("automatic rename refreshes the displayed title before settling, even witho
width: 110,
height: 20,
state: state.path,
config: { tabs: { enabled: true, layout: "vertical" }, session: { sidebar: "hide" } },
config: { tabs: { mode: "on", layout: "vertical" }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
@@ -585,7 +585,7 @@ test.each([80, 120])("completes custom Markdown and ordinary fences in a session
width,
height: 55,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
@@ -690,7 +690,7 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
width: 100,
height: 40,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide", tps: true } },
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide", tps: true } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
@@ -886,7 +886,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
let session: unknown
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } },
config: { animations: false, tabs: { mode: "off" }, keybinds: { "session.new": "f6" } },
fetch: async (url, request) => {
requests.push(url)
if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target)
@@ -1024,7 +1024,7 @@ test("completed user shell output replaces a partial live read when the final re
let failedReads = 0
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
@@ -1456,7 +1456,7 @@ test.each([100, 44])(
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
config: { animations: false, tabs: { mode: "off" } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
@@ -1620,7 +1620,7 @@ test.each([44, 100])(
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
config: { animations: false, tabs: { mode: "off" } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
@@ -89,7 +89,7 @@ test.each([40, 120])("shell completion notices do not navigate at width %s", asy
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false } }),
get: async () => ({ animations: false, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
@@ -170,7 +170,7 @@ test.each([40, 120])("subagent completion notices navigate to the child session
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false } }),
get: async () => ({ animations: false, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
@@ -28,7 +28,7 @@ test("releasing a transcript selection over tab controls does not activate them"
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
@@ -79,7 +79,7 @@ test("middle-click closes a session tab without selecting it", async () => {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
@@ -127,7 +127,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
@@ -178,7 +178,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
@@ -33,7 +33,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [newTab, setNewTab] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
const settings: Info = { tabs: { mode: "on" } }
const copied: string[] = []
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
+32 -6
View File
@@ -33,12 +33,13 @@ test("validates mini replay and work spinner settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({
tabs: { enabled: true, layout: "vertical", indicators: "numbers" },
expect(decode({ tabs: { mode: "on", layout: "vertical", indicators: "numbers" } })).toEqual({
tabs: { mode: "on", layout: "vertical", indicators: "numbers" },
})
expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow()
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { mode: true } })).toThrow()
expect(decode({ tabs: { enabled: false } })).toEqual({ tabs: { enabled: false } })
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
expect(decode({ session: { tps: false } })).toEqual({ session: { tps: false } })
@@ -55,7 +56,7 @@ test("resolves nested config and keybind defaults", () => {
diffs: { view: "split" },
debug: { devtools: true },
},
{ terminalSuspend: true },
{ terminalSuspend: true, environment: {} },
)
expect(config.leader.timeout).toBe(500)
@@ -63,13 +64,38 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", indicators: "status" })
expect(config.tabs).toEqual({
mode: "auto",
enabled: true,
scope: "cwd",
layout: "horizontal",
indicators: "status",
})
expect(config.session.new_location).toBe("launch")
expect(config.session.tps).toBe(true)
})
test("resolves automatic tabs from the terminal environment", () => {
expect(resolve({}, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(true)
expect(resolve({}, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled).toBe(false)
expect(
resolve({ tabs: { mode: "on" } }, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled,
).toBe(true)
expect(resolve({ tabs: { mode: "off" } }, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(false)
expect(resolve({ tabs: { enabled: false } }, { terminalSuspend: true, environment: {} }).tabs).toMatchObject({
mode: "off",
enabled: false,
})
expect(
resolve({ tabs: { mode: "on", enabled: false } }, { terminalSuspend: true, environment: {} }).tabs,
).toMatchObject({ mode: "on", enabled: true })
})
test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.mode")).toMatchObject({
default: "auto",
values: ["off", "on", "auto"],
})
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
expect(settings.find((setting) => setting.path.join(".") === "tabs.indicators")).toMatchObject({
@@ -135,7 +135,7 @@ async function renderSessionTabs(
let storage!: ReturnType<typeof useStorage>
let config!: ReturnType<typeof useConfig>
let configuration = {
tabs: { enabled: options?.tabsEnabled ?? true },
tabs: { mode: options?.tabsEnabled === false ? ("off" as const) : ("on" as const) },
experimental: options?.experimental,
session: { new_location: options?.newLocation ?? "launch" },
}
@@ -205,7 +205,7 @@ async function renderSessionTabs(
setTabsEnabled: (enabled: boolean) =>
config.update((draft) => {
draft.tabs ??= {}
draft.tabs.enabled = enabled
draft.tabs.mode = enabled ? "on" : "off"
}),
async destroy() {
app.renderer.destroy()
+1 -1
View File
@@ -8,7 +8,7 @@ type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
}
export function createTuiResolvedConfig(input: ResolvedInput = {}, options?: { terminal?: boolean }) {
const config = resolve(input, { terminalSuspend: process.platform !== "win32" })
const config = resolve(input, { terminalSuspend: process.platform !== "win32", environment: {} })
return {
...config,
session: { ...config.session, terminal: options?.terminal ?? config.session.terminal },
+1 -1
View File
@@ -93,7 +93,7 @@ test.each([
config: {
get: async () => ({
animations: false,
tabs: { enabled: false },
tabs: { mode: "off" },
keybinds: {
"session.line.up": "f6",
"session.page.down": "f7",
@@ -78,7 +78,7 @@ test
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false } }),
get: async () => ({ animations: false, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
+1 -1
View File
@@ -38,7 +38,7 @@ test("stats shows only this year and returns after errors or success", async ()
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false } }),
get: async () => ({ animations: false, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
+1 -1
View File
@@ -12,7 +12,7 @@ export default OpenCodeDriver.use(
keepArtifacts: true,
tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } },
config: { autoupdate: false, username: "Demo" },
tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } },
tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { mode: "off" } },
project: {
git: true,
files: {
+5 -3
View File
@@ -40,7 +40,7 @@ while the TUI is running.
Set `OPENCODE_CLI_CONFIG_CONTENT` to apply CLI settings from inline JSON:
```sh
OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"enabled":false}}' opencode
OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"mode":"off"}}' opencode
```
OpenCode merges the inline settings over the global `cli.json`. Nested objects are merged, while arrays and scalar
@@ -152,7 +152,7 @@ Configure the persistent session tab strip:
```json title="cli.json"
{
"tabs": {
"enabled": true,
"mode": "auto",
"scope": "cwd",
"layout": "horizontal",
"indicators": "status"
@@ -160,11 +160,13 @@ Configure the persistent session tab strip:
}
```
The legacy boolean `tabs.enabled` setting remains supported: `true` is `on` and `false` is `off`. OpenCode reads it without rewriting the config file.
<div class="docs-table-scroll" role="region" aria-label="Tab settings" tabIndex={0}>
| Setting | Values | Description |
| ----------------- | --------------------------- | ----------------------------------------------------------------------------- |
| `tabs.enabled` | boolean | Uses a persistent tab strip instead of pinned quick-switch sessions. |
| `tabs.mode` | `auto`, `on`, or `off` | Uses tabs automatically, always, or never. Auto hides them inside Herdr. |
| `tabs.scope` | `cwd` or `global` | Keeps separate tabs per working directory or shares them globally. |
| `tabs.layout` | `horizontal` or `vertical` | Places tabs in a horizontal strip or a vertical sidebar. |
| `tabs.indicators` | `status` or `numbers` | Shows status icons or always shows tab numbers. |