Compare commits

..

1 Commits

Author SHA1 Message Date
James Long ff736fbbc8 feat(tui): coalesce equivalent forms 2026-08-06 17:48:30 +00:00
65 changed files with 306 additions and 374 deletions
+16 -2
View File
@@ -1937,9 +1937,23 @@ export type IntegrationInfo = {
connections: Array<ConnectionInfo>
}
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type FormInfo = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata
fields: FormFields
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type FormInfo1 = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata1
fields: FormFields1
}
export type SessionInputAdmitted = {
id: string
+1 -1
View File
@@ -11,7 +11,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b && tsgo --noEmit -p tsconfig.tests.json"
"typecheck": "tsgo --noEmit"
},
"bin": {
"opencode": "./bin/opencode"
+6 -10
View File
@@ -132,16 +132,14 @@ function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: ${JSON.stringify(name)},
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
`
}
@@ -149,15 +147,13 @@ function renderSchema(sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
const schema: Omit<DatabaseMigration.Migration, "id"> = {
export default {
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
}
export default schema
} satisfies Omit<DatabaseMigration.Migration, "id">
`
}
@@ -195,10 +191,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
export const migrations: DatabaseMigration.Migration[] = (
export const migrations = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
`
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DatabaseMigration } from "./migration"
export const migrations: DatabaseMigration.Migration[] = (
export const migrations = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
@@ -43,4 +43,4 @@ export const migrations: DatabaseMigration.Migration[] = (
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
])
).map((module) => module.default)
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260127222353_familiar_lady_ursula",
up(tx) {
return Effect.gen(function* () {
@@ -104,6 +104,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260211171708_add_project_commands",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260213144116_wakeful_the_professor",
up(tx) {
return Effect.gen(function* () {
@@ -20,6 +20,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260225215848_workspace",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260227213759_add_session_workspace_id",
up(tx) {
return Effect.gen(function* () {
@@ -9,6 +9,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260228203230_blue_harpoon",
up(tx) {
return Effect.gen(function* () {
@@ -27,6 +27,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260303231226_add_workspace_fields",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260309230000_move_org_to_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260312043431_session_message_cursor",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260323234822_events",
up(tx) {
return Effect.gen(function* () {
@@ -23,6 +23,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
@@ -26,6 +26,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260413175956_chief_energizer",
up(tx) {
return Effect.gen(function* () {
@@ -21,6 +21,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260423070820_add_icon_url_override",
up(tx) {
return Effect.gen(function* () {
@@ -11,6 +11,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260427172553_slow_nightmare",
up(tx) {
return Effect.gen(function* () {
@@ -27,6 +27,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DROP TABLE \`session_entry\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260428004200_add_session_path",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260501142318_next_venus",
up(tx) {
return Effect.gen(function* () {
@@ -9,6 +9,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260504145000_add_sync_owner",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260507164347_add_workspace_time",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260510033149_session_usage",
up(tx) {
return Effect.gen(function* () {
@@ -53,6 +53,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260511000411_data_migration_state",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260511173437_session-metadata",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
@@ -19,6 +19,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
@@ -21,6 +21,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260602182828_add_project_directories",
up(tx) {
return Effect.gen(function* () {
@@ -17,6 +17,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603001617_session_message_projection_indexes",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603040000_session_message_projection_order",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603141458_session_input_inbox",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603160727_jittery_ezekiel_stane",
up(tx) {
return Effect.gen(function* () {
@@ -17,6 +17,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260604172448_event_sourced_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -44,6 +44,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260605003541_add_session_context_snapshot",
up(tx) {
return Effect.gen(function* () {
@@ -18,6 +18,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260605042240_add_context_epoch_agent",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`agent\` text DEFAULT 'build' NOT NULL;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260611035744_credential",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260611192811_lush_chimera",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260612174303_project_dir_strategy",
up(tx) {
return Effect.gen(function* () {
@@ -26,6 +26,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
@@ -10,6 +10,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -14,6 +14,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
@@ -135,6 +135,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DROP TABLE \`session_input\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -30,14 +30,12 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(LegacyValue)
const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
},
}
export default migration
} satisfies DatabaseMigration.Migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
+2 -4
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
const schema: Omit<DatabaseMigration.Migration, "id"> = {
export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
@@ -248,6 +248,4 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
)
})
},
}
export default schema
} satisfies Omit<DatabaseMigration.Migration, "id">
+1
View File
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
id,
sessionID: input.sessionID,
title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
+6 -6
View File
@@ -82,10 +82,10 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const empty: ReadonlyArray<Source> = []
export const empty: Instructions = []
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
export function make<A>(source: Source.Definition<A>): ReadonlyArray<Source> {
export function make<A>(source: Source.Definition<A>): Instructions {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
@@ -121,7 +121,7 @@ export function make<A>(source: Source.Definition<A>): ReadonlyArray<Source> {
]
}
export function combine(values: ReadonlyArray<ReadonlyArray<Source>>): ReadonlyArray<Source> {
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flat()
const keys = new Set<Key>()
for (const source of sources) {
@@ -131,7 +131,7 @@ export function combine(values: ReadonlyArray<ReadonlyArray<Source>>): ReadonlyA
return sources
}
export function read(value: ReadonlyArray<Source>): Effect.Effect<ReadResult> {
export function read(value: Instructions): Effect.Effect<ReadResult> {
return Effect.forEach(
value,
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
@@ -158,7 +158,7 @@ export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Adm
return Effect.succeed({ delta, blobs })
}
export function renderInitial(value: ReadonlyArray<Source>, values: Readonly<Record<string, Schema.Json>>) {
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(values, source.key)) return []
@@ -169,7 +169,7 @@ export function renderInitial(value: ReadonlyArray<Source>, values: Readonly<Rec
}
export function renderUpdate(
value: ReadonlyArray<Source>,
value: Instructions,
previous: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
) {
@@ -59,6 +59,7 @@ export const Plugin = {
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
coalesce: `${context.messageID}:websearch-consent`,
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -91,6 +92,7 @@ export const Plugin = {
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" },
fields: [
{
+44 -48
View File
@@ -13,13 +13,54 @@ export abstract class NamedError extends Error {
static create<Name extends string, Fields extends Schema.Struct.Fields>(
name: Name,
fields: Fields,
): ReturnType<typeof createSchemaClass<Name, Schema.Struct<Fields>>>
): ReturnType<typeof NamedError.createSchemaClass<Name, Schema.Struct<Fields>>>
static create<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
): ReturnType<typeof createSchemaClass<Name, DataSchema>>
): ReturnType<typeof NamedError.createSchemaClass<Name, DataSchema>>
static create<Name extends string>(name: Name, data: Schema.Top | Schema.Struct.Fields) {
return createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data), this)
return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data))
}
private static createSchemaClass<Name extends string, DataSchema extends Schema.Top>(name: Name, data: DataSchema) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends NamedError {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return NamedError.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}
public static readonly Unknown = NamedError.create("UnknownError", {
@@ -27,48 +68,3 @@ export abstract class NamedError extends Error {
ref: Schema.optional(Schema.String),
})
}
function createSchemaClass<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
base: typeof NamedError = NamedError,
) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends base {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return base.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}
+2
View File
@@ -15,6 +15,7 @@ const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@@ -32,6 +33,7 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id)
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
{
sessionID,
title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" },
fields: [
{
+2 -11
View File
@@ -2,15 +2,6 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"emitDeclarationOnly": true,
"incremental": true,
"noEmit": false,
"noUncheckedIndexedAccess": false,
"outDir": "node_modules/.ts-dist/source",
"rootDir": "src",
"tsBuildInfoFile": "node_modules/.ts-dist/source.tsbuildinfo"
},
"include": ["src"]
"noUncheckedIndexedAccess": false
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"incremental": true,
"noUncheckedIndexedAccess": false,
"tsBuildInfoFile": "node_modules/.ts-dist/tests.tsbuildinfo"
},
"include": ["drizzle.config.ts", "script", "test"],
"references": [{ "path": "./tsconfig.json" }]
}
@@ -110,12 +110,12 @@ export type SQLiteEffectDelete<
export type AnySQLiteEffectDelete = SQLiteEffectDeleteBase<any, any, any, any, any, any>
export interface SQLiteEffectDeleteBase<
out TTable extends SQLiteTable,
out TRunResult,
out TReturning extends Record<string, unknown> | undefined = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
SQLWrapper,
Effect.Effect<
@@ -137,12 +137,12 @@ export interface SQLiteEffectDeleteBase<
}
export class SQLiteEffectDeleteBase<
out TTable extends SQLiteTable,
out TRunResult,
out TReturning extends Record<string, unknown> | undefined = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
@@ -126,9 +126,9 @@ export type SQLiteEffectInsert<
export type AnySQLiteEffectInsert = SQLiteEffectInsertBase<any, any, any, any, any, any>
export class SQLiteEffectInsertBuilder<
in out TTable extends SQLiteTable,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectInsertBuilder"
@@ -194,12 +194,12 @@ export class SQLiteEffectInsertBuilder<
}
export interface SQLiteEffectInsertBase<
in out TTable extends SQLiteTable,
out TRunResult,
out TReturning = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
@@ -221,12 +221,12 @@ export interface SQLiteEffectInsertBase<
}
export class SQLiteEffectInsertBase<
in out TTable extends SQLiteTable,
out TRunResult,
out TReturning = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
@@ -19,9 +19,9 @@ import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export class SQLiteEffectRelationalQueryBuilder<
out TSchema extends TablesRelationalConfig,
TSchema extends TablesRelationalConfig,
TFields extends TableRelationalConfig,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryBuilderV2"
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
TEffectHKT
>
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
export class SQLiteEffectSelectBuilder<
out TSelection extends SelectedFields | undefined,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TBuilderMode extends "db" | "qb" = "db",
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
@@ -152,18 +151,18 @@ export interface SQLiteEffectSelectHKT<TEffectHKT extends QueryEffectHKTBase = Q
}
export interface SQLiteEffectSelectBase<
out TTableName extends string | undefined,
out TRunResult,
out TSelection extends ColumnsSelection,
out TSelectMode extends SelectMode = "single",
out TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
out TDynamic extends boolean = false,
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
@@ -180,18 +179,18 @@ export interface SQLiteEffectSelectBase<
Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectSelectBase<
out TTableName extends string | undefined,
out TRunResult,
out TSelection extends ColumnsSelection,
out TSelectMode extends SelectMode = "single",
out TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
out TDynamic extends boolean = false,
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
}
}
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
export abstract class SQLiteEffectSession<
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TRunResult = unknown,
out TRelations extends AnyRelations = EmptyRelations,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
}
export abstract class SQLiteEffectTransaction<
out TEffectHKT extends QueryEffectHKTBase,
out TRunResult,
out TRelations extends AnyRelations = EmptyRelations,
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
@@ -158,9 +158,9 @@ export type SQLiteEffectUpdateJoinFn<T extends AnySQLiteEffectUpdate> = <
) => T
export class SQLiteEffectUpdateBuilder<
in out TTable extends SQLiteTable,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectUpdateBuilder"
@@ -193,13 +193,13 @@ export class SQLiteEffectUpdateBuilder<
}
export interface SQLiteEffectUpdateBase<
out TTable extends SQLiteTable = SQLiteTable,
out TRunResult = unknown,
out TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
out TReturning = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
@@ -222,13 +222,13 @@ export interface SQLiteEffectUpdateBase<
}
export class SQLiteEffectUpdateBase<
out TTable extends SQLiteTable = SQLiteTable,
out TRunResult = unknown,
out TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
out TReturning = undefined,
out TDynamic extends boolean = false,
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
interface ToolDraft {
add<
Input extends Tool.ValueSchema<any>,
Output extends Tool.ValueSchema<any> | undefined,
+3
View File
@@ -124,6 +124,9 @@ const InfoBase = {
// on non-session owners anywhere else.
sessionID: Schema.String,
title: Schema.String,
coalesce: Schema.String.pipe(optional).annotate({
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
}),
metadata: Metadata.pipe(optional),
}
+2 -2
View File
@@ -93,13 +93,13 @@ export function Home() {
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" />
</box>
<Show when={forms()[0]?.id} keyed>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} />
<FormPrompt form={form} forms={forms()} />
</box>
</box>
) : null
+39 -41
View File
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
}
}
export function FormPrompt(props: { form: FormWithLocation }) {
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
const client = useClient()
const themes = useThemes()
const theme = useTheme("elevated")
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | undefined
const forms = createMemo(() => {
if (!props.form.coalesce) return [props.form]
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
})
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", "")
}
function replySingle(field: FormAnswerField, value: FormValue) {
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: { [field.key]: value },
},
requestOptions(props.form),
function reply(answer: Record<string, FormValue>) {
Promise.all(
forms().map((form) =>
client.api.form.reply(
{
sessionID: form.sessionID,
formID: form.id,
answer,
},
requestOptions(form),
),
),
).catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
})
}
function replySingle(field: FormAnswerField, value: FormValue) {
reply({ [field.key]: value })
}
function pick(value: FormValue, customValue?: string) {
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function cancel() {
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
for (const form of forms())
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
}
function openExternal() {
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return
}
client.api.form
.reply(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
},
requestOptions(props.form),
)
.catch((error: unknown) => {
setStore(
"error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "Invalid answer",
)
})
reply(
Object.fromEntries(
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
),
)
}
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
+2 -2
View File
@@ -1026,10 +1026,10 @@ export function Session() {
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
return form ? <FormPrompt form={form} forms={forms()} /> : null
}}
</Show>
</Match>
+25 -3
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80) {
async function mountForm(root: string, width = 80, coalesce = false) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
const events = createEventStream()
const transport = createFetch(
(url, request) =>
url.pathname === "/api/session/ses_test/form/frm_test/reply"
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
...(coalesce ? { coalesce: "authorization" } : {}),
fields: [
{
key: "authorization",
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
</ToastProvider>
</ThemeProvider>
</ClientProvider>
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy()
}
})
test("replies to every coalesced form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, true)
try {
prompt.app.mockInput.pressKey("right")
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
prompt.app.mockInput.pressKey("left")
prompt.app.mockInput.pressKey("c")
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
} finally {
prompt.app.renderer.destroy()
}
})