mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 12:45:07 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b22986b4 |
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
{
|
||||
id: "dialog.prompt.submit",
|
||||
title: "Submit dialog prompt",
|
||||
bind: "return",
|
||||
group: "Dialog",
|
||||
run: confirm,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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")
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user