Compare commits

..

1 Commits

Author SHA1 Message Date
Hona c5b22986b4 fix(core): respect repository git semantics 2026-08-12 07:38:01 +00:00
53 changed files with 510 additions and 2022 deletions
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
}
;(
window as Window & {
__sessionTabPaint?: {
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
}
).__sessionTabPaint = {
samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() => {
const probe = (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
).__sessionTabPaint
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
})
await page.waitForFunction(() =>
(
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
)
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (
window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number
stop: () => void
}
@@ -15,6 +15,8 @@ export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const requestedDirectory = Option.getOrUndefined(input.directory)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
@@ -34,8 +36,6 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
+31 -53
View File
@@ -1,8 +1,7 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@@ -29,6 +28,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,60 +49,38 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const lock = yield* restore(
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
)
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
return yield* restore(effect)
}),
),
)
const get = Effect.fn("cli.config.get")(() =>
withLock(
Effect.gen(function* () {
const migration = yield* migrate.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
),
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
withLock(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
+15 -123
View File
@@ -1,18 +1,13 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { Info } from "./schema"
import type { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
@@ -20,60 +15,7 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const config = Option.getOrUndefined(decodeRecord(value))
if (config === undefined) return
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
if (keybinds === undefined) return
const deduped = findKeybindObjects(text)
.slice(0, -1)
.reduce((text) => {
const property = findKeybindObjects(text)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
const updated = Object.keys(keybinds).reduce((text, name) => {
const target =
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
if (target === undefined) return text
const properties = findKeybindProperties(text, name)
if (!properties.length) return text
const remove = !(target in Definitions) || (target !== name && target in keybinds)
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
const property = findKeybindProperties(text, name)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
if (remove) return updated
if (target === name) return updated
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
if (key === undefined) return text
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
}, deduped)
if (updated === text) return
const updatedErrors: ParseError[] = []
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
if (updatedErrors.length || info === undefined) return
return yield* persist(updated, info)
}
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -81,59 +23,19 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
return result
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
})
function findKeybindProperties(text: string, name: string) {
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function findKeybindObjects(text: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
}
function removeProperty(text: string, property: Node) {
const properties = property.parent?.children ?? []
const index = properties.indexOf(property)
const end = property.offset + property.length
const next = properties[index + 1]
if (next) {
const comma = findComma(text, end, next.offset)
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
}
const previous = properties[index - 1]
if (previous) {
const comma = findComma(text, previous.offset + previous.length, property.offset)
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
}
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
return text.slice(0, property.offset) + text.slice(end)
}
function findComma(text: string, start: number, end: number) {
const scanner = createScanner(text, false)
scanner.setPosition(start)
while (true) {
scanner.scan()
const offset = scanner.getTokenOffset()
if (scanner.getTokenLength() === 0 || offset >= end) return
if (text[offset] === ",") return offset
}
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@@ -147,16 +49,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
const keybinds =
legacy?.keybinds === undefined
? undefined
: Object.fromEntries(
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
if (!(target in Definitions)) return []
return [[target, value]]
}),
)
return {
...(themeName !== undefined || themeMode !== undefined
@@ -167,7 +59,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
},
}
: {}),
...(keybinds === undefined ? {} : { keybinds }),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+4 -288
View File
@@ -1,9 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
import { Config } from "../src/config"
@@ -23,14 +21,7 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
keybinds: {
leader: "ctrl+o",
app_exit: "ctrl+q",
app_heap_snapshot: "ctrl+h",
input_paste: { key: "ctrl+v", preventDefault: false },
session_delete: false,
"dialog.select.next": "ctrl+n",
},
keybinds: { leader: "ctrl+o" },
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
@@ -74,13 +65,7 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
keybinds: { leader: "ctrl+o" },
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
@@ -95,13 +80,7 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
})
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -162,257 +141,6 @@ test("preserves legacy cursor settings", async () => {
}
})
test("migrates legacy keybind names in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
// Preserve this comment
"keybinds": {
// Session list shortcut
"session_list": "ctrl+l",
"app_heap_snapshot": "ctrl+h",
// Legacy delete shortcut
"session_delete": "ctrl+d",
// Canonical delete shortcut
"session.delete": "ctrl+x",
"app.heap_snapshot": "ctrl+shift+h"
}
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
const text = await Bun.file(file).text()
expect(text).toContain("// Preserve this comment")
expect(text).toContain("// Session list shortcut")
expect(text).toContain("// Legacy delete shortcut")
expect(text).toContain("// Canonical delete shortcut")
expect(parse(text).keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("uses migrated keybinds when persistence fails", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "rename") return () => Effect.die(new Error("read-only config"))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates and updates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes migration and updates across processes", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const migrateReady = path.join(directory, "migrate-ready")
const updateReady = path.join(directory, "update-ready")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(started, migrate.exited)
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
expect(await new Response(update.stderr).text()).toBe("")
expect([migrateCode, updateCode]).toEqual([0, 0])
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
update.kill()
await update.exited
}
} finally {
await Bun.write(release, "")
migrate.kill()
await migrate.exited
await Bun.$`rm -rf ${directory}`
}
})
test("config reads remain interruptible while waiting for the file lock", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const locks = path.join(directory, "locks")
const held = await Flock.acquire(file, { dir: locks })
try {
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
} finally {
await held.release()
await Bun.$`rm -rf ${directory}`
}
})
test("updates effective duplicate canonical keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
"session.delete": "changed",
"permission.mode": "changed",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("removes orphaned keybinds without deleting trailing comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
"keybinds": {
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
},
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({})
const text = await Bun.file(file).text()
expect(text).toContain("/* Keep legacy explanation */")
expect(text).toContain("/* Keep canonical explanation */")
expect(parse(text).keybinds).toEqual({})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -439,15 +167,3 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
return true
})(),
exited.then(() => false),
Bun.sleep(5000).then(() => false),
])
if (!found) throw new Error(`timed out waiting for ${file}`)
}
@@ -1,42 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Config } from "../../src/config"
const [mode, directory, started, release, ready] = process.argv.slice(2)
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
state.writes++
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
return Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
yield* node.writeFileString(target, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
await Bun.write(ready, "")
if (mode === "migrate") await Effect.runPromise(service.get())
if (mode === "update")
await Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
-69
View File
@@ -1,69 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, Option } from "effect"
import { expect, mock, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { Config } from "../src/config"
import type { MiniCommandInput } from "../src/mini"
import { OPENCODE_VERSION } from "../src/version"
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const configDirectory = path.join(root, "config")
const stateDirectory = path.join(root, "state")
await mkdir(configDirectory, { recursive: true })
await Bun.write(
path.join(configDirectory, "cli.json"),
JSON.stringify({
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
leader: { timeout: 321 },
}),
)
let received: MiniCommandInput["tuiConfig"]
const mini = await import("../src/mini")
mock.module("../src/mini", () => ({
...mini,
validateMiniTerminal() {},
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
received = input.tuiConfig
return Promise.resolve()
},
}))
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
})
try {
await Effect.runPromise(
handler({
server: Option.some(server.url.toString()),
standalone: false,
continue: false,
session: Option.none(),
fork: false,
replay: true as never,
replayLimit: Option.none(),
model: Option.none(),
agent: Option.none(),
prompt: Option.none(),
demo: false,
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
const config = await received
expect(config?.leader.timeout).toBe(321)
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
mock.restore()
await rm(root, { recursive: true, force: true })
}
})
-10
View File
@@ -358,15 +358,6 @@ export type Endpoint5_31Output =
readonly previous?: Model.Ref | undefined
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.move.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly move: SessionPending.MoveData }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -376,7 +367,6 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly moveID?: Event.ID | undefined
readonly location: Location.Ref
readonly projectID?: Project.ID | undefined
readonly subpath?: RelativePath | undefined
+2 -28
View File
@@ -420,8 +420,6 @@ export type SessionMessageLocationSwitched = {
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionPendingMoveData = { location: LocationRef; projectID: string; subpath?: string }
export type SessionCreated = {
id: string
created: number
@@ -470,7 +468,7 @@ export type SessionMoved = {
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; moveID?: string; location: LocationRef; projectID?: string; subpath?: string }
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionRenamed = {
@@ -1529,24 +1527,6 @@ export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionPendingMove = {
id: string
sessionID: string
timeCreated: number
type: "move"
data: SessionPendingMoveData
}
export type SessionMoveAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.move.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; move: SessionPendingMoveData }
}
export type SessionInfo = {
id: string
parentID?: string
@@ -1934,11 +1914,7 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionPendingInfo =
| SessionPendingUser
| SessionPendingSynthetic
| SessionPendingCompaction
| SessionPendingMove
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
@@ -2007,7 +1983,6 @@ export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoveAdmitted
| SessionMoved
| SessionRenamed
| SessionDeleted
@@ -2071,7 +2046,6 @@ export type V2Event =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoveAdmitted
| SessionMoved
| SessionRenamed
| SessionUsageUpdated
+56 -9
View File
@@ -22,10 +22,10 @@ const snapshotConfigFile = "opencode.gitconfig"
const snapshotConfigInclude = `[include]
path = ${snapshotConfigFile}
`
const snapshotConfig = `[core]
autocrlf = false
const snapshotConfig = (input: { autocrlf: string; symlinks: string }) => `[core]
autocrlf = ${input.autocrlf}
longpaths = true
symlinks = true
symlinks = ${input.symlinks}
fsmonitor = false
untrackedCache = true
[feature]
@@ -341,6 +341,46 @@ const layer = Layer.effect(
})
})
const sourceConfig = Effect.fnUntraced(function* (
repository: Repository | undefined,
key: string,
fallback: string,
allowInput: boolean,
) {
if (!repository) return fallback
const result = yield* execute(
repository.worktree,
proc,
)(["config", "--get", key]).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: repository.worktree,
message: `Failed to resolve ${key}`,
cause,
}),
),
)
if (result.exitCode === 0) {
const value = result.text.trim().toLowerCase()
if (allowInput && value === "input") return value
if (["true", "yes", "on", "1"].includes(value)) return "true"
if (["false", "no", "off", "0"].includes(value)) return "false"
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: `Invalid ${key} value: ${value}`,
})
}
if (result.exitCode === 1) return fallback
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: result.stderr.trim() || `Failed to resolve ${key}`,
})
})
const create = Effect.fn("Git.repo.create")(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
@@ -363,14 +403,20 @@ const layer = Layer.effect(
commonDirectory: input.gitDirectory,
})
yield* repositoryOperation("create", repository, ["init"])
yield* Effect.gen(function* () {
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
const semantics = {
autocrlf: yield* sourceConfig(input.seed, "core.autocrlf", "false", true),
symlinks: yield* sourceConfig(input.seed, "core.symlinks", "true", false),
}
const reseed = yield* Effect.gen(function* () {
const owned = path.join(input.gitDirectory, snapshotConfigFile)
const desired = snapshotConfig(semantics)
const previous = yield* fs.readFileString(owned).pipe(Effect.catch(() => Effect.succeed("")))
yield* fs.writeFileString(owned, desired)
const config = path.join(input.gitDirectory, "config")
const current = yield* fs.readFileString(config)
if (current.includes(snapshotConfigInclude)) return
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
const base = current.replace(/^\s*path\s*=\s*opencode\.gitconfig\s*\r?\n?/gm, "").trimEnd()
yield* fs.writeFileString(config, `${base}\n\n${snapshotConfigInclude}`)
return previous !== desired
}).pipe(
Effect.mapError(
(cause) =>
@@ -410,6 +456,7 @@ const layer = Layer.effect(
}),
),
)
if (!reseed) return repository
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.catch(() => Effect.void))
+13 -11
View File
@@ -185,8 +185,8 @@ export interface Interface {
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs,
* unhandled compaction barriers, and deferred moves.
* ordered by admission. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
@@ -738,21 +738,23 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const pending = yield* SessionPending.move(db, input.sessionID)
if (!pending && current.location.directory === directory && current.location.workspaceID === input.workspaceID)
return
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
yield* SessionPending.admitMove(db, bus, {
sessionID: input.sessionID,
source: current.location,
data: {
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
})
yield* execution.wake(input.sessionID)
{ location: current.location },
)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
+15 -26
View File
@@ -11,8 +11,6 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { Database } from "../database/database.js"
import { SessionPending } from "./pending.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -47,7 +45,6 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@@ -57,6 +54,7 @@ export const layer = Layer.effect(
Effect.annotateLogs({ sessionID }),
),
),
Effect.asVoid,
)
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
// transaction as the started event. Terminals release it — except shutdown interruption, which
@@ -74,7 +72,7 @@ export const layer = Layer.effect(
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
).pipe(Effect.asVoid),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -93,9 +91,11 @@ export const layer = Layer.effect(
sessionID,
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded")
if (outcome.type === "succeeded") {
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
if (outcome.type === "interrupted")
return
}
if (outcome.type === "interrupted") {
// A user cancel (or a superseding execution) releases the claim: the turn must not
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish(
@@ -103,27 +103,16 @@ export const layer = Layer.effect(
{ sessionID, reason: outcome.reason },
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
)
if (outcome.type === "failed")
yield* bus.publish(
SessionEvent.Execution.Failed,
{
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
)
if (outcome.type === "interrupted" && outcome.reason === "shutdown") return false
const pending = yield* SessionPending.move(db, sessionID)
if (!pending) return false
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return
}
yield* bus.publish(
SessionEvent.Moved,
{ sessionID, moveID: pending.id, ...pending.data },
{ location: session.location },
SessionEvent.Execution.Failed,
{
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
)
return yield* SessionPending.has(db, sessionID, "any")
}),
),
})
@@ -141,7 +130,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+1 -12
View File
@@ -7,8 +7,6 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { Database } from "../../database/database.js"
import { SessionPending } from "../pending.js"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
@@ -64,7 +62,6 @@ export const layer = (options?: Options) =>
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
@@ -106,14 +103,6 @@ export const layer = (options?: Options) =>
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
const claimed = new Set(orphaned)
yield* Effect.forEach(
(yield* SessionPending.moveSessions(db)).filter(
(sessionID) => !active.has(sessionID) && !claimed.has(sessionID),
),
execution.wake,
{ concurrency: "unbounded", discard: true },
)
}),
})
}),
@@ -122,5 +111,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Database.node],
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
@@ -106,7 +106,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.move.admitted": () => Effect.void,
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
+12 -118
View File
@@ -7,8 +7,6 @@ import {
Delivery,
Info,
Message,
Move,
MoveData,
Synthetic,
SyntheticData,
User,
@@ -21,11 +19,10 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
import { Event } from "@opencode-ai/schema/event"
type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Move, MoveData, Synthetic, SyntheticData, User, UserData }
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
@@ -38,8 +35,6 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMove = Schema.decodeUnknownSync(MoveData)
const encodeMove = Schema.encodeSync(MoveData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
@@ -47,24 +42,21 @@ type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionS
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
id: Schema.Union([SessionMessage.ID, Event.ID]),
id: SessionMessage.ID,
},
) {}
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "move")
return Move.make({ ...base, id: Event.ID.make(row.id), type: "move", data: decodeMove(row.data) })
const id = SessionMessage.ID.make(row.id)
if (row.type === "compaction") return Compaction.make({ ...base, id, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id })
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
if (row.type === "user")
return User.make({
...base,
id,
type: "user",
data: decodeUser(row.data),
delivery: row.delivery,
@@ -72,12 +64,11 @@ const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
if (row.type === "synthetic")
return Synthetic.make({
...base,
id,
type: "synthetic",
data: decodeSynthetic(row.data),
delivery: row.delivery,
})
throw new LifecycleConflict({ id })
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
@@ -107,44 +98,6 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
return entry.type === "compaction" ? entry : undefined
})
export const move = Effect.fn("SessionPending.move")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "move")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "move" ? entry : undefined
})
export const admitMove = Effect.fn("SessionPending.admitMove")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly data: MoveData; readonly source: MoveData["location"] },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const pending = yield* move(db, input.sessionID)
if (pending && JSON.stringify(encodeMove(pending.data)) === JSON.stringify(encodeMove(input.data))) return pending
const event = yield* bus.publish(
SessionEvent.MoveAdmitted,
{
sessionID: input.sessionID,
move: input.data,
},
{ location: input.source },
)
const stored = yield* move(db, input.sessionID)
if (stored) return stored
return yield* Effect.die(new LifecycleConflict({ id: event.id }))
}),
)
})
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -335,35 +288,6 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectMoveAdmitted = Effect.fn("SessionPending.projectMoveAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: Event.ID
readonly sessionID: SessionSchema.ID
readonly data: MoveData
readonly timeCreated: DateTime.Utc
},
) {
yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "move")))
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionPendingTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "move",
data: input.data,
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.run()
.pipe(Effect.orDie)
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
@@ -373,8 +297,7 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
db: DatabaseService,
input: PendingRef,
) {
if ((yield* compaction(db, input.sessionID)) || (yield* move(db, input.sessionID)))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@@ -383,8 +306,7 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(deleted)
if (stored.type === "compaction" || stored.type === "move")
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
@@ -452,33 +374,6 @@ export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(fun
return undefined
})
export const settleMove = Effect.fn("SessionPending.settleMove")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly id: Event.ID },
) {
yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.type, "move"),
),
)
.run()
.pipe(Effect.orDie)
})
export const moveSessions = Effect.fn("SessionPending.moveSessions")(function* (db: DatabaseService) {
const rows = yield* db
.select({ sessionID: SessionPendingTable.session_id })
.from(SessionPendingTable)
.where(eq(SessionPendingTable.type, "move"))
.all()
.pipe(Effect.orDie)
return [...new Set(rows.map((row) => row.sessionID))]
})
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
@@ -502,7 +397,7 @@ export const has = Effect.fn("SessionPending.has")(function* (
sessionID: SessionSchema.ID,
scope: Scope,
) {
if (scope !== "any" && ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID)))) return false
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
@@ -578,13 +473,12 @@ const publish = Effect.fn("SessionPending.publish")(function* (
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction" || entry.type === "move")
return Effect.die(new LifecycleConflict({ id: entry.id }))
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
@@ -618,7 +512,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
-15
View File
@@ -433,8 +433,6 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* InstructionState.reset(db, event.data.sessionID)
if (event.data.moveID)
yield* SessionPending.settleMove(db, { sessionID: event.data.sessionID, id: event.data.moveID })
}),
)
yield* bus.project(SessionEvent.Deleted, (event) =>
@@ -524,19 +522,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.MoveAdmitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionPending.projectMoveAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.id,
sessionID: event.data.sessionID,
data: event.data.move,
timeCreated: event.created,
})
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
+3 -24
View File
@@ -1,6 +1,6 @@
export * as SessionRunCoordinator from "./run-coordinator.js"
import { Cause, Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> {
@@ -50,17 +50,11 @@ export const make = <Key, E, Reason = never>(options: {
* Runs in the execution fiber for every exit, including interruption, after the final
* drain and before the execution settles (waiters resolve after it completes).
*/
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<boolean | void>
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
Effect.gen(function* () {
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const closing = { value: false }
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closing.value = true
}),
)
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
@@ -91,22 +85,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.onExit((exit) =>
Effect.sync(() => {
execution.owner = undefined
if (closing.value && Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) {
execution.stopping = true
execution.pendingWake = false
}
}).pipe(
Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void),
Effect.map(Boolean),
Effect.tap((restart) =>
restart && !execution.stopping
? Effect.sync(() => {
execution.pendingWake = true
})
: Effect.void,
),
Effect.asVoid,
),
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
-4
View File
@@ -143,7 +143,6 @@ const layer = Layer.effect(
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
if (yield* SessionPending.move(db, sessionID)) return
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
@@ -237,8 +236,6 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promotable && promoted === 0 && (yield* SessionPending.move(db, sessionID)))
return CallOutcome.Completed({ needsContinuation: false, step })
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
@@ -485,7 +482,6 @@ const layer = Layer.effect(
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
if (yield* SessionPending.move(db, sessionID)) return
const pending = yield* SessionPending.compaction(db, sessionID)
if (!pending) return
const session = yield* getSession(sessionID)
+2 -4
View File
@@ -96,15 +96,13 @@ export const SessionMessageTable = sqliteTable(
export const SessionPendingTable = sqliteTable(
"session_pending",
{
id: text().$type<SessionPending.Info["id"]>().primaryKey(),
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionPending.Info["type"]>().notNull(),
data: text({ mode: "json" })
.$type<UserData | SyntheticData | SessionPending.MoveData | Record<string, never>>()
.notNull(),
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
delivery: text().$type<SessionPending.Delivery>(),
admitted_seq: integer().notNull(),
time_created: integer()
+3 -5
View File
@@ -83,11 +83,9 @@ const layer = Layer.effect(
const gitDirectory = AbsolutePath.make(
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)),
)
const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory })
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
const snapshotRepository = yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
}).pipe(Effect.forkIn(lifetime)),
)
-4
View File
@@ -103,14 +103,10 @@ type GitOps = ReturnType<typeof makeGit>
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
+15 -2
View File
@@ -148,21 +148,34 @@ describe("Git trees", () => {
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!source) throw new Error("Repository not found")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(root.path).quiet())
const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage"))
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf false`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.symlinks true`.quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("true\n")
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.symlinks`.text()),
).toBe("false\n")
yield* Effect.promise(() => $`git config core.autocrlf input`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("input\n")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
(yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g),
).toHaveLength(1)
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()),
).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n")
).toBe("first.gitconfig\nsecond.gitconfig\nopencode.gitconfig\n")
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const before = yield* git.tree.write(repository)
+2 -105
View File
@@ -8,7 +8,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
@@ -17,16 +17,11 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Location } from "@opencode-ai/core/location"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@@ -138,104 +133,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("applies a deferred move only after the active execution settles", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_deferred_move")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Deferred.await(release))),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkIn(scope))
yield* Deferred.await(draining)
yield* bus.publish(SessionEvent.MoveAdmitted, {
sessionID,
move: {
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
projectID: Project.ID.global,
subpath: RelativePath.make(""),
},
})
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/project"))
expect((yield* SessionPending.move(database.db, sessionID))?.data.location.directory).toBe(
AbsolutePath.make("/destination"),
)
yield* Deferred.succeed(release, undefined)
yield* execution.awaitIdle(sessionID)
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/destination"))
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("settling one move preserves a newer admitted destination", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_move_replacement")
yield* seedSessions(database, [sessionID])
const first = {
location: Location.Ref.make({ directory: AbsolutePath.make("/first") }),
projectID: Project.ID.global,
subpath: RelativePath.make("first"),
}
const second = {
location: Location.Ref.make({ directory: AbsolutePath.make("/second") }),
projectID: Project.ID.global,
subpath: RelativePath.make("second"),
}
const admittedFirst = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: first })
const admittedSecond = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: second })
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedFirst.id, ...first })
expect((yield* store.get(sessionID))?.location.directory).toBe(first.location.directory)
expect((yield* SessionPending.move(database.db, sessionID))?.id).toBe(admittedSecond.id)
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedSecond.id, ...second })
expect((yield* store.get(sessionID))?.location.directory).toBe(second.location.directory)
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("recovers an unclaimed deferred move on startup", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_move_recovery")
yield* seedSessions(database, [sessionID])
yield* bus.publish(SessionEvent.MoveAdmitted, {
sessionID,
move: {
location: Location.Ref.make({ directory: AbsolutePath.make("/recovered") }),
projectID: Project.ID.global,
},
})
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.void)
const execution = Context.get(context, SessionExecution.Service)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* execution.awaitIdle(sessionID)
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/recovered"))
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
+17 -20
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
@@ -35,7 +34,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("durably admits a move when the source directory no longer exists", () =>
it.effect("moves a session whose source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -50,26 +49,24 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(
AbsolutePath.make(path.join(tmp.path, "deleted")),
)
expect(yield* session.pending(created.id)).toMatchObject([
{
type: "move",
data: { location: { directory: destination }, projectID: Project.ID.global },
},
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
subpath: "",
}),
])
const replacement = AbsolutePath.make(path.join(tmp.path, "replacement"))
yield* Effect.promise(() => fs.mkdir(replacement))
yield* session.move({ sessionID: created.id, directory: replacement })
expect(yield* session.pending(created.id)).toMatchObject([
{
type: "move",
data: { location: { directory: replacement }, projectID: Project.ID.global },
},
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
@@ -143,26 +143,19 @@ describe("SessionRunCoordinator", () => {
it.effect("cleans active executions when its scope closes", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => runs++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.wake("session")
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
return coordinator
}),
)
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1)
}),
)
@@ -524,31 +517,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("starts one successor when settlement requests it", () =>
Effect.scoped(
Effect.gen(function* () {
const successor = yield* Deferred.make<void>()
let drains = 0
let settlements = 0
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: () =>
Effect.sync(() => {
drains++
if (drains === 2) Deferred.doneUnsafe(successor, Effect.void)
}),
settled: () => Effect.sync(() => ++settlements === 1),
})
yield* coordinator.wake("session")
yield* Deferred.await(successor)
yield* coordinator.awaitIdle("session")
expect(drains).toBe(2)
expect(settlements).toBe(2)
}),
),
)
it.effect("trampolines synchronous self-waking execution", () =>
Effect.scoped(
Effect.gen(function* () {
+45
View File
@@ -127,6 +127,51 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("repairs existing storage with source repository semantics", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const file = path.join(project, "line-endings.txt")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(file, "before\n")
await initGit(project, true)
await $`git config core.autocrlf false`.cwd(project).quiet()
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
const source = yield* git.repo.discover(AbsolutePath.make(project))
if (!source) throw new Error("Repository not found")
const location = yield* Location.Service.pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot", location.project.id, Hash.fast(project))),
seed: source,
})
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(project).quiet()
await fs.rm(file)
await $`git checkout -- line-endings.txt`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
yield* Effect.promise(() => fs.writeFile(file, "before\n"))
expect(yield* snapshot.capture()).toBe(before)
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+36
View File
@@ -93,6 +93,42 @@ describe("Vcs", () => {
),
)
it.live("respects repository line ending configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "line-endings.txt"), "before\n")
await commitAll(directory, "line endings")
await fs.rm(path.join(directory, "line-endings.txt"))
await $`git checkout -- line-endings.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("respects repository symlink configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
const blob = await $`printf target.txt | git hash-object -w --stdin`.cwd(directory).text()
await $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(directory).quiet()
await $`git commit -m symlink`.cwd(directory).quiet()
await $`git config core.symlinks false`.cwd(directory).quiet()
await $`git checkout-index -f link.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
+1 -1
View File
@@ -308,7 +308,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "v2.session.move",
summary: "Move session",
description: "Move a session to another project directory after any active execution settles.",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
-12
View File
@@ -90,7 +90,6 @@ export const Moved = Event.durable({
...options,
schema: {
...Base,
moveID: Event.ID.pipe(optional),
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
@@ -98,16 +97,6 @@ export const Moved = Event.durable({
})
export type Moved = typeof Moved.Type
export const MoveAdmitted = Event.durable({
type: "session.move.admitted",
...options,
schema: {
...Base,
move: SessionPending.MoveData,
},
})
export type MoveAdmitted = typeof MoveAdmitted.Type
export const Renamed = Event.durable({
type: "session.renamed",
...options,
@@ -608,7 +597,6 @@ export const Definitions = Event.inventory(
Created,
AgentSelected,
ModelSelected,
MoveAdmitted,
Moved,
Renamed,
UsageUpdated,
+1 -21
View File
@@ -7,10 +7,6 @@ import { DateTimeUtcFromMillis } from "./schema.js"
import { SessionDelivery } from "./session-delivery.js"
import { SessionID } from "./session-id.js"
import { SessionMessage } from "./session-message.js"
import { Event } from "./event.js"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { RelativePath } from "./schema.js"
export const Delivery = SessionDelivery.Delivery
export type Delivery = SessionDelivery.Delivery
@@ -72,23 +68,7 @@ export const Compaction = Schema.Struct({
type: Schema.tag("compaction"),
}).annotate({ identifier: "SessionPending.Compaction" })
export interface MoveData extends Schema.Schema.Type<typeof MoveData> {}
export const MoveData = Schema.Struct({
location: Location.Ref,
projectID: Project.ID,
subpath: RelativePath.pipe(optional),
}).annotate({ identifier: "SessionPending.MoveData" })
export interface Move extends Schema.Schema.Type<typeof Move> {}
export const Move = Schema.Struct({
id: Event.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
type: Schema.tag("move"),
data: MoveData,
}).annotate({ identifier: "SessionPending.Move" })
export const Info = Schema.Union([User, Synthetic, Compaction, Move]).pipe(
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "SessionPending.Info" }),
)
@@ -90,16 +90,6 @@ describe("contract hygiene", () => {
})
})
test("pending moves omit absent placement details", () => {
expect(
Schema.encodeSync(SessionPending.MoveData)({
location: { directory: AbsolutePath.make("/project"), workspaceID: undefined },
projectID: Project.ID.global,
subpath: undefined,
}),
).toEqual({ location: { directory: "/project" }, projectID: "global" })
})
test("forms require at least one field", () => {
expect(() =>
Schema.decodeUnknownSync(Form.Info)({
@@ -78,7 +78,6 @@ describe("public event manifest", () => {
"session.deleted.2",
"session.agent.selected.1",
"session.model.selected.1",
"session.move.admitted.1",
"session.moved.1",
"session.renamed.1",
"session.usage.recorded.1",
-2
View File
@@ -150,7 +150,6 @@ const appBindingCommands = [
"variant.cycle",
"variant.list",
"provider.connect",
"opencode.settings",
"opencode.status",
"server.pair",
"service.restart",
@@ -169,7 +168,6 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"permission.mode",
] as const
export type TuiInput = {
+5 -4
View File
@@ -234,10 +234,10 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds["terminal.suspend"] = "none"
if (keybinds["input.undo"] === undefined) {
const inputUndo = TuiKeybind.defaultValue("input.undo")
keybinds["input.undo"] = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
keybinds.terminal_suspend = "none"
if (keybinds.input_undo === undefined) {
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
.filter((value, index, values) => values.indexOf(value) === index)
.join(",")
}
@@ -254,6 +254,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
+2 -332
View File
@@ -1,332 +1,2 @@
export * as TuiKeybind from "./keybind"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
import { Schema } from "effect"
const KeyStroke = Schema.Struct({
name: Schema.String,
ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
meta: Schema.optional(Schema.Boolean),
super: Schema.optional(Schema.Boolean),
hyper: Schema.optional(Schema.Boolean),
})
const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
export const BindingValueSchema = Schema.Union([
Schema.Literal(false),
Schema.Literal("none"),
BindingItem,
Schema.Array(BindingItem),
])
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
type Definition = {
default: BindingValueSchema
description: string
}
export const LeaderDefault = "ctrl+x"
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
"app.debug": keybind("none", "Toggle debug panel"),
"app.console": keybind("none", "Toggle console"),
"app.scrap": keybind("none", "Open scrap screen"),
"app.toggle.animations": keybind("none", "Toggle animations"),
"app.toggle.file_context": keybind("none", "Toggle file context"),
"app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"),
"app.toggle.paste_summary": keybind("none", "Toggle paste summary"),
"command.palette.show": keybind("ctrl+p", "List available commands"),
"help.show": keybind("none", "Open help dialog"),
"docs.open": keybind("none", "Open documentation"),
"opencode.settings": keybind("none", "Open settings"),
"server.pair": keybind("none", "Pair device"),
"service.restart": keybind("none", "Restart service"),
"permission.mode": keybind("none", "Toggle auto-approve permissions"),
"diff.open": keybind("none", "Open diff viewer"),
"diff.close": keybind("escape,q", "Close diff viewer"),
"diff.down": keybind("j,down", "Move diff viewer down"),
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
"opencode.status": keybind("<leader>s", "View status"),
"opencode.debug": keybind("none", "View debug info"),
"session.export": keybind("<leader>x", "Export session to editor"),
"session.copy": keybind("none", "Copy session transcript"),
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
"session.delete": keybind("ctrl+d", "Delete session"),
"session.share": keybind("none", "Share current session"),
"session.unshare": keybind("none", "Unshare current session"),
"session.interrupt": keybind("escape", "Interrupt current session"),
"session.background": keybind("ctrl+b", "Background blocking session tools"),
"session.compact": keybind("<leader>c", "Compact the session"),
"session.cd": keybind("none", "Change working directory"),
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.child.first": keybind("down", "Toggle subagent picker"),
"session.child.next": keybind("right", "Go to next child session"),
"session.child.previous": keybind("left", "Go to previous child session"),
"session.parent": keybind("up", "Go to parent session"),
"session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
"session.quick_switch.1": keybind("<leader>1", "Switch to session in quick slot 1"),
"session.quick_switch.2": keybind("<leader>2", "Switch to session in quick slot 2"),
"session.quick_switch.3": keybind("<leader>3", "Switch to session in quick slot 3"),
"session.quick_switch.4": keybind("<leader>4", "Switch to session in quick slot 4"),
"session.quick_switch.5": keybind("<leader>5", "Switch to session in quick slot 5"),
"session.quick_switch.6": keybind("<leader>6", "Switch to session in quick slot 6"),
"session.quick_switch.7": keybind("<leader>7", "Switch to session in quick slot 7"),
"session.quick_switch.8": keybind("<leader>8", "Switch to session in quick slot 8"),
"session.quick_switch.9": keybind("<leader>9", "Switch to session in quick slot 9"),
"session.tab.select.1": keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
"session.tab.select.2": keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
"session.tab.select.3": keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
"session.tab.select.4": keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
"session.tab.select.5": keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
"session.tab.select.6": keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
"model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"),
"model.list": keybind("<leader>m", "List available models"),
"model.cycle_recent": keybind("f2", "Next recently used model"),
"model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"),
"model.cycle_favorite": keybind("none", "Next favorite model"),
"model.cycle_favorite_reverse": keybind("none", "Previous favorite model"),
"mcp.list": keybind("none", "List MCP servers"),
"provider.connect": keybind("none", "Connect integration"),
"agent.list": keybind("<leader>a", "List agents"),
"agent.cycle": keybind("shift+tab", "Next agent"),
"agent.cycle.reverse": keybind("none", "Previous agent"),
"variant.cycle": keybind("ctrl+t", "Cycle model variants"),
"variant.list": keybind("none", "List model variants"),
"session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
"session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
"session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
"session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
"session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("alt+down", "Navigate to next message"),
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
"session.redo": keybind("<leader>r", "Redo message"),
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
"prompt.stash.list": keybind("none", "List stashed prompts"),
"prompt.clear": keybind("ctrl+c", "Clear input field"),
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
"input.submit": keybind("return", "Submit input"),
"input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
"input.move.up": keybind("up", "Move cursor up in input"),
"input.move.down": keybind("down", "Move cursor down in input"),
"input.select.left": keybind("shift+left", "Select left in input"),
"input.select.right": keybind("shift+right", "Select right in input"),
"input.select.up": keybind("shift+up", "Select up in input"),
"input.select.down": keybind("shift+down", "Select down in input"),
"input.line.home": keybind("ctrl+a", "Move to start of line in input"),
"input.line.end": keybind("ctrl+e", "Move to end of line in input"),
"input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"),
"input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"),
"input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"),
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
"input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
"input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
"input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
"input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
"input.undo": keybind("ctrl+-,super+z", "Undo in input"),
"input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
"input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
"input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
"input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
"input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
"input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
"input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
"input.select.all": keybind("super+a", "Select all in input"),
"prompt.history.previous": keybind("up", "Previous history item"),
"prompt.history.next": keybind("down", "Next history item"),
"composer.subagent.up": keybind("up", "Previous subagent"),
"composer.subagent.down": keybind("down", "Next subagent"),
"composer.subagent.select": keybind("return", "Navigate to subagent"),
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
"composer.shell.up": keybind("up", "Previous shell"),
"composer.shell.down": keybind("down", "Next shell"),
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
"plugins.list": keybind("none", "Open plugin manager dialog"),
"plugins.install": keybind("none", "Install plugin"),
"which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"),
"which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"),
"which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
"which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
"which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
"which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
"which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
"which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"),
"which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"),
"which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"),
"which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
const KeybindNames = new Set<string>(Object.keys(Definitions))
export const KeybindOverrides = Schema.Struct(
Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
]),
),
).annotate({ description: "TUI keybinding overrides" })
export const Descriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
) as Record<KeybindName, string>
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
export type KeybindOverrides = Partial<Keybinds>
export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
}
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
export function defaultValue(name: KeybindName) {
return Definitions[name].default
}
export function parse(keybinds: KeybindOverrides): Keybinds {
const invalid = unknownKeys(keybinds)
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
return Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
]),
) as Keybinds
}
export const Keybinds = { parse }
export function unknownKeys(input: object) {
return Object.keys(input).filter((key) => !KeybindNames.has(key))
}
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
return ({ command, binding }) => {
if (binding.desc !== undefined) return
return { desc: Descriptions[command as KeybindName] }
}
}
export * from "./v1/keybind"
export * as TuiKeybind from "./v1/keybind"
+1 -2
View File
@@ -201,8 +201,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || (item.type !== "user" && item.type !== "synthetic") || item.delivery === delivery)
return
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
@@ -427,6 +427,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.down",
title: "Move diff viewer down",
group: "VCS",
bind: "j,down",
run: focusRunner({
files() {
moveFileSelection(1)
@@ -441,6 +442,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.up",
title: "Move diff viewer up",
group: "VCS",
bind: "k,up",
run: focusRunner({
files() {
moveFileSelection(-1)
@@ -455,6 +457,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.down",
title: "Page diff viewer down",
group: "VCS",
bind: "pagedown,ctrl+f",
run: focusRunner({
files() {
moveFileSelection(8)
@@ -469,6 +472,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.up",
title: "Page diff viewer up",
group: "VCS",
bind: "pageup,ctrl+b",
run: focusRunner({
files() {
moveFileSelection(-8)
@@ -574,6 +578,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
group: "VCS",
bind: "m",
run() {
toggleSelectedFileReviewed()
},
+3 -2
View File
@@ -191,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const subagentShortcut = () => shortcut("session.child.first")
const queuedShortcut = () => shortcut("session.queued_prompts")
const backgroundShortcut = () => shortcut("session.background")
const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt")
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
const interrupt = () => shortcut("session.interrupt")
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
const clearShortcut = () => shortcut("prompt.clear")
@@ -610,9 +610,10 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
id: "composer.subagent.interrupt",
id: "subagent.interrupt",
title: "Interrupt subagent",
group: "Session",
bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
@@ -55,6 +55,7 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.up",
title: "Previous shell",
group: "Composer",
bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -67,6 +68,7 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.down",
title: "Next shell",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -77,6 +79,7 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.kill",
title: "Kill shell command",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry) return
@@ -169,6 +169,7 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.up",
title: "Previous subagent",
group: "Composer",
bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -181,6 +182,7 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.down",
title: "Next subagent",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -191,6 +193,7 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.select",
title: "Navigate to subagent",
group: "Composer",
bind: "return",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
@@ -210,6 +213,7 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
+1
View File
@@ -40,6 +40,7 @@ export function DialogPrompt(props: DialogPromptProps) {
{
id: "dialog.prompt.submit",
title: "Submit dialog prompt",
bind: "return",
group: "Dialog",
run: confirm,
},
-60
View File
@@ -294,63 +294,3 @@ test("session startup prompt is submitted exactly once", async () => {
await server.stop()
}
})
test("configured app bindings execute settings and permission commands", async () => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
keybinds: { "opencode.settings": "f6", "permission.mode": "f7" },
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("F6")
const settings = await setup.waitForFrame((frame) => frame.includes("Settings"))
expect(settings).toContain("Color mode")
expect(settings).toContain("Animations")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Settings"))
setup.mockInput.pressKey("F7")
await setup.renderOnce()
setup.mockInput.pressKey("p", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Commands"))
setup.mockInput.pressKey("END")
const commands = await setup.waitForFrame(
(frame) => {
if (frame.includes("Disable auto-approve permissions")) return true
setup.mockInput.pressArrow("up")
return false
},
{ maxPasses: 100 },
)
expect(commands).not.toContain("Enable auto-approve permissions")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
@@ -1,190 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import type { TuiKeybind } from "../../../src/config/keybind"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const sessions = {
parent: session("parent", "Parent"),
"child-a": session("child-a", "First", "parent"),
"child-b": session("child-b", "Second", "parent"),
}
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
const ready = Promise.withResolvers<void>()
let closed = 0
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
let route!: ReturnType<typeof useRoute>
const calls = createFetch((url, request) => {
if (url.pathname === "/api/session/active")
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] })
const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1]
if (interruptID && request.method === "POST") {
interrupted.push(interruptID)
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/shell" && request.method === "GET") {
const requestDirectory = url.searchParams.get("location[directory]") ?? directory
return json({
location: { directory: requestDirectory, project: { id: "proj_test", directory: requestDirectory } },
data: shells,
})
}
const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
if (shellID && request.method === "DELETE") {
removed.push(shellID)
return new Response(null, { status: 204 })
}
}, events)
function Content() {
const data = useData()
route = useRoute()
dispatch = Keymap.use().dispatch
onMount(() => {
void Promise.all([
data.session.sync("parent"),
data.session.sync("child-a"),
data.session.sync("child-b"),
data.shell.sync(),
])
.then(() => wait(() => data.session.status("child-a") === "running"))
.then(() => ready.resolve(), ready.reject)
})
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
}
const app = await testRender(
() => (
<TestTuiContexts directory={directory}>
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Content />
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 20, kittyKeyboard: true },
)
await ready.promise
await app.renderOnce()
return {
app,
interrupted,
removed,
route: () => route.data,
dispatch: (command: string) => dispatch(command),
closed: () => closed,
}
}
test("disabled subagent bindings have no component fallbacks", async () => {
const composer = await renderComposer("subagents", {
"composer.subagent.up": "none",
"composer.subagent.down": "none",
"composer.subagent.select": "none",
"composer.subagent.interrupt": "none",
})
try {
expect(composer.app.captureCharFrame()).toContain("First")
composer.app.mockInput.pressArrow("up")
composer.app.mockInput.pressEnter()
composer.app.mockInput.pressKey("d", { ctrl: true })
await composer.app.renderOnce()
expect(composer.closed()).toBe(0)
expect(composer.route()).toMatchObject({ type: "session", sessionID: "parent" })
expect(composer.interrupted).toEqual([])
composer.app.mockInput.pressArrow("down")
composer.dispatch("composer.subagent.select")
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
} finally {
composer.app.renderer.destroy()
}
})
test("disabled shell bindings have no component fallbacks", async () => {
const composer = await renderComposer("shell", {
"composer.shell.up": "none",
"composer.shell.down": "none",
"composer.shell.kill": "none",
})
try {
expect(composer.app.captureCharFrame()).toContain("bun test")
composer.app.mockInput.pressArrow("up")
composer.app.mockInput.pressKey("d", { ctrl: true })
await composer.app.renderOnce()
expect(composer.closed()).toBe(0)
expect(composer.removed).toEqual([])
composer.app.mockInput.pressArrow("down")
composer.dispatch("composer.shell.kill")
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
}
})
function session(id: string, title: string, parentID?: string) {
return {
id,
projectID: "proj_test",
title,
agent: "build",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
...(parentID ? { parentID } : {}),
}
}
function shell(id: string, command: string) {
return {
id,
status: "running" as const,
command,
cwd: directory,
shell: "/bin/sh",
file: `/tmp/${id}`,
metadata: { sessionID: "parent" },
time: { started: 1 },
}
}
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
+2 -12
View File
@@ -982,12 +982,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
await wait(() =>
data.session.pending
.list(sessionID)
.some(
(item) =>
item.id === "message-queued" &&
(item.type === "user" || item.type === "synthetic") &&
item.delivery === "steer",
),
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
@@ -1001,12 +996,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
await wait(() =>
data.session.pending
.list(sessionID)
.some(
(item) =>
item.id === "message-queued" &&
(item.type === "user" || item.type === "synthetic") &&
item.delivery === "queue",
),
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
@@ -87,8 +87,8 @@ test("dialog prompt submit wins when return is also input newline", async () =>
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
"input.submit": "super+return",
"input.newline": "return,shift+return,alt+return,ctrl+j",
input_submit: "super+return",
input_newline: "return,shift+return,alt+return,ctrl+j",
},
onConfirm: (value) => confirmed.push(value),
})
@@ -113,7 +113,7 @@ test("dialog prompt submit can be rebound separately from input submit", async (
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
"input.submit": "return",
input_submit: "return",
"dialog.prompt.submit": "ctrl+y",
},
onConfirm: (value) => confirmed.push(value),
@@ -135,29 +135,3 @@ test("dialog prompt submit can be rebound separately from input submit", async (
await prompt.cleanup()
}
})
test("dialog prompt submit can be disabled", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
"input.submit": "return",
"dialog.prompt.submit": "none",
},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter()
expect(confirmed).toEqual([])
expect(textarea.plainText).toBe("draft")
} finally {
await prompt.cleanup()
}
})
+109 -150
View File
@@ -14,7 +14,7 @@ import type {
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { ConfigProvider } from "../../../src/config"
import type { TuiKeybind } from "../../../src/config/keybind"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -22,7 +22,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createSignal } from "solid-js"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -50,7 +49,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
})
test("shows an error instead of an empty diff when loading fails", async () => {
const viewer = await renderDiffViewer([], { fail: true })
const viewer = await renderDiffViewer([], 20, undefined, true)
try {
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
@@ -60,7 +59,7 @@ test("shows an error instead of an empty diff when loading fails", async () => {
})
test("uses the active location when opened outside a session", async () => {
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
const viewer = await renderDiffViewer([], 20, { type: "home" })
try {
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/default" },
@@ -73,35 +72,66 @@ test("uses the active location when opened outside a session", async () => {
})
test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer(hunkDiff, { height: 12 })
const viewer = await renderDiffViewer(
[
{
file: "src/file.ts",
additions: 3,
deletions: 3,
status: "modified",
patch: `--- a/src/file.ts
+++ b/src/file.ts
@@ -1,3 +1,3 @@
const first = true
-const oldFirst = true
+const newFirst = true
const afterFirst = true
@@ -20,3 +20,3 @@
const second = true
-const oldSecond = true
+const newSecond = true
const afterSecond = true
@@ -40,3 +40,3 @@
const third = true
-const oldThird = true
+const newThird = true
const afterThird = true`,
},
],
12,
)
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@")
expect(countDiffs(viewer.app.renderer.root)).toBe(3)
const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop
viewer.app.mockInput.pressKey("]")
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
viewer.app.mockInput.pressKey("]")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
viewer.app.mockInput.pressKey("[")
viewer.commands.get("diff.previous_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
viewer.app.mockInput.pressKey("]")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
viewer.app.mockInput.pressKey("]")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
@@ -109,49 +139,13 @@ test("brackets navigate diff hunks", async () => {
}
})
test("disabled diff keybinds have no component fallbacks", async () => {
const viewer = await renderDiffViewer(hunkDiff, {
height: 12,
keybinds: disabledDiffKeybinds,
})
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush()
const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop
Object.keys(disabledDiffKeybinds).forEach((command) => expect(viewer.shortcut(command)).toBe(""))
viewer.app.mockInput.pressKey("j")
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(initial)
} finally {
viewer.app.renderer.destroy()
}
})
async function renderDiffViewer(
vcsDiff: unknown[],
options: {
height?: number
initialRoute?: Route
fail?: boolean
keybinds?: TuiKeybind.KeybindOverrides
} = {},
) {
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
const commands = new Map<string, KeymapCommand>()
const [current, setCurrent] = createSignal<Route>(options.initialRoute ?? startRoute)
const currentData = () => {
const route = current()
return route.type === "plugin" ? route.data : undefined
}
let current = initialRoute ?? startRoute
let renderDiff: Page["render"] | undefined
let renderCommands: SlotClaim<"app">["render"] | undefined
let vcsDiffInput: unknown
let shortcut: (command: string) => string | undefined = () => undefined
const config = createTuiResolvedConfig({ keybinds: options.keybinds })
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
vcsDiffInput = {
@@ -159,7 +153,7 @@ async function renderDiffViewer(
mode: url.searchParams.get("mode"),
context: url.searchParams.get("context"),
}
if (options.fail) return json({ message: "boom" }, { status: 500 })
if (fail) return json({ message: "boom" }, { status: 500 })
return json({
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
data: vcsDiff,
@@ -167,65 +161,61 @@ async function renderDiffViewer(
}, createEventStream())
function Harness() {
let theme: ReturnType<ReturnType<typeof useThemes>["currentTokens"]>
function Content() {
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
shortcut = shortcuts.get
theme = useThemes().currentTokens()
const context = {
options: {},
client: createApi(transport.fetch),
data: {
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
const context = {
options: {},
client: createApi(transport.fetch),
data: {
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
},
get theme() {
return theme
},
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
if (command.id) commands.set(command.id, command)
})
},
get theme() {
return theme
dispatch() {},
shortcuts: () => [],
mode: { current: () => "base", push: () => () => {} },
},
ui: {
dialog: {
show: () => () => {},
set() {},
clear() {},
},
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
if (command.id) commands.set(command.id, command)
})
Keymap.createLayer(input)
},
dispatch: keymap.dispatch,
shortcuts: shortcuts.list,
mode: keymap.mode,
},
ui: {
dialog: {
show: () => () => {},
set() {},
clear() {},
},
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {}
},
navigate(destination: Destination) {
setCurrent(
destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination,
)
},
current,
},
slot(claim: SlotClaim<"app">) {
renderCommands = claim.render
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {}
},
navigate(destination: Destination) {
current =
destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
},
current: () => current,
},
} as unknown as Context
slot(claim: SlotClaim<"app">) {
renderCommands = claim.render
return () => {}
},
},
} as unknown as Context
void diffViewerPlugin.setup(context)
void diffViewerPlugin.setup(context)
function Content() {
theme = useThemes().currentTokens()
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
<>
{commandView}
{renderDiff?.({ data: currentData() })}
{renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
</>
)
}
@@ -247,60 +237,19 @@ async function renderDiffViewer(
)
}
const app = await testRender(() => <Harness />, { width: 80, height: options.height ?? 20 })
for (let attempt = 0; attempt < 100; attempt++) {
await app.renderOnce()
if (current().type !== "plugin") commands.get("diff.open")?.run()
if (commands.has("diff.close")) break
await Bun.sleep(25)
}
await app.waitFor(() => commands.has("diff.close"), { maxPasses: 1 })
const app = await testRender(() => <Harness />, { width: 80, height })
await waitForCommand(app, commands, "diff.close")
await app.waitFor(() => vcsDiffInput !== undefined)
return {
app,
commands,
current,
shortcut: (command: string) => shortcut(command),
current: () => current,
vcsDiffInput: () => vcsDiffInput,
}
}
const startRoute: Route = { type: "session", sessionID: "session-1" }
const disabledDiffKeybinds = {
"diff.down": "none",
"diff.up": "none",
"diff.page.down": "none",
"diff.page.up": "none",
"diff.mark_reviewed": "none",
} satisfies TuiKeybind.KeybindOverrides
const hunkDiff = [
{
file: "src/file.txt",
additions: 3,
deletions: 3,
status: "modified",
patch: `--- a/src/file.txt
+++ b/src/file.txt
@@ -1,3 +1,3 @@
const first = true
-const oldFirst = true
+const newFirst = true
const afterFirst = true
@@ -20,3 +20,3 @@
const second = true
-const oldSecond = true
+const newSecond = true
const afterSecond = true
@@ -40,3 +40,3 @@
const third = true
-const oldThird = true
+const newThird = true
const afterThird = true`,
},
]
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
return root.getChildren().map(findScrollBox).find(Boolean)
@@ -331,13 +280,11 @@ const session = {
}
test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], {
initialRoute: {
type: "plugin",
id: "diff-viewer",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
},
const viewer = await renderDiffViewer([], 20, {
type: "plugin",
id: "diff-viewer",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
try {
expect(viewer.current()).toEqual({
@@ -355,3 +302,15 @@ test("branch diff source requests branch VCS diff", async () => {
viewer.app.renderer.destroy()
}
})
async function waitForCommand(
app: Awaited<ReturnType<typeof testRender>>,
commands: Map<string, unknown>,
command: string,
) {
for (let attempt = 0; attempt < 10; attempt++) {
await app.renderOnce()
if (commands.has(command)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
+5 -106
View File
@@ -4,17 +4,15 @@ import { expect, test } from "bun:test"
import { Schema } from "effect"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
import { settings } from "../src/component/dialog-config"
import { TuiKeybind } from "../src/config/keybind"
import { CommandMap, Definitions } from "../src/config/v1/keybind"
const decodeInfo = Schema.decodeUnknownSync(Info)
test("validates mini replay settings", () => {
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
})
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
expect(() => decode({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
})
test("validates the session tabs setting", () => {
@@ -62,105 +60,6 @@ test("shows the new session location default in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
})
test("uses command IDs as keybind keys", () => {
const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true })
expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }])
expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"])
expect(
Object.keys(TuiKeybind.Definitions)
.filter((key) => key !== "leader")
.every((key) => key.includes(".")),
).toBe(true)
})
test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
["prompt.paste", "input_paste"],
["session.delete", "session_delete"],
["session.list", "session_list"],
["agent.list", "agent_list"],
] as const
pairs.forEach(([command, name]) => {
expect(CommandMap[name]).toBe(command)
expect(TuiKeybind.Definitions[command].default).toEqual(Definitions[name].default)
})
})
test("accepts every v2-only named command ID", () => {
const commands = [
"server.pair",
"session.toggle.exploration_grouping",
"composer.subagent.up",
"composer.subagent.down",
"composer.subagent.select",
"composer.subagent.interrupt",
"composer.shell.up",
"composer.shell.down",
"composer.shell.kill",
"diff.down",
"diff.up",
"diff.page.down",
"diff.page.up",
"diff.mark_reviewed",
"opencode.settings",
"service.restart",
"permission.mode",
"session.cd",
"app.scrap",
]
const config = resolve(
decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }),
{ terminalSuspend: true },
)
commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }]))
})
test("centralizes named command defaults and resolves explicit none", () => {
const defaults = {
"composer.subagent.up": "up",
"composer.subagent.down": "down",
"composer.subagent.select": "return",
"composer.subagent.interrupt": "ctrl+d",
"composer.shell.up": "up",
"composer.shell.down": "down",
"composer.shell.kill": "ctrl+d",
"diff.down": "j,down",
"diff.up": "k,up",
"diff.page.down": "pagedown,ctrl+f",
"diff.page.up": "pageup,ctrl+b",
"diff.mark_reviewed": "m",
}
const config = resolve({}, { terminalSuspend: true })
Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
const disabled = resolve(
decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }),
{ terminalSuspend: true },
)
Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
})
test("rejects orphaned keybind definitions", () => {
expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
})
test("uses ctrl+z for input undo when terminal suspend is unavailable", () => {
const config = resolve({}, { terminalSuspend: false })
expect(config.keybinds.has("terminal.suspend")).toBe(false)
expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
const overridden = resolve(
{ keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } },
{ terminalSuspend: false },
)
expect(overridden.keybinds.has("terminal.suspend")).toBe(false)
expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
})
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
+2 -2
View File
@@ -2,6 +2,6 @@ import { expect, test } from "bun:test"
import { TuiKeybind } from "../src/config/keybind"
test("binds agent cycling only to shift+tab by default", () => {
expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab")
expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none")
expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab")
expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none")
})
+2 -2
View File
@@ -27,8 +27,8 @@ test("legacy page key aliases compile as page keys", async () => {
<ConfigProvider
config={createTuiResolvedConfig({
keybinds: {
"session.page.up": "pgup",
"session.page.down": "pgdown",
messages_page_up: "pgup",
messages_page_down: "pgdown",
},
})}
>
+7 -44
View File
@@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types"
async function renderSubagent(interrupt: "ctrl+i" | "none") {
test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
@@ -34,17 +34,9 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
forms: [],
})
const config = resolve(
{
keybinds: {
"prompt.editor": "none",
"session.queued_prompts": "none",
"composer.subagent.interrupt": interrupt,
},
},
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
const interrupted: string[] = []
function Harness() {
return (
<Keymap.Provider config={config}>
@@ -90,47 +82,18 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
onLayout={() => {}}
onStatus={() => {}}
onMiniSettingChange={() => {}}
onSubagentInterrupt={(sessionID) => interrupted.push(sessionID)}
/>
</Keymap.Provider>
)
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
return { app, interrupted }
}
async function openSubagent(app: Awaited<ReturnType<typeof testRender>>) {
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
app.mockInput.pressArrow("down")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Select subagent")
app.mockInput.pressEnter()
await app.renderOnce()
}
test("configured subagent key updates its hint and action", async () => {
const { app, interrupted } = await renderSubagent("ctrl+i")
try {
await openSubagent(app)
expect(app.captureCharFrame()).toContain("ctrl+i")
app.mockInput.pressKey("i", { ctrl: true })
expect(interrupted).toEqual(["subagent-1"])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
app.renderer.destroy()
}
})
test("disabled subagent interrupt has no component fallback", async () => {
const { app, interrupted } = await renderSubagent("none")
try {
await openSubagent(app)
expect(app.captureCharFrame()).not.toContain("ctrl+d")
app.mockInput.pressKey("d", { ctrl: true })
expect(interrupted).toEqual([])
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
app.mockInput.pressArrow("down")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Select subagent")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
+4 -4
View File
@@ -1072,7 +1072,7 @@ test.skip("direct footer recreates the frame across command panel transitions",
test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "<leader>t" } }),
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
@@ -1092,7 +1092,7 @@ test.skip("direct footer dispatches leader variant binding only when leader is r
test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "<leader>t" } }),
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
@@ -1603,7 +1603,7 @@ test("direct footer keeps the command hint at its minimum width", async () => {
test("direct footer keeps complete status text ahead of the spinner", async () => {
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }),
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }),
state: { phase: "running" },
width: 22,
})
@@ -1663,7 +1663,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }),
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
state: { phase: "running" },
mono: true,
})
@@ -1,14 +1,75 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "../../src/config"
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
function config(input?: {
leader?: string
leaderTimeout?: number
bindings?: Partial<{
commandList: string[]
variantCycle: string[]
interrupt: string[]
historyPrevious: string[]
historyNext: string[]
inputClear: string[]
inputSubmit: string[]
inputNewline: string[]
}>
}): Resolved {
const bind = input?.bindings
return createTuiResolvedConfig({
leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout },
keybinds: {
...(input?.leader && { leader: input.leader }),
...(bind?.commandList && { command_list: bind.commandList }),
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
...(bind?.historyNext && { history_next: bind.historyNext }),
...(bind?.inputClear && { input_clear: bind.inputClear }),
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
},
})
}
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
test("reads footer keybinds from resolved keybind config", async () => {
const input = config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
})
const result = await resolveRunTuiConfig(input)
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader.timeout).toBe(2000)
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
})
test("falls back to default tui keymap config when config load fails", async () => {
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
@@ -25,6 +86,12 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves disabled leader from resolved tui config", async () => {
const result = await resolveRunTuiConfig(config({ leader: "none" }))
expect(result.keybinds.get("leader")).toEqual([])
})
test("preserves shared config while resolving independent Mini defaults", async () => {
const result = await resolveRunTuiConfig(
createTuiResolvedConfig({
+1 -8
View File
@@ -5,7 +5,6 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
@@ -489,7 +488,7 @@ describe("run interactive runtime", () => {
expect(closedTitle).toBe("Cached title")
})
test("adopts deferred target placement and supplied TUI config", async () => {
test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
@@ -499,8 +498,6 @@ describe("run interactive runtime", () => {
let getDirectory: (() => string) | undefined
let findFiles: ((query: string) => Promise<string[]>) | undefined
let transportLocation: unknown
let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
})
@@ -537,13 +534,11 @@ describe("run interactive runtime", () => {
model: undefined,
variant: undefined,
files: [],
tuiConfig,
},
{
createRuntimeLifecycle: async (input) => {
getDirectory = input.getDirectory
findFiles = input.findFiles
runtimeConfig = input.tuiConfig
lifecycleStarted.resolve()
return {
footer: api,
@@ -582,8 +577,6 @@ describe("run interactive runtime", () => {
const query = { location: { directory: "/session", workspace: "work-1" } }
expect(getDirectory?.()).toBe("/session")
if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config")
expect(await runtimeConfig).toBe(tuiConfig)
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })