Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton edee757028 docs: fix package manager code blocks 2026-08-13 14:57:45 +00:00
13 changed files with 121 additions and 213 deletions
+1 -4
View File
@@ -122,10 +122,7 @@ const layer = Layer.effect(
return { id: info?.id ?? defaultID, info }
}),
list: Effect.fn("Agent.list")(function* () {
const agents = Array.fromIterable(state.get().agents.values())
const selected = selectedDefault()
if (!selected) return agents
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
return Array.fromIterable(state.get().agents.values())
}),
})
}),
+11 -37
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -22,64 +22,38 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() }
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() }
if (!initialized) index = next
yield* ripgrep.find({
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
next.files.push(entry.path)
files.push(entry.path)
const parts = entry.path.split("/")
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
}),
})
index = next
initialized = true
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
}),
),
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
find: (input) =>
Effect.gen(function* () {
yield* refresh
const items =
input.type === "file"
? index.files
? files
: input.type === "directory"
? Array.from(index.directories)
: [...index.files, ...index.directories]
? Array.from(directories)
: [...files, ...directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
-20
View File
@@ -68,26 +68,6 @@ describe("Agent", () => {
}),
)
it.effect("lists the selected default agent first", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("build"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("reviewer"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
editor.default(Agent.ID.make("reviewer"))
})
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
+1 -68
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
import { TestClock } from "effect/testing"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
@@ -57,70 +56,4 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(initial)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "old", type: "file" })
yield* Deferred.await(started)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(2)
yield* Deferred.succeed(release, undefined)
const refreshed = yield* Effect.gen(function* () {
yield* Effect.yieldNow
return yield* search.find({ query: "new", type: "file" })
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
})
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveRendererDevUrl } from "./renderer-url"
describe("renderer development URL", () => {
test("allows a valid URL in development", () => {
expect(resolveRendererDevUrl(false, "http://localhost:5173")?.origin).toBe("http://localhost:5173")
})
test("ignores the override in packaged applications", () => {
expect(resolveRendererDevUrl(true, "https://example.com")).toBeUndefined()
})
test("ignores invalid URLs", () => {
expect(resolveRendererDevUrl(false, "not a url")).toBeUndefined()
})
})
@@ -1,4 +0,0 @@
export function resolveRendererDevUrl(packaged: boolean, value?: string) {
if (packaged || !value || !URL.canParse(value)) return undefined
return new URL(value)
}
+4 -4
View File
@@ -15,7 +15,6 @@ import { createUnresponsiveSampler } from "./unresponsive"
import { nativeT } from "./native-translations"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveRendererDevUrl } from "./renderer-url"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
const root = dirname(fileURLToPath(import.meta.url))
@@ -333,7 +332,7 @@ export function registerRendererProtocol() {
}
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = resolveRendererDevUrl(app.isPackaged, process.env.ELECTRON_RENDERER_URL)
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
const url = new URL(html, devUrl)
void win.loadURL(url.toString())
@@ -511,8 +510,9 @@ function isRendererUrl(value?: string, html = false) {
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = resolveRendererDevUrl(app.isPackaged, process.env.ELECTRON_RENDERER_URL)
return devUrl ? url.origin === devUrl.origin : false
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
function wireZoom(win: BrowserWindow) {
@@ -1,11 +1,10 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
type Experiment = {
id: string
id: "tab_drafts"
title: string
description: string
}
@@ -13,30 +12,36 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
},
]
export function DialogExperiments() {
const config = useConfig()
const theme = useTheme()
const toast = useToast()
const [selected, setSelected] = createSignal<Experiment>()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment) => ({
experiments.map((experiment, index) => ({
title: experiment.title,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
value: index,
})),
)
// All experiments are booleans, so either direction toggles.
async function change(experiment = selected()) {
async function change(index = selected()) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
@@ -53,33 +58,23 @@ export function DialogExperiments() {
<DialogSelect
title="Experiments"
options={options()}
renderFilter={experiments.length > 0}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
bindings={
experiments.length > 0
? [
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]
: []
}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]}
/>
)
}
@@ -1,18 +1,30 @@
import type { PromptInfo } from "../../prompt/history"
// Holds one in-progress draft per tab across Prompt remounts. A draft is
// consumed on take: restoring it moves it out of the stash, so a stale copy
// never shadows newer input.
// Holds one in-progress draft per slot across Prompt remounts. The undefined
// key is the default single global slot that follows focus across tabs; the
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
// were written in. A draft is consumed on take: restoring it moves it out of
// the stash, so a stale copy never shadows newer input.
export type DraftEntry = { prompt: PromptInfo; cursor: number }
const byTab = new Map<string | undefined, DraftEntry>()
let global: DraftEntry | undefined
const byTab = new Map<string, DraftEntry>()
export function takeDraft(sessionID: string | undefined) {
const entry = byTab.get(sessionID)
byTab.delete(sessionID)
export function takeDraft(key: string | undefined) {
if (key === undefined) {
const entry = global
global = undefined
return entry
}
const entry = byTab.get(key)
byTab.delete(key)
return entry
}
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
byTab.set(sessionID, entry)
export function saveDraft(key: string | undefined, entry: DraftEntry) {
if (key === undefined) {
global = entry
return
}
byTab.set(key, entry)
}
+3 -2
View File
@@ -678,9 +678,10 @@ export function Prompt(props: PromptProps) {
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
const stashSessionID = props.sessionID
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
onMount(() => {
const saved = takeDraft(stashSessionID)
const saved = takeDraft(stashKey())
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -693,7 +694,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
}
setInputTarget(undefined)
props.ref?.(undefined)
+7 -3
View File
@@ -192,9 +192,13 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Debugging settings" }),
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "Experimental features that may change or be removed at any time",
}),
experimental: Schema.optional(
Schema.Struct({
tab_drafts: Schema.optional(Schema.Boolean).annotate({
description: "Keep unsent prompt drafts on the tab where they were written",
}),
}),
).annotate({ description: "Experimental features that may change or be removed at any time" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
+26 -3
View File
@@ -3,13 +3,23 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
import { emptyPrompt } from "../../src/prompt/history"
// The Prompt component stashes an unsent draft in onCleanup and takes it back
// in onMount across route remounts, keyed by sessionID or undefined for home.
// in onMount across route remounts. The key it uses is undefined by default
// (one global slot that follows focus across tabs) and the tab identity
// (sessionID, or "home") when the tab_drafts experiment is on.
function draft(text: string, cursor = text.length) {
return { prompt: { ...emptyPrompt(), text }, cursor }
}
describe("prompt draft stash", () => {
test("global slot follows focus: any tab takes the last stashed draft", () => {
const entry = draft("follow me")
saveDraft(undefined, entry)
expect(takeDraft(undefined)).toBe(entry)
// Consumed on take, so a remount never restores a stale copy.
expect(takeDraft(undefined)).toBeUndefined()
})
test("tab-keyed drafts stay on the tab they were written in", () => {
const two = draft("notes for session two")
saveDraft("ses_two", two)
@@ -27,12 +37,25 @@ describe("prompt draft stash", () => {
const one = draft("DRAFT-ONE")
const home = draft("draft on home")
saveDraft("ses_one", one)
saveDraft(undefined, home)
saveDraft("home", home)
expect(takeDraft(undefined)).toBe(home)
expect(takeDraft("home")).toBe(home)
expect(takeDraft("ses_one")).toBe(one)
})
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
const global = draft("stashed before enabling tab_drafts")
const keyed = draft("stashed after enabling tab_drafts")
saveDraft(undefined, global)
saveDraft("ses_a", keyed)
// A keyed lookup must not surface the global draft on the wrong tab...
expect(takeDraft("ses_b")).toBeUndefined()
// ...and the global slot must not surface a tab's draft.
expect(takeDraft(undefined)).toBe(global)
expect(takeDraft("ses_a")).toBe(keyed)
})
test("a newer draft for the same slot replaces the older one", () => {
saveDraft("ses_a", draft("first"))
const second = draft("second")
+19 -10
View File
@@ -15,20 +15,29 @@ description: "Get started with OpenCode."
## Install
### Install script
<CodeGroup>
```bash
```bash npm
npm install -g @opencode-ai/cli@next
```
```bash bun
bun install -g --trust @opencode-ai/cli@next
```
```bash pnpm
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
```
```bash yarn
yarn global add @opencode-ai/cli@next
```
```bash curl
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
```
You can also install it with the following package managers.
<Tabs>
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
</Tabs>
</CodeGroup>
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.