mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ad692d93 | |||
| c53f4cfb09 | |||
| 45a49ae32a | |||
| 238ce304a9 | |||
| cf606660fb | |||
| 1ebb37ebb0 |
@@ -0,0 +1,68 @@
|
|||||||
|
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Global } from "@opencode-ai/util/global"
|
||||||
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Config } from "../../config.js"
|
||||||
|
import { Formatter } from "../../formatter.js"
|
||||||
|
import { make, type Info } from "../../formatter/builtins.js"
|
||||||
|
import { Location } from "../../location.js"
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.config.formatter",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const npm = yield* Npm.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
|
const loaded = { entries: yield* config.entries() }
|
||||||
|
|
||||||
|
yield* formatter.transform((draft) => {
|
||||||
|
const configured = Config.latest(loaded.entries, "formatter")
|
||||||
|
if (!configured) return
|
||||||
|
const builtIns = make({
|
||||||
|
directory: location.directory,
|
||||||
|
worktree: location.project.directory,
|
||||||
|
fs,
|
||||||
|
npm,
|
||||||
|
processes,
|
||||||
|
bin: global.bin,
|
||||||
|
})
|
||||||
|
builtIns.forEach(draft.set)
|
||||||
|
if (configured === true) return
|
||||||
|
|
||||||
|
for (const [name, entry] of Object.entries(configured)) {
|
||||||
|
if (entry.disabled) {
|
||||||
|
draft.remove(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||||
|
const current: Info = {
|
||||||
|
name,
|
||||||
|
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||||
|
environment: { ...builtIn?.environment, ...entry.environment },
|
||||||
|
enabled:
|
||||||
|
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||||
|
}
|
||||||
|
draft.set(current)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* ctx.event.subscribe().pipe(
|
||||||
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
config.entries().pipe(
|
||||||
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
|
Effect.andThen(formatter.reload()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -1,10 +1,43 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
import type { DatabaseMigration } from "../migration.js"
|
import type { DatabaseMigration } from "../migration.js"
|
||||||
|
|
||||||
|
const previousV2Marker = "20260730195856_optional_session_title"
|
||||||
|
|
||||||
const migration: DatabaseMigration.Migration = {
|
const migration: DatabaseMigration.Migration = {
|
||||||
id: "20260804233008_loose_psylocke",
|
id: "20260804233008_loose_psylocke",
|
||||||
up(tx) {
|
up(tx) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
|
||||||
|
// are canonical, so rename them in place instead of replaying the V1 squash.
|
||||||
|
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
|
||||||
|
const v1Only = yield* tx.get(sql`
|
||||||
|
SELECT 1
|
||||||
|
FROM message
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
|
||||||
|
|
||||||
|
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
|
||||||
|
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
|
||||||
|
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
|
||||||
|
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
|
||||||
|
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
|
||||||
|
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||||
|
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||||
|
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||||
|
yield* tx.run(
|
||||||
|
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||||
|
)
|
||||||
|
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
|
||||||
|
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
|
||||||
|
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE IF NOT EXISTS \`kv\` (
|
CREATE TABLE IF NOT EXISTS \`kv\` (
|
||||||
\`key\` text PRIMARY KEY,
|
\`key\` text PRIMARY KEY,
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import { Context, Effect, Layer } from "effect"
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Config } from "./config.js"
|
|
||||||
import { Location } from "./location.js"
|
import { Location } from "./location.js"
|
||||||
import { make, type Info } from "./formatter/builtins.js"
|
import type { Info } from "./formatter/builtins.js"
|
||||||
|
import { State } from "./state.js"
|
||||||
|
|
||||||
export interface Interface {
|
type Data = {
|
||||||
|
formatters: Info[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Draft = {
|
||||||
|
set: (formatter: Info) => void
|
||||||
|
remove: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,54 +27,24 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const npm = yield* Npm.Service
|
|
||||||
const processes = yield* AppProcess.Service
|
const processes = yield* AppProcess.Service
|
||||||
const global = yield* Global.Service
|
|
||||||
const commands = new Map<string, string[] | false>()
|
const commands = new Map<string, string[] | false>()
|
||||||
let formatters: Info[] = []
|
const state = State.create<Data, Draft>({
|
||||||
|
name: "formatter",
|
||||||
const load = yield* Effect.cached(
|
initial: () => ({ formatters: [] }),
|
||||||
Effect.gen(function* () {
|
draft: (draft) => ({
|
||||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
set: (formatter) => {
|
||||||
if (!configured) {
|
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
|
||||||
yield* Effect.logInfo("all formatters are disabled")
|
if (index === -1) draft.formatters.push(formatter)
|
||||||
return
|
else draft.formatters[index] = formatter
|
||||||
}
|
},
|
||||||
|
remove: (name) => {
|
||||||
const builtIns = make({
|
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
|
||||||
directory: location.directory,
|
},
|
||||||
worktree: location.project.directory,
|
}),
|
||||||
fs,
|
finalize: () => Effect.sync(() => commands.clear()),
|
||||||
npm,
|
})
|
||||||
processes,
|
|
||||||
bin: global.bin,
|
|
||||||
})
|
|
||||||
formatters = builtIns
|
|
||||||
if (configured === true) return
|
|
||||||
|
|
||||||
for (const [name, entry] of Object.entries(configured)) {
|
|
||||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
|
||||||
if (entry.disabled) {
|
|
||||||
if (index !== -1) formatters.splice(index, 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
|
||||||
const formatter: Info = {
|
|
||||||
name,
|
|
||||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
|
||||||
environment: { ...builtIn?.environment, ...entry.environment },
|
|
||||||
enabled:
|
|
||||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
|
||||||
}
|
|
||||||
if (index === -1) formatters.push(formatter)
|
|
||||||
else formatters[index] = formatter
|
|
||||||
}
|
|
||||||
}).pipe(Effect.withSpan("Formatter.load")),
|
|
||||||
)
|
|
||||||
|
|
||||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||||
const cached = commands.get(formatter.name)
|
const cached = commands.get(formatter.name)
|
||||||
@@ -79,8 +55,9 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||||
yield* load
|
const matching = state
|
||||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
.get()
|
||||||
|
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||||
|
|
||||||
for (const formatter of matching) {
|
for (const formatter of matching) {
|
||||||
const enabled = yield* command(formatter)
|
const enabled = yield* command(formatter)
|
||||||
@@ -118,12 +95,12 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ file })
|
return Service.of({ transform: state.transform, reload: state.reload, file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
deps: [Location.node, AppProcess.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const layer = Layer.effect(
|
|||||||
` Workspace root folder: ${location.project.directory}`,
|
` Workspace root folder: ${location.project.directory}`,
|
||||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||||
"</env>",
|
"</env>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as PluginInternal from "./internal.js"
|
|||||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Context, Effect, Scope } from "effect"
|
import { Context, Effect, Scope } from "effect"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { Agent } from "../agent.js"
|
import { Agent } from "../agent.js"
|
||||||
@@ -12,6 +13,7 @@ import { Config } from "../config.js"
|
|||||||
import { Credential } from "../credential.js"
|
import { Credential } from "../credential.js"
|
||||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||||
|
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||||
@@ -74,6 +76,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
|
|||||||
|
|
||||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
const agent = yield* Agent.Service
|
const agent = yield* Agent.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const command = yield* Command.Service
|
const command = yield* Command.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
@@ -111,6 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
return Context.mergeAll(
|
return Context.mergeAll(
|
||||||
Context.make(Agent.Service, agent),
|
Context.make(Agent.Service, agent),
|
||||||
|
Context.make(AppProcess.Service, processes),
|
||||||
Context.make(Catalog.Service, catalog),
|
Context.make(Catalog.Service, catalog),
|
||||||
Context.make(Command.Service, command),
|
Context.make(Command.Service, command),
|
||||||
Context.make(Config.Service, config),
|
Context.make(Config.Service, config),
|
||||||
@@ -155,6 +159,7 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
|
|||||||
|
|
||||||
export const requirements = LayerNode.group([
|
export const requirements = LayerNode.group([
|
||||||
Agent.node,
|
Agent.node,
|
||||||
|
AppProcess.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
Command.node,
|
Command.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
@@ -224,6 +229,7 @@ const post = [
|
|||||||
ConfigReferencePlugin.Plugin,
|
ConfigReferencePlugin.Plugin,
|
||||||
ConfigAgentPlugin.Plugin,
|
ConfigAgentPlugin.Plugin,
|
||||||
ConfigCommandPlugin.Plugin,
|
ConfigCommandPlugin.Plugin,
|
||||||
|
ConfigFormatterPlugin.Plugin,
|
||||||
ConfigSkillPlugin.Plugin,
|
ConfigSkillPlugin.Plugin,
|
||||||
ConfigProviderPlugin.Plugin,
|
ConfigProviderPlugin.Plugin,
|
||||||
ConfigWebSearchPlugin.Plugin,
|
ConfigWebSearchPlugin.Plugin,
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
|||||||
runFork(
|
runFork(
|
||||||
handle.exitCode.pipe(
|
handle.exitCode.pipe(
|
||||||
Effect.flatMap((code) => finish("exited", code)),
|
Effect.flatMap((code) => finish("exited", code)),
|
||||||
Effect.catch(() => Effect.void),
|
Effect.catch(() => finish("exited")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||||
|
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
|
||||||
|
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
|
||||||
|
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
|
||||||
|
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
|
|
||||||
const run = <A, E>(
|
const run = <A, E>(
|
||||||
@@ -128,6 +132,142 @@ describe("DatabaseMigration", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves previous V2 state through the current migration lineage", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||||
|
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
INSERT INTO migration (id, time_completed)
|
||||||
|
VALUES ('20260730195856_optional_session_title', 1)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE project_directory (
|
||||||
|
project_id text NOT NULL,
|
||||||
|
directory text NOT NULL,
|
||||||
|
type text,
|
||||||
|
strategy text,
|
||||||
|
time_created integer NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, directory)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE workspace (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
type text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
project_id text NOT NULL,
|
||||||
|
time_used integer NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE session (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||||
|
workspace_id text,
|
||||||
|
parent_id text,
|
||||||
|
time_suspended integer
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
|
||||||
|
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
|
||||||
|
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
|
||||||
|
)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE session_message (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
|
||||||
|
data text NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE session_pending (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
CREATE TABLE event (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
|
||||||
|
seq integer NOT NULL,
|
||||||
|
created integer NOT NULL,
|
||||||
|
type text NOT NULL,
|
||||||
|
data text NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
|
||||||
|
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
|
||||||
|
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
|
||||||
|
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
|
||||||
|
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
|
||||||
|
|
||||||
|
yield* DatabaseMigration.applyOnly(db, [
|
||||||
|
previousV2Migration,
|
||||||
|
workspaceMigration,
|
||||||
|
executionClaimsMigration,
|
||||||
|
sessionInboxMigration,
|
||||||
|
worktreeMigration,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
|
||||||
|
id: "session",
|
||||||
|
resume_attempts: 0,
|
||||||
|
})
|
||||||
|
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
|
||||||
|
id: "message",
|
||||||
|
data: '{"text":"preserved"}',
|
||||||
|
})
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
|
||||||
|
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
|
||||||
|
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
|
||||||
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
|
||||||
|
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
|
||||||
|
expect.objectContaining({ table: "session_v2" }),
|
||||||
|
)
|
||||||
|
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
|
||||||
|
expect.objectContaining({ table: "session_v2" }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects previous V2 databases with V1-only session history", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||||
|
yield* db.run(sql`
|
||||||
|
INSERT INTO migration (id, time_completed)
|
||||||
|
VALUES ('20260730195856_optional_session_title', 1)
|
||||||
|
`)
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||||
|
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||||
|
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
|
||||||
|
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
|
||||||
|
|
||||||
|
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
|
||||||
|
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||||
|
name: "session",
|
||||||
|
})
|
||||||
|
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("copies project directories into worktrees without removing the old table", async () => {
|
test("copies project directories into worktrees without removing the old table", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,41 +1,30 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Info } from "@opencode-ai/schema/config"
|
||||||
import { Document, Info } from "@opencode-ai/schema/config"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Config } from "../src/config"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Formatter } from "../src/formatter"
|
import { Formatter } from "../src/formatter"
|
||||||
import { Location } from "../src/location"
|
import { Location } from "../src/location"
|
||||||
import { location } from "./fixture/location"
|
import { tempGlobalLayer } from "./fixture/global"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||||
|
[Global.node, tempGlobalLayer],
|
||||||
|
]),
|
||||||
|
)
|
||||||
type ConfigInput = typeof Info.Encoded
|
type ConfigInput = typeof Info.Encoded
|
||||||
|
|
||||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
|
||||||
const entries =
|
|
||||||
configured === undefined
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
return AppNodeBuilder.build(Formatter.node, [
|
|
||||||
[Config.node, Config.testLayer(entries)],
|
|
||||||
[
|
|
||||||
Location.node,
|
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
|
||||||
],
|
|
||||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -44,122 +33,166 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Formatter", () => {
|
function withFormatter<A, E, R>(
|
||||||
it.live("does not run formatters marked as disabled in config", () =>
|
configured: ConfigInput["formatter"],
|
||||||
withTemp((directory) =>
|
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
|
||||||
Effect.gen(function* () {
|
) {
|
||||||
const file = path.join(directory, "test.disabled")
|
return withTemp((directory) =>
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
Effect.promise(() =>
|
||||||
}).pipe(
|
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
|
||||||
Effect.provide(
|
).pipe(
|
||||||
formatterLayer(directory, {
|
Effect.andThen(
|
||||||
disabled: {
|
Effect.gen(function* () {
|
||||||
disabled: true,
|
const plugins = yield* PluginSupervisor.Service
|
||||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
yield* plugins.flush
|
||||||
extensions: [".disabled"],
|
return yield* body(yield* Formatter.Service, directory)
|
||||||
},
|
}).pipe(
|
||||||
}),
|
Effect.scoped,
|
||||||
|
Effect.provide(
|
||||||
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Formatter", () => {
|
||||||
|
it.live("does not run formatters marked as disabled in config", () =>
|
||||||
|
withFormatter(
|
||||||
|
{
|
||||||
|
disabled: {
|
||||||
|
disabled: true,
|
||||||
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
|
extensions: [".disabled"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(formatter, directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(directory, "test.disabled")
|
||||||
|
expect(yield* formatter.file(file)).toBe(false)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("file() returns false when no formatter runs", () =>
|
it.live("file() returns false when no formatter runs", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(false, (formatter, directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(directory, "test.txt")
|
const file = path.join(directory, "test.txt")
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
expect(yield* formatter.file(file)).toBe(false)
|
||||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("loads formatter state per directory", () =>
|
it.live("loads formatter state per directory", () =>
|
||||||
withTemp((off) =>
|
withFormatter(false, (disabledFormatter, off) =>
|
||||||
withTemp((on) =>
|
withFormatter(
|
||||||
Effect.gen(function* () {
|
{
|
||||||
const offFile = path.join(off, "test.isolated")
|
isolated: {
|
||||||
const onFile = path.join(on, "test.isolated")
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
extensions: [".isolated"],
|
||||||
Effect.provide(formatterLayer(off, false)),
|
},
|
||||||
)
|
},
|
||||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
(enabledFormatter, on) =>
|
||||||
Effect.provide(
|
Effect.gen(function* () {
|
||||||
formatterLayer(on, {
|
const offFile = path.join(off, "test.isolated")
|
||||||
isolated: {
|
const onFile = path.join(on, "test.isolated")
|
||||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
const disabled = yield* disabledFormatter.file(offFile)
|
||||||
extensions: [".isolated"],
|
const enabled = yield* enabledFormatter.file(onFile)
|
||||||
},
|
expect(disabled).toBe(false)
|
||||||
}),
|
expect(enabled).toBe(true)
|
||||||
),
|
}),
|
||||||
)
|
|
||||||
expect(disabled).toBe(false)
|
|
||||||
expect(enabled).toBe(true)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("stops after the first matching formatter succeeds", () =>
|
it.live("stops after the first matching formatter succeeds", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(
|
||||||
Effect.gen(function* () {
|
{
|
||||||
const file = path.join(directory, "test.seq")
|
first: {
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
command: [
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
process.execPath,
|
||||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
"-e",
|
||||||
}).pipe(
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||||
Effect.provide(
|
"$FILE",
|
||||||
formatterLayer(directory, {
|
],
|
||||||
first: {
|
extensions: [".seq"],
|
||||||
command: [
|
},
|
||||||
process.execPath,
|
second: {
|
||||||
"-e",
|
command: [
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
process.execPath,
|
||||||
"$FILE",
|
"-e",
|
||||||
],
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||||
extensions: [".seq"],
|
"$FILE",
|
||||||
},
|
],
|
||||||
second: {
|
extensions: [".seq"],
|
||||||
command: [
|
},
|
||||||
process.execPath,
|
},
|
||||||
"-e",
|
(formatter, directory) =>
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
Effect.gen(function* () {
|
||||||
"$FILE",
|
const file = path.join(directory, "test.seq")
|
||||||
],
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
extensions: [".seq"],
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
},
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("tries the next matching formatter when the first fails", () =>
|
it.live("tries the next matching formatter when the first fails", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(
|
||||||
|
{
|
||||||
|
first: {
|
||||||
|
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||||
|
extensions: [".fallback"],
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
command: [
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||||
|
"$FILE",
|
||||||
|
],
|
||||||
|
extensions: [".fallback"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(formatter, directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(directory, "test.fallback")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("rebuilds formatter state and clears resolved commands", () =>
|
||||||
|
withFormatter(false, (formatter, directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(directory, "test.fallback")
|
const command = { suffix: "A" }
|
||||||
|
yield* formatter.transform((draft) => {
|
||||||
|
const suffix = command.suffix
|
||||||
|
draft.set({
|
||||||
|
name: "reload",
|
||||||
|
extensions: [".reload"],
|
||||||
|
enabled: Effect.succeed([
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
|
||||||
|
"$FILE",
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const file = path.join(directory, "test.reload")
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
|
||||||
}).pipe(
|
command.suffix = "B"
|
||||||
Effect.provide(
|
yield* formatter.reload()
|
||||||
formatterLayer(directory, {
|
|
||||||
first: {
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
|
||||||
extensions: [".fallback"],
|
}),
|
||||||
},
|
|
||||||
second: {
|
|
||||||
command: [
|
|
||||||
process.execPath,
|
|
||||||
"-e",
|
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
|
||||||
"$FILE",
|
|
||||||
],
|
|
||||||
extensions: [".fallback"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
|
|||||||
` Workspace root folder: ${projectDirectory}`,
|
` Workspace root folder: ${projectDirectory}`,
|
||||||
" Is directory a git repo: yes",
|
" Is directory a git repo: yes",
|
||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||||
"</env>",
|
"</env>",
|
||||||
"",
|
"",
|
||||||
`Today's date: ${localDate(timestamp)}`,
|
`Today's date: ${localDate(timestamp)}`,
|
||||||
|
|||||||
@@ -782,6 +782,40 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isWindows) {
|
||||||
|
it.live("settles a shell terminated by an external signal", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
return withSession(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const shell = yield* Shell.Service
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call({ command: idleCommand, background: true }, "call-external-signal"),
|
||||||
|
)
|
||||||
|
const shellID = settled.metadata?.shellID
|
||||||
|
expect(typeof shellID).toBe("string")
|
||||||
|
if (typeof shellID !== "string") return
|
||||||
|
const id = ShellSchema.ID.make(shellID)
|
||||||
|
const info = yield* shell.get(id)
|
||||||
|
expect(typeof info.pid).toBe("number")
|
||||||
|
if (info.pid === undefined) return
|
||||||
|
|
||||||
|
process.kill(-info.pid, "SIGTERM")
|
||||||
|
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
|
||||||
|
expect(result._tag).toBe("Some")
|
||||||
|
if (result._tag === "Some") expect(result.value.status).toBe("exited")
|
||||||
|
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
it.live("backgrounds a foreground command when the session is signaled", () =>
|
it.live("backgrounds a foreground command when the session is signaled", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -452,7 +452,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Queue prompt",
|
title: "Queue prompt",
|
||||||
name: "prompt.queue",
|
name: "prompt.queue",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
palette: undefined,
|
|
||||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export const Definitions = {
|
|||||||
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
|
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
|
||||||
|
|
||||||
"prompt.submit": keybind("none", "Submit prompt"),
|
"prompt.submit": keybind("none", "Submit prompt"),
|
||||||
"prompt.queue": keybind("alt+return", "Queue prompt"),
|
"prompt.queue": keybind("<leader>return", "Queue prompt"),
|
||||||
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
||||||
"prompt.images.view": keybind("<leader>i", "View image attachments"),
|
"prompt.images.view": keybind("<leader>i", "View image attachments"),
|
||||||
"prompt.skills": keybind("none", "Open skill selector"),
|
"prompt.skills": keybind("none", "Open skill selector"),
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const Definitions = {
|
|||||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||||
|
|
||||||
prompt_submit: keybind("none", "Submit prompt"),
|
prompt_submit: keybind("none", "Submit prompt"),
|
||||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
prompt_queue: keybind("<leader>return", "Queue prompt"),
|
||||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||||
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||||
prompt_skills: keybind("none", "Open skill selector"),
|
prompt_skills: keybind("none", "Open skill selector"),
|
||||||
|
|||||||
@@ -1050,6 +1050,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
id: "prompt.queue",
|
id: "prompt.queue",
|
||||||
title: "Queue prompt",
|
title: "Queue prompt",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
|
palette: true,
|
||||||
run() {
|
run() {
|
||||||
syncDraft()
|
syncDraft()
|
||||||
submitPrompt(promptCopy(draft), "queue")
|
submitPrompt(promptCopy(draft), "queue")
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
{
|
{
|
||||||
id: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
title: "View queued prompts",
|
title: "View queued prompts",
|
||||||
group: "Session",
|
group: "Prompt",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1062,7 +1062,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||||||
{
|
{
|
||||||
title: "View queued prompts",
|
title: "View queued prompts",
|
||||||
id: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
group: "Session",
|
group: "Prompt",
|
||||||
enabled: queuedPrompts().length > 0,
|
enabled: queuedPrompts().length > 0,
|
||||||
run: openQueuedPrompts,
|
run: openQueuedPrompts,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ test("preserves migrated v1 keybind defaults", () => {
|
|||||||
const pairs = [
|
const pairs = [
|
||||||
["app.exit", "app_exit"],
|
["app.exit", "app_exit"],
|
||||||
["prompt.paste", "input_paste"],
|
["prompt.paste", "input_paste"],
|
||||||
|
["prompt.queue", "prompt_queue"],
|
||||||
["session.delete", "session_delete"],
|
["session.delete", "session_delete"],
|
||||||
["session.list", "session_list"],
|
["session.list", "session_list"],
|
||||||
["agent.list", "agent_list"],
|
["agent.list", "agent_list"],
|
||||||
|
|||||||
@@ -981,7 +981,8 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
app.mockInput.pressEnter({ meta: true })
|
app.mockInput.pressKey("x", { ctrl: true })
|
||||||
|
app.mockInput.pressEnter()
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
expect(steered).toEqual([])
|
expect(steered).toEqual([])
|
||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
@@ -1034,7 +1035,8 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
|
|||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
await app.mockInput.typeText("/settings ")
|
await app.mockInput.typeText("/settings ")
|
||||||
app.mockInput.pressEnter({ meta: true })
|
app.mockInput.pressKey("x", { ctrl: true })
|
||||||
|
app.mockInput.pressEnter()
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
expect(submitted).toEqual([])
|
expect(submitted).toEqual([])
|
||||||
expect(statuses).toContain("this prompt cannot be queued")
|
expect(statuses).toContain("this prompt cannot be queued")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
|
|||||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves shared config while resolving independent Mini defaults", async () => {
|
test("preserves shared config while resolving independent Mini defaults", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user