mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
22 Commits
v2
...
pr-37174-rebase
| Author | SHA1 | Date | |
|---|---|---|---|
| bf61c972a1 | |||
| eda6b3eb51 | |||
| d103acd91d | |||
| 1c7be967c0 | |||
| dbf6739f40 | |||
| 55a4cc53f5 | |||
| dd2e67d003 | |||
| 7399dca484 | |||
| d7ccc7f9ad | |||
| f32029e8b7 | |||
| 3c2a473470 | |||
| 068b42576d | |||
| b021a58b4f | |||
| 6fa47feeda | |||
| cde8042178 | |||
| 198c824f68 | |||
| ef3037afeb | |||
| 86fd3c765e | |||
| 65eb47c639 | |||
| da04ac39dd | |||
| e731f1e8ba | |||
| 8b7d72d046 |
@@ -1,7 +1,8 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
@@ -28,7 +29,6 @@ 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,38 +49,60 @@ 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")(function* () {
|
||||
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
})
|
||||
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 update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
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 }))),
|
||||
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 }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
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 { parse, type ParseError } from "jsonc-parser"
|
||||
import { randomUUID } from "crypto"
|
||||
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import type { Info } from "./schema"
|
||||
import { 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
|
||||
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
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)
|
||||
}
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
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,
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
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) =>
|
||||
@@ -49,6 +147,16 @@ 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
|
||||
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(keybinds === undefined ? {} : { keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
@@ -21,7 +23,14 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
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",
|
||||
},
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
@@ -80,7 +95,13 @@ 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" })
|
||||
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")).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)
|
||||
@@ -141,6 +162,257 @@ 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')
|
||||
@@ -167,3 +439,15 @@ 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}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -148,6 +148,7 @@ const appBindingCommands = [
|
||||
"variant.cycle",
|
||||
"variant.list",
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
@@ -166,6 +167,7 @@ const appBindingCommands = [
|
||||
"app.toggle.file_context",
|
||||
"app.toggle.diffwrap",
|
||||
"app.toggle.paste_summary",
|
||||
"permission.mode",
|
||||
] as const
|
||||
|
||||
export type TuiInput = {
|
||||
|
||||
@@ -221,10 +221,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(",")
|
||||
}
|
||||
@@ -241,7 +241,6 @@ 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,2 +1,332 @@
|
||||
export * from "./v1/keybind"
|
||||
export * as TuiKeybind from "./v1/keybind"
|
||||
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] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +427,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.down",
|
||||
title: "Move diff viewer down",
|
||||
group: "VCS",
|
||||
bind: "j,down",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(1)
|
||||
@@ -442,7 +441,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.up",
|
||||
title: "Move diff viewer up",
|
||||
group: "VCS",
|
||||
bind: "k,up",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(-1)
|
||||
@@ -457,7 +455,6 @@ 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)
|
||||
@@ -472,7 +469,6 @@ 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)
|
||||
@@ -578,7 +574,6 @@ 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("subagent.interrupt")
|
||||
const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt")
|
||||
const interrupt = () => shortcut("session.interrupt")
|
||||
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
|
||||
const clearShortcut = () => shortcut("prompt.clear")
|
||||
@@ -610,10 +610,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "subagent.interrupt",
|
||||
id: "composer.subagent.interrupt",
|
||||
title: "Interrupt subagent",
|
||||
group: "Session",
|
||||
bind: "ctrl+d",
|
||||
run: () => {
|
||||
const current = selectedTab()
|
||||
if (current?.status !== "running") {
|
||||
|
||||
@@ -55,7 +55,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
id: "composer.shell.up",
|
||||
title: "Previous shell",
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
@@ -68,7 +67,6 @@ 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
|
||||
@@ -79,7 +77,6 @@ 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,7 +169,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
id: "composer.subagent.up",
|
||||
title: "Previous subagent",
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
@@ -182,7 +181,6 @@ 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
|
||||
@@ -193,7 +191,6 @@ 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 })
|
||||
@@ -213,7 +210,6 @@ 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,7 +40,6 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
{
|
||||
id: "dialog.prompt.submit",
|
||||
title: "Submit dialog prompt",
|
||||
bind: "return",
|
||||
group: "Dialog",
|
||||
run: confirm,
|
||||
},
|
||||
|
||||
@@ -294,3 +294,63 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/** @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)
|
||||
}
|
||||
}
|
||||
@@ -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,3 +135,29 @@ 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 { TuiKeybind } from "../../../src/config/keybind"
|
||||
import type { 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,6 +22,7 @@ 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([])
|
||||
@@ -49,7 +50,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([], 20, undefined, true)
|
||||
const viewer = await renderDiffViewer([], { fail: true })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
|
||||
@@ -59,7 +60,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([], 20, { type: "home" })
|
||||
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
|
||||
try {
|
||||
expect(viewer.vcsDiffInput()).toEqual({
|
||||
location: { directory: "/repo/default" },
|
||||
@@ -72,66 +73,35 @@ test("uses the active location when opened outside a session", async () => {
|
||||
})
|
||||
|
||||
test("brackets navigate diff hunks", async () => {
|
||||
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,
|
||||
)
|
||||
const viewer = await renderDiffViewer(hunkDiff, { height: 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
|
||||
|
||||
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
|
||||
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
const first = scroll.scrollTop
|
||||
expect(first).toBeGreaterThan(initial)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
const second = scroll.scrollTop
|
||||
expect(second).toBeGreaterThan(first)
|
||||
|
||||
viewer.commands.get("diff.previous_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("[")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(second)
|
||||
|
||||
scroll.scrollTo(initial)
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
} finally {
|
||||
@@ -139,13 +109,49 @@ test("brackets navigate diff hunks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
|
||||
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
|
||||
} = {},
|
||||
) {
|
||||
const commands = new Map<string, KeymapCommand>()
|
||||
let current = initialRoute ?? startRoute
|
||||
const [current, setCurrent] = createSignal<Route>(options.initialRoute ?? startRoute)
|
||||
const currentData = () => {
|
||||
const route = current()
|
||||
return route.type === "plugin" ? route.data : undefined
|
||||
}
|
||||
let renderDiff: Page["render"] | undefined
|
||||
let renderCommands: SlotClaim<"app">["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
let shortcut: (command: string) => string | undefined = () => undefined
|
||||
const config = createTuiResolvedConfig({ keybinds: options.keybinds })
|
||||
const transport = createFetch((url) => {
|
||||
if (url.pathname !== "/api/vcs/diff") return
|
||||
vcsDiffInput = {
|
||||
@@ -153,7 +159,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
mode: url.searchParams.get("mode"),
|
||||
context: url.searchParams.get("context"),
|
||||
}
|
||||
if (fail) return json({ message: "boom" }, { status: 500 })
|
||||
if (options.fail) return json({ message: "boom" }, { status: 500 })
|
||||
return json({
|
||||
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
||||
data: vcsDiff,
|
||||
@@ -161,61 +167,65 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
}, createEventStream())
|
||||
function Harness() {
|
||||
let theme: ReturnType<ReturnType<typeof useThemes>["currentTokens"]>
|
||||
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)
|
||||
})
|
||||
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" }) },
|
||||
},
|
||||
dispatch() {},
|
||||
shortcuts: () => [],
|
||||
mode: { current: () => "base", push: () => () => {} },
|
||||
},
|
||||
ui: {
|
||||
dialog: {
|
||||
show: () => () => {},
|
||||
set() {},
|
||||
clear() {},
|
||||
get theme() {
|
||||
return theme
|
||||
},
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
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
|
||||
return () => {}
|
||||
},
|
||||
navigate(destination: Destination) {
|
||||
current =
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination
|
||||
},
|
||||
current: () => current,
|
||||
},
|
||||
slot(claim: SlotClaim<"app">) {
|
||||
renderCommands = claim.render
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
} as unknown as Context
|
||||
|
||||
void diffViewerPlugin.setup(context)
|
||||
function Content() {
|
||||
theme = useThemes().currentTokens()
|
||||
void diffViewerPlugin.setup(context)
|
||||
const commandView = renderCommands?.({})
|
||||
if (current.type !== "plugin") commands.get("diff.open")?.run()
|
||||
return (
|
||||
<>
|
||||
{commandView}
|
||||
{renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
|
||||
{renderDiff?.({ data: currentData() })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -237,19 +247,60 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
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 })
|
||||
await app.waitFor(() => vcsDiffInput !== undefined)
|
||||
return {
|
||||
app,
|
||||
commands,
|
||||
current: () => current,
|
||||
current,
|
||||
shortcut: (command: string) => shortcut(command),
|
||||
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)
|
||||
@@ -280,11 +331,13 @@ const session = {
|
||||
}
|
||||
|
||||
test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
const viewer = await renderDiffViewer([], {
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
@@ -302,15 +355,3 @@ 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,15 +4,17 @@ 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", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({
|
||||
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
|
||||
mini: { replay: false, replay_limit: 50 },
|
||||
})
|
||||
expect(() => decode({ mini: { replay_limit: 0 } })).toThrow()
|
||||
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates the session tabs setting", () => {
|
||||
@@ -53,6 +55,105 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
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: {
|
||||
messages_page_up: "pgup",
|
||||
messages_page_down: "pgdown",
|
||||
"session.page.up": "pgup",
|
||||
"session.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"
|
||||
|
||||
test("down opens subagents from an empty prompt", async () => {
|
||||
async function renderSubagent(interrupt: "ctrl+i" | "none") {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
@@ -34,9 +34,17 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
forms: [],
|
||||
})
|
||||
const config = resolve(
|
||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||
{
|
||||
keybinds: {
|
||||
"prompt.editor": "none",
|
||||
"session.queued_prompts": "none",
|
||||
"composer.subagent.interrupt": interrupt,
|
||||
},
|
||||
},
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
const interrupted: string[] = []
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<Keymap.Provider config={config}>
|
||||
@@ -82,18 +90,47 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
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 app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
app.mockInput.pressArrow("down")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Select subagent")
|
||||
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([])
|
||||
} 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", input_clear: "ctrl+l" } }),
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }),
|
||||
state: { phase: "running" },
|
||||
mono: true,
|
||||
})
|
||||
|
||||
@@ -1,75 +1,14 @@
|
||||
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")))
|
||||
|
||||
@@ -86,12 +25,6 @@ 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,6 +5,7 @@ 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
|
||||
@@ -488,7 +489,7 @@ describe("run interactive runtime", () => {
|
||||
expect(closedTitle).toBe("Cached title")
|
||||
})
|
||||
|
||||
test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
|
||||
test("adopts deferred target placement and supplied TUI config", async () => {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const lifecycleStarted = defer<void>()
|
||||
const painted = defer<void>()
|
||||
@@ -498,6 +499,8 @@ 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" },
|
||||
})
|
||||
@@ -534,11 +537,13 @@ 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,
|
||||
@@ -577,6 +582,8 @@ 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