Compare commits

..

66 Commits

Author SHA1 Message Date
opencode-agent[bot]
4b957f6fa3 Apply PR #24229: fix: lazy session error schema 2026-05-04 07:25:31 +00:00
opencode-agent[bot]
10593a0f2d Apply PR #22753: core: move plugin intialisation to config layer override 2026-05-04 07:25:30 +00:00
opencode-agent[bot]
c03723357c Apply PR #21537: fix(app): remove pierre diff virtualization 2026-05-04 07:22:59 +00:00
opencode-agent[bot]
f4ff6df275 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-05-04 07:21:59 +00:00
opencode-agent[bot]
7bb0271d99 Apply PR #11710: feat: Add the ability to include cleared prompts in the history, toggled by a KV-persisted command palette item (resolves #11489) 2026-05-04 07:17:31 +00:00
Brendan Allan
9b85d2cbb4 Merge branch 'dev' into brendan/lazy-init-plugins 2026-05-01 11:48:58 +08:00
Brendan Allan
4a86c2b77a Merge branch 'dev' into brendan/lazy-init-plugins 2026-05-01 11:47:50 +08:00
LukeParkerDev
d704110e52 fix: lazy session error schema 2026-04-25 10:04:49 +10:00
Ariane Emory
09e4e5a184 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-23 21:55:13 -04:00
Brendan Allan
e041605b40 Merge branch 'dev' into brendan/lazy-init-plugins 2026-04-21 12:33:02 +08:00
LukeParkerDev
f280e7e69c fix: defer MessageV2.Assistant.shape access to break circular dep in compiled binary 2026-04-20 16:08:42 +10:00
Brendan Allan
b265742fd0 Merge branch 'dev' into brendan/lazy-init-plugins 2026-04-19 21:15:45 +08:00
Brendan Allan
b1db69fdf7 fix other commands 2026-04-17 17:03:53 +08:00
Brendan Allan
031766efa0 fix tui 2026-04-17 15:44:01 +08:00
Brendan Allan
dc6d39551c address feedback 2026-04-17 15:44:01 +08:00
Brendan Allan
e287569f82 rename layer 2026-04-17 15:44:01 +08:00
Brendan Allan
14eacb4019 core: move plugin intialisation to config layer override 2026-04-17 15:44:01 +08:00
Ariane Emory
731c1e58f2 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-16 20:22:02 -04:00
Ariane Emory
c411d37484 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-12 04:22:06 -04:00
Adam
cb29742b57 fix(app): remove pierre diff virtualization 2026-04-08 13:16:45 -05:00
Ariane Emory
97a94571a4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-03 09:19:12 -04:00
Ariane Emory
6652585a7f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 11:17:40 -04:00
Ariane Emory
532b64c0d5 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 07:43:03 -04:00
Ariane Emory
eec4c775a7 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-23 21:10:21 -04:00
Ariane Emory
01e350449c Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 19:12:18 -04:00
Dax
5792a80a8c Merge branch 'dev' into feat/auto-accept-permissions 2026-03-20 10:46:31 -04:00
Dax Raad
db039db7f5 regen js sdk 2026-03-20 10:21:10 -04:00
Dax Raad
c1a3936b61 Merge remote-tracking branch 'origin/dev' into feat/auto-accept-permissions
# Conflicts:
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-03-20 10:20:26 -04:00
Ariane Emory
a9d9e4d9c4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 03:35:16 -04:00
Ariane Emory
2531b2d3a9 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-13 11:47:39 -04:00
Ariane Emory
a718f86e0f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 19:28:41 -04:00
Ariane Emory
f3efdff861 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 08:36:02 -04:00
Ariane Emory
955d8591df Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-05 18:24:19 -05:00
Ariane Emory
33b3388bf4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 17:50:11 -05:00
Ariane Emory
716f40b128 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 01:36:39 -05:00
Ariane Emory
0b06ff1407 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-20 21:24:12 -05:00
Ariane Emory
01ff5b5390 Merge branch 'dev' into feat/canceled-prompts-in-history
# Conflicts:
#	packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
2026-02-20 02:16:02 -05:00
Ariane Emory
3d1b121e70 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-19 19:18:48 -05:00
Ariane Emory
b70629af27 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-18 19:10:26 -05:00
Ariane Emory
b7b016fa28 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-17 00:09:51 -05:00
Ariane Emory
5ba2d7e5f0 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-15 12:27:51 -05:00
Ariane Emory
459b22b83d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-14 19:21:47 -05:00
Ariane Emory
377812b98a Merge dev into feat/canceled-prompts-in-history 2026-02-14 06:28:48 -05:00
Ariane Emory
5cc0901e38 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-13 09:37:11 -05:00
Ariane Emory
7fb6b589d1 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-12 18:29:23 -05:00
Ariane Emory
3f37b43e7d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-11 12:46:47 -05:00
Ariane Emory
8805dfc849 fix: deduplicate prompt history entries
Avoid adding duplicate entries to prompt history when the same input
is appended multiple times (e.g., clearing with ctrl+c then restoring
via history navigation and clearing again).
2026-02-10 22:21:39 -05:00
Ariane Emory
ac5a5d8b16 Merge branch 'feat/canceled-prompts-in-history' of github.com:ariane-emory/opencode into feat/canceled-prompts-in-history 2026-02-10 16:37:55 -05:00
Ariane Emory
eaf94ed047 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:29:05 -05:00
Ariane Emory
b8031c5ae8 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:10:35 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Ariane Emory
d5dcadc000 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-07 13:34:42 -05:00
Ariane Emory
0c154e6a2f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-06 15:59:50 -05:00
Ariane Emory
4f96975148 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-05 18:17:01 -05:00
Ariane Emory
eaba99711b Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-04 19:33:59 -05:00
Ariane Emory
f762125775 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-03 18:36:44 -05:00
Ariane Emory
ded6bb6513 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-02 21:23:28 -05:00
Ariane Emory
39332f5be6 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-01 22:33:29 -05:00
Ariane Emory
2c6ff35400 feat: add toggle to control whether cleared prompts are saved to history
Adds a toggle command in the System category that allows users to enable
or disable saving cleared prompts to history. The feature is disabled by
default to preserve existing behavior.

When enabled via the command palette ("Include cleared prompts in history"),
pressing Ctrl+C will save the current prompt to history before clearing it,
allowing users to navigate back with arrow keys.

The setting persists in kv.json.
2026-02-01 21:12:48 -05:00
Ariane Emory
738d6c8899 feat: save prompt to history when cleared with Ctrl+C
When users press Ctrl+C to clear the input field, the current prompt
is now saved to history before clearing. This allows users to navigate
back to cleared prompts using arrow keys, preventing loss of work.

Addresses #11489
2026-02-01 21:01:15 -05:00
20 changed files with 136 additions and 394 deletions

View File

@@ -779,6 +779,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
value: "app.toggle.clear_prompt_history",
category: "System",
onSelect: (dialog) => {
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
dialog.clear()
},
},
])
event.on(TuiEvent.CommandExecute.type, (evt) => {

View File

@@ -82,6 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
if (store.history.at(-1)?.input === item.input) return
const entry = structuredClone(unwrap(item))
let trimmed = false
setStore(

View File

@@ -136,6 +136,7 @@ export function Prompt(props: PromptProps) {
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -296,6 +297,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -1124,6 +1136,12 @@ export function Prompt(props: PromptProps) {
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
if (kv.get("clear_prompt_save_history", false)) {
history.append({
...store.prompt,
mode: store.mode,
})
}
input.clear()
input.extmarks.clear()
setStore("prompt", {
@@ -1316,9 +1334,14 @@ export function Prompt(props: PromptProps) {
)}
</Show>
</box>
<Show when={hasRightContent()}>
<Show when={hasRightContent() || autoaccept() === "edit"}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={hasRightContent()}>{props.right}</Show>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</Show>
</box>

View File

@@ -110,6 +110,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
@@ -152,6 +153,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
void sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])

View File

@@ -191,6 +191,7 @@ export const TuiThreadCommand = cmd({
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||

View File

@@ -37,6 +37,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -93,8 +94,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -62,6 +62,7 @@ const KeybindsSchema = Schema.Struct({
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
permission_auto_accept_toggle: keybind("none", "Toggle auto-accept for edit permissions"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),

View File

@@ -48,19 +48,37 @@ import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import * as Effect from "effect/Effect"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
// Adjusts the default Config layer to ensure that plugins are always initialised before
// any other layers read the current config
const ConfigWithPluginPriority = Layer.effect(
Config.Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
return {
...config,
get: () => Effect.andThen(plugin.init(), config.get),
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
}
}),
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
ConfigWithPluginPriority,
Git.defaultLayer,
Ripgrep.defaultLayer,
File.defaultLayer,

View File

@@ -2,7 +2,7 @@ export * as ServerAuth from "./auth"
import { ConfigService } from "@/effect/config-service"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config as EffectConfig, Context, Layer, Option, Redacted } from "effect"
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
export type Credentials = {
password?: string
@@ -14,31 +14,10 @@ export type DecodedCredentials = {
readonly password: Redacted.Redacted
}
// Read auth config from `Flag.*` instead of via Effect's `Config` system.
// Effect's generated `defaultLayer` reads `Config.string(...)` once and is
// memoized by `Layer` identity, so subsequent runtime mutation of
// `process.env` is never observed by the resolved layer. Tests and dynamic
// deploys mutate `Flag.OPENCODE_SERVER_*` at runtime; matching Hono's
// behavior requires re-reading `Flag.*` whenever a fresh listener (i.e. a
// fresh `memoMap`) is built. `Layer.sync` defers the read until layer-build
// time, so each new listener picks up the current `Flag.*` values.
//
// Note: this is per-listener, not per-request. Hono's `AuthMiddleware` reads
// `Flag.*` on every request; if exact per-request parity is ever required,
// the middleware itself must read `Flag.*` rather than yielding `Config`.
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
}) {
static override get defaultLayer() {
return Layer.sync(this, () =>
this.of({
password: Flag.OPENCODE_SERVER_PASSWORD ? Option.some(Flag.OPENCODE_SERVER_PASSWORD) : Option.none(),
username: Flag.OPENCODE_SERVER_USERNAME ?? "opencode",
}),
)
}
}
}) {}
export type Info = Context.Service.Shape<typeof Config>

View File

@@ -45,9 +45,9 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { ServerAuth } from "@/server/auth"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi, eventHandlers } from "./event"

View File

@@ -339,7 +339,8 @@ export const Event = {
sessionID: Schema.optional(SessionID),
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
// the derived zod keeps the same discriminated-union shape on the bus.
error: MessageV2.Assistant.fields.error,
// Schema.suspend defers access to break circular init in compiled binaries.
error: Schema.suspend(() => MessageV2.Assistant.fields.error),
}),
),
}

View File

@@ -256,6 +256,8 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
return array(ast)
case "Declaration":
return decl(ast)
case "Suspend":
return z.lazy(() => walk(ast.thunk()))
default:
return fail(ast)
}

View File

@@ -229,8 +229,8 @@ test("agent permission config merges with defaults", async () => {
expect(build).toBeDefined()
// Specific pattern is denied
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
// Edit still asks (default behavior)
expect(evalPerm(build, "edit")).toBe("ask")
},
})
})

View File

@@ -40,8 +40,8 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
async function startNoAuthListener() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
@@ -257,20 +257,18 @@ describe("HttpApi Server.listen", () => {
}
})
for (const backend of ["effect-httpapi", "hono"] as const) {
testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener(backend)
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`))
ws.send(`ping-no-auth-${backend}\n`)
expect(await message).toContain(`ping-no-auth-${backend}`)
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
}
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
ws.send("ping-no-auth\n")
expect(await message).toContain("ping-no-auth")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
})

View File

@@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
@@ -11,19 +13,23 @@ import * as Log from "@opencode-ai/core/util/log"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(input: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input.password
Flag.OPENCODE_SERVER_USERNAME = input.username
const handler = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, {
disableLogger: true,
}).handler
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input.password,
OPENCODE_SERVER_USERNAME: input.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
@@ -42,9 +48,7 @@ async function cancelBody(response: Response) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await disposeAllInstances()
await resetDatabase()
})

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Layer, Option } from "effect"
import { ConfigProvider, Effect, Layer } from "effect"
import {
HttpClient,
HttpClientRequest,
@@ -50,10 +50,12 @@ function app(input?: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ServerAuth.Config.layer({
password: input?.password ? Option.some(input.password) : Option.none(),
username: input?.username ?? "opencode",
}),
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
@@ -69,10 +71,6 @@ function app(input?: { password?: string; username?: string }) {
}
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
const authConfigLayer = ServerAuth.Config.layer({
password: input?.password ? Option.some(input.password) : Option.none(),
username: input?.username ?? "opencode",
})
const handler = HttpRouter.toWebHandler(
HttpRouter.use((router) =>
Effect.gen(function* () {
@@ -81,11 +79,17 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer))),
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
Layer.provide([
AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
HttpServer.layerServices,
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
]),
),
{ disableLogger: true },

View File

@@ -1,4 +1,4 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
@@ -13,7 +13,6 @@ import {
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
@@ -26,7 +25,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle)
@@ -51,14 +49,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
@@ -92,27 +82,15 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff.options,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff.options,
},
workerPool,
)
fileDiffInstance = new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
workerPool,
)
applyViewerScheme(fileDiffRef)
@@ -163,8 +141,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(() => {
clearReadyWatcher(ready)
fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (

View File

@@ -1,6 +1,5 @@
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
type FileDiffMetadata,
@@ -10,10 +9,6 @@ import {
type FileOptions,
type LineAnnotation,
type SelectedLineRange,
type VirtualFileMetrics,
VirtualizedFile,
VirtualizedFileDiff,
Virtualizer,
} from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media"
@@ -40,19 +35,10 @@ import {
readShadowLineSelection,
} from "../pierre/file-selection"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24,
fileGap: 0,
} satisfies Partial<VirtualFileMetrics>
type SharedProps<T> = {
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
@@ -386,11 +372,6 @@ type AnnotationTarget<A> = {
rerender: () => void
}
type VirtualStrategy = {
get: () => Virtualizer | undefined
cleanup: () => void
}
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
@@ -532,64 +513,6 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
}
}
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
let virtualizer: Virtualizer | undefined
let root: Document | HTMLElement | undefined
const release = () => {
virtualizer?.cleanUp()
virtualizer = undefined
root = undefined
}
return {
get: () => {
if (!enabled()) {
release()
return
}
if (typeof document === "undefined") return
const wrapper = host()
if (!wrapper) return
const next = scrollParent(wrapper) ?? document
if (virtualizer && root === next) return virtualizer
release()
virtualizer = new Virtualizer()
root = next
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
return virtualizer
},
cleanup: release,
}
}
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
shared?.release()
shared = undefined
}
return {
get: () => {
if (shared) return shared.virtualizer
const container = host()
if (!container) return
const result = acquireVirtualizer(container)
if (!result) return
shared = result
return result.virtualizer
},
cleanup: release,
}
}
function parseLine(node: HTMLElement) {
if (!node.dataset.line) return
const value = parseInt(node.dataset.line, 10)
@@ -688,7 +611,7 @@ function ViewerShell(props: {
// ---------------------------------------------------------------------------
function TextViewer<T>(props: TextFileProps<T>) {
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
let instance: PierreFile<T> | undefined
let viewer!: Viewer
const [local, others] = splitProps(props, textKeys)
@@ -708,36 +631,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
return Math.max(1, total)
}
const bytes = createMemo(() => {
const value = local.file.contents as unknown
if (typeof value === "string") return value.length
if (Array.isArray(value)) {
return value.reduce(
// oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
0,
)
}
if (value == null) return 0
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
return String(value).length
})
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
const applySelection = (range: SelectedLineRange | null) => {
const current = instance
if (!current) return false
if (virtual()) {
current.setSelectedLines(range)
return true
}
const root = viewer.getRoot()
if (!root) return false
@@ -836,10 +735,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
const notify = () => {
notifyRendered({
viewer,
isReady: (root) => {
if (virtual()) return root.querySelector("[data-line]") != null
return root.querySelectorAll("[data-line]").length >= lineCount()
},
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
onReady: () => {
applySelection(viewer.lastSelection)
viewer.find.refresh({ reset: true })
@@ -858,17 +754,11 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
const virtualizer = virtuals.get()
renderViewer({
viewer,
current: instance,
create: () =>
isVirtual && virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
create: () => new PierreFile<T>(opts, workerPool),
assign: (value) => {
instance = value
},
@@ -895,7 +785,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
})
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
@@ -991,8 +880,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter,
)
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => {
if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("")
@@ -1055,7 +942,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const done = preserve(viewer)
@@ -1070,10 +956,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
renderViewer({
viewer,
current: instance,
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
create: () => new FileDiff<T>(opts, workerPool),
assign: (value) => {
instance = value
},
@@ -1111,7 +994,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined
})

View File

@@ -26,7 +26,6 @@ import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
export type SessionReviewDiffStyle = "unified" | "split"
@@ -159,14 +158,11 @@ type SessionReviewSelection = {
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
let focusToken = 0
let frame: number | undefined
const i18n = useI18n()
const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const nodes = new Map<string, HTMLDivElement>()
const [store, setStore] = createStore({
open: [] as string[],
visible: {} as Record<string, boolean>,
force: {} as Record<string, boolean>,
selection: null as SessionReviewSelection | null,
commenting: null as SessionReviewSelection | null,
@@ -196,44 +192,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
const syncVisible = () => {
frame = undefined
if (!scroll) return
const root = scroll.getBoundingClientRect()
const top = root.top - REVIEW_MOUNT_MARGIN
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
const openSet = new Set(open())
const next: Record<string, boolean> = {}
for (const [file, el] of nodes) {
if (!openSet.has(file)) continue
const rect = el.getBoundingClientRect()
if (rect.bottom < top || rect.top > bottom) continue
next[file] = true
}
const prev = untrack(() => store.visible)
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(next)
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
setStore("visible", next)
}
const queue = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(syncVisible)
}
const pinned = (file: string) =>
props.focusedComment?.file === file ||
props.focusedFile === file ||
selection()?.file === file ||
commenting()?.file === file ||
opened()?.file === file
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
queue()
const next = props.onScroll
if (!next) return
if (Array.isArray(next)) {
@@ -244,21 +203,9 @@ export const SessionReview = (props: SessionReviewProps) => {
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
}
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
createEffect(() => {
props.open
files()
queue()
})
const handleChange = (next: string[]) => {
props.onOpenChange?.(next)
if (props.open === undefined) setStore("open", next)
queue()
}
const handleExpandOrCollapseAll = () => {
@@ -372,7 +319,6 @@ export const SessionReview = (props: SessionReviewProps) => {
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
queue()
}}
onScroll={handleScroll}
classList={{
@@ -391,7 +337,6 @@ export const SessionReview = (props: SessionReviewProps) => {
const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0
const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
const force = () => !!store.force[file]
const comments = createMemo(() => grouped().get(file) ?? [])
@@ -482,8 +427,6 @@ export const SessionReview = (props: SessionReviewProps) => {
onCleanup(() => {
anchors.delete(file)
nodes.delete(file)
queue()
})
const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -569,19 +512,10 @@ export const SessionReview = (props: SessionReviewProps) => {
data-slot="session-review-diff-wrapper"
ref={(el) => {
anchors.set(file, el)
nodes.set(file, el)
queue()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={!mounted() && !tooLarge()}>
<div
data-slot="session-review-diff-placeholder"
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
style={{ height: "160px" }}
/>
</Match>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">

View File

@@ -1,100 +0,0 @@
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
type Target = {
key: Document | HTMLElement
root: Document | HTMLElement
content: HTMLElement | undefined
}
type Entry = {
virtualizer: Virtualizer
refs: number
}
const cache = new WeakMap<Document | HTMLElement, Entry>()
export const virtualMetrics: Partial<VirtualFileMetrics> = {
lineHeight: 24,
hunkSeparatorHeight: 24,
fileGap: 0,
}
function scrollable(value: string) {
return value === "auto" || value === "scroll" || value === "overlay"
}
function scrollRoot(container: HTMLElement) {
let node = container.parentElement
while (node) {
const style = getComputedStyle(node)
if (scrollable(style.overflowY)) return node
node = node.parentElement
}
}
function target(container: HTMLElement): Target | undefined {
if (typeof document === "undefined") return
const review = container.closest("[data-component='session-review']")
if (review instanceof HTMLElement) {
const root = scrollRoot(container) ?? review
const content = review.querySelector("[data-slot='session-review-container']")
return {
key: review,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
const root = scrollRoot(container)
if (root) {
const content = root.querySelector("[role='log']")
return {
key: root,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
return {
key: document,
root: document,
content: undefined,
}
}
export function acquireVirtualizer(container: HTMLElement) {
const resolved = target(container)
if (!resolved) return
let entry = cache.get(resolved.key)
if (!entry) {
const virtualizer = new Virtualizer()
virtualizer.setup(resolved.root, resolved.content)
entry = {
virtualizer,
refs: 0,
}
cache.set(resolved.key, entry)
}
entry.refs += 1
let done = false
return {
virtualizer: entry.virtualizer,
release() {
if (done) return
done = true
const current = cache.get(resolved.key)
if (!current) return
current.refs -= 1
if (current.refs > 0) return
current.virtualizer.cleanUp()
cache.delete(resolved.key)
},
}
}