mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-22 08:37:36 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7968747e34 | ||
|
|
9d531435b4 | ||
|
|
5b9dc35eec | ||
|
|
1814dd9799 | ||
|
|
1e1cd042ea | ||
|
|
02566f6219 | ||
|
|
f488aa3f79 |
@@ -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],
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,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 }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: "" }) },
|
||||
|
||||
@@ -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: "" }) },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user