chore: merge dev
This commit is contained in:
commit
d525663171
28 changed files with 3744 additions and 3051 deletions
|
|
@ -88,8 +88,17 @@ const names = (() => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
if (musl) return arch === "x64" ? (baseline ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]) : [`${base}-musl`, base]
|
if (musl)
|
||||||
return arch === "x64" ? (baseline ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]) : [base, `${base}-musl`]
|
return arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||||
|
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||||
|
: [`${base}-musl`, base]
|
||||||
|
return arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||||
|
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||||
|
: [base, `${base}-musl`]
|
||||||
}
|
}
|
||||||
return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base]
|
return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base]
|
||||||
})()
|
})()
|
||||||
|
|
@ -98,10 +107,11 @@ function findBinary(startDir) {
|
||||||
let current = startDir
|
let current = startDir
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const modules = path.join(current, "node_modules")
|
const modules = path.join(current, "node_modules")
|
||||||
if (fs.existsSync(modules)) for (const name of names) {
|
if (fs.existsSync(modules))
|
||||||
const candidate = path.join(modules, name, "bin", binary)
|
for (const name of names) {
|
||||||
if (fs.existsSync(candidate)) return candidate
|
const candidate = path.join(modules, name, "bin", binary)
|
||||||
}
|
if (fs.existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
const parent = path.dirname(current)
|
const parent = path.dirname(current)
|
||||||
if (parent === current) return
|
if (parent === current) return
|
||||||
current = parent
|
current = parent
|
||||||
|
|
@ -110,7 +120,11 @@ function findBinary(startDir) {
|
||||||
|
|
||||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
console.error("It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + names.map((name) => `"${name}"`).join(" or ") + " package")
|
console.error(
|
||||||
|
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
|
||||||
|
names.map((name) => `"${name}"`).join(" or ") +
|
||||||
|
" package",
|
||||||
|
)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
run(resolved)
|
run(resolved)
|
||||||
|
|
|
||||||
|
|
@ -44,5 +44,9 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.all(Object.entries(binaries).map(([name, version]) => publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version)))
|
await Promise.all(
|
||||||
|
Object.entries(binaries).map(([name, version]) =>
|
||||||
|
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||||
|
),
|
||||||
|
)
|
||||||
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ export type Input<Value> =
|
||||||
: never
|
: never
|
||||||
|
|
||||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||||
type Loader<Node extends Spec.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> }>
|
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||||
|
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
|
||||||
|
}>
|
||||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||||
|
|
||||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,9 @@ export const layer = Layer.effect(
|
||||||
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
|
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
|
||||||
const temp = file + ".tmp"
|
const temp = file + ".tmp"
|
||||||
yield* fs.makeDirectory(directory, { recursive: true })
|
yield* fs.makeDirectory(directory, { recursive: true })
|
||||||
yield* fs.writeFileString(
|
yield* fs.writeFileString(temp, JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }), {
|
||||||
temp,
|
mode: 0o600,
|
||||||
JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }),
|
})
|
||||||
{ mode: 0o600 },
|
|
||||||
)
|
|
||||||
yield* fs.rename(temp, file)
|
yield* fs.rename(temp, file)
|
||||||
// The metadata file represents this live listener, not persistent config.
|
// The metadata file represents this live listener, not persistent config.
|
||||||
// Scope shutdown removes it when the server exits normally.
|
// Scope shutdown removes it when the server exits normally.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
DELETE FROM `session_message`;--> statement-breakpoint
|
||||||
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
|
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
|
||||||
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
|
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
|
||||||
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
|
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@ export const Plugin = PluginV2.define({
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const transform = yield* command.transform()
|
const transform = yield* command.transform()
|
||||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||||
return loadDirectory(fs, entry.path).pipe(
|
return loadDirectory(fs, entry.path).pipe(
|
||||||
Effect.map((commands) => [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]),
|
Effect.map((commands) => [
|
||||||
)
|
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||||
}).pipe(Effect.map((documents) => documents.flat()))
|
]),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.map((documents) => documents.flat()))
|
||||||
|
|
||||||
yield* transform((editor) => {
|
yield* transform((editor) => {
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ export const Plugin = PluginV2.define({
|
||||||
|
|
||||||
yield* transform((editor) => {
|
yield* transform((editor) => {
|
||||||
for (const directory of directories) {
|
for (const directory of directories) {
|
||||||
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
|
editor.source(
|
||||||
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||||
|
)
|
||||||
|
editor.source(
|
||||||
|
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,10 @@ export default {
|
||||||
id: "20260603040000_session_message_projection_order",
|
id: "20260603040000_session_message_projection_order",
|
||||||
up(tx) {
|
up(tx) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL DEFAULT 0;`)
|
// Pre-launch Session projections were written before durable event persistence
|
||||||
yield* tx.run(
|
// became unconditional, so they cannot be assigned truthful aggregate order.
|
||||||
`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`,
|
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||||
)
|
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL;`)
|
||||||
const unmatched = yield* tx.get<{ count: number }>(
|
|
||||||
`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`,
|
|
||||||
)
|
|
||||||
if ((unmatched?.count ?? 0) > 0)
|
|
||||||
return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
|
|
||||||
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = \`seq\` - 1;`)
|
|
||||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
|
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||||
yield* tx.run(
|
yield* tx.run(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export const Plugin = PluginV2.define({
|
||||||
effect: Effect.gen(function* () {
|
effect: Effect.gen(function* () {
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const transform = yield* skill.transform()
|
const transform = yield* skill.transform()
|
||||||
const content = yield* Effect.promise(() => Bun.file(new URL("./skill/customize-opencode.md", import.meta.url)).text())
|
const content = yield* Effect.promise(() =>
|
||||||
|
Bun.file(new URL("./skill/customize-opencode.md", import.meta.url)).text(),
|
||||||
|
)
|
||||||
|
|
||||||
yield* transform((editor) => {
|
yield* transform((editor) => {
|
||||||
editor.source(
|
editor.source(
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,11 @@ export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource])
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
|
key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
|
||||||
source.type === "directory" ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` : `embedded:${source.skill.name}`,
|
source.type === "directory"
|
||||||
|
? `directory:${source.path}`
|
||||||
|
: source.type === "url"
|
||||||
|
? `url:${source.url}`
|
||||||
|
: `embedded:${source.skill.name}`,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
export type Source = typeof Source.Type
|
export type Source = typeof Source.Type
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,20 @@ describe("DatabaseMigration", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("backfills projected Session message order from durable event sequence", async () => {
|
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||||
|
)
|
||||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
|
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||||
)
|
)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
|
sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
|
||||||
|
|
@ -93,40 +100,40 @@ describe("DatabaseMigration", () => {
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
|
sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
|
||||||
)
|
)
|
||||||
yield* db.run(sql`INSERT INTO event (id, seq) VALUES ('evt_z', 0), ('evt_a', 1)`)
|
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_z', 'session', 'user', 0, '{}'), ('evt_a', 'session', 'user', 0, '{}')`,
|
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`,
|
||||||
|
)
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id, seq FROM session_message ORDER BY seq`)).toEqual([
|
expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([
|
||||||
{ id: "evt_z", seq: 0 },
|
{ id: "legacy_message", session_id: "session", data: '{"role":"user"}' },
|
||||||
{ id: "evt_a", seq: 1 },
|
|
||||||
])
|
])
|
||||||
|
expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([
|
||||||
|
{
|
||||||
|
id: "legacy_part",
|
||||||
|
message_id: "legacy_message",
|
||||||
|
session_id: "session",
|
||||||
|
data: '{"type":"text","text":"hello"}',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
|
||||||
|
|
||||||
|
yield* db.run(
|
||||||
|
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
|
||||||
|
)
|
||||||
|
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("fails projected Session message order backfill without a durable event", async () => {
|
|
||||||
await expect(
|
|
||||||
run(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
|
||||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
|
||||||
yield* db.run(
|
|
||||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
|
|
||||||
)
|
|
||||||
yield* db.run(
|
|
||||||
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_missing', 'session', 'user', 0, '{}')`,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).rejects.toThrow("Cannot migrate session_message projections without matching durable events")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("runs session usage backfill in order with schema changes", async () => {
|
test("runs session usage backfill in order with schema changes", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@ const project = AbsolutePath.make("/repo")
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
CommandV2.locationLayer.pipe(
|
CommandV2.locationLayer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.succeed(
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -26,7 +23,10 @@ describe("CommandPlugin.Plugin", () => {
|
||||||
const command = yield* CommandV2.Service
|
const command = yield* CommandV2.Service
|
||||||
yield* CommandPlugin.Plugin.effect.pipe(
|
yield* CommandPlugin.Plugin.effect.pipe(
|
||||||
Effect.provideService(CommandV2.Service, command),
|
Effect.provideService(CommandV2.Service, command),
|
||||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
|
Effect.provideService(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(yield* command.get("init")).toMatchObject({
|
expect(yield* command.get("init")).toMatchObject({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ACPSession } from "./session"
|
import { ACPSession } from "./session"
|
||||||
import { ACPPermission } from "./permission"
|
import { ACPPermission } from "./permission"
|
||||||
|
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||||
import {
|
import {
|
||||||
duplicateRunningToolUpdate,
|
duplicateRunningToolUpdate,
|
||||||
errorToolUpdate,
|
errorToolUpdate,
|
||||||
|
|
@ -87,7 +88,31 @@ export class Subscription {
|
||||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
await this.handleToolPart(message.info.sessionID, part)
|
await this.handleToolPart(message.info.sessionID, part)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
await this.replayContentPart(message, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replayContentPart(message: SessionMessageResponse, part: Part) {
|
||||||
|
if (part.type !== "text" && part.type !== "file" && part.type !== "reasoning") return
|
||||||
|
|
||||||
|
const sessionUpdate =
|
||||||
|
part.type === "reasoning"
|
||||||
|
? "agent_thought_chunk"
|
||||||
|
: message.info.role === "user"
|
||||||
|
? "user_message_chunk"
|
||||||
|
: "agent_message_chunk"
|
||||||
|
|
||||||
|
for (const chunk of partsToContentChunks([part as ReplayPart])) {
|
||||||
|
await this.input.connection.sessionUpdate({
|
||||||
|
sessionId: message.info.sessionID,
|
||||||
|
update: {
|
||||||
|
sessionUpdate,
|
||||||
|
messageId: message.info.id,
|
||||||
|
...chunk,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { useSDK } from "@tui/context/sdk"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { useToast } from "@tui/ui/toast"
|
import { useToast } from "@tui/ui/toast"
|
||||||
import { useCommandShortcut } from "@tui/keymap"
|
import { useCommandShortcut } from "@tui/keymap"
|
||||||
import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { DialogSessionRename } from "@tui/component/dialog-session-rename"
|
import { DialogSessionRename } from "@tui/component/dialog-session-rename"
|
||||||
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
|
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
|
||||||
|
|
@ -31,6 +32,7 @@ export function SessionSwitcherDialog() {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||||
const deleteHint = useCommandShortcut("session.delete")
|
const deleteHint = useCommandShortcut("session.delete")
|
||||||
|
|
@ -151,11 +153,6 @@ export function SessionSwitcherDialog() {
|
||||||
if (!first || !last) return undefined
|
if (!first || !last) return undefined
|
||||||
return quickSwitchRange(first, last)
|
return quickSwitchRange(first, last)
|
||||||
})
|
})
|
||||||
const quickSwitchFooterHints = createMemo(() => {
|
|
||||||
const hint = quickSwitchHint()
|
|
||||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = createMemo<DialogSelectOption<string>[]>(() => {
|
const options = createMemo<DialogSelectOption<string>[]>(() => {
|
||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
const sessionMap = new Map(
|
const sessionMap = new Map(
|
||||||
|
|
@ -183,10 +180,18 @@ export function SessionSwitcherDialog() {
|
||||||
const status = sync.data.session_status?.[x.id]
|
const status = sync.data.session_status?.[x.id]
|
||||||
const isWorking = status?.type === "busy" || status?.type === "retry"
|
const isWorking = status?.type === "busy" || status?.type === "retry"
|
||||||
const slot = slotByID.get(x.id)
|
const slot = slotByID.get(x.id)
|
||||||
const gutter = isWorking
|
const gutter =
|
||||||
? () => <Spinner />
|
slot !== undefined || isWorking
|
||||||
: slot !== undefined
|
? () => (
|
||||||
? () => <text fg={theme.accent}>{slot}</text>
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Show when={slot !== undefined}>
|
||||||
|
<text fg={theme.accent}>{slot}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={isWorking}>
|
||||||
|
<Spinner />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
|
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
|
||||||
return {
|
return {
|
||||||
|
|
@ -194,6 +199,17 @@ export function SessionSwitcherDialog() {
|
||||||
bg: isDeleting ? theme.error : undefined,
|
bg: isDeleting ? theme.error : undefined,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
category,
|
category,
|
||||||
|
categoryView:
|
||||||
|
category === "Pinned" ? (
|
||||||
|
<text>
|
||||||
|
<span style={{ fg: theme.accent }}>
|
||||||
|
<b>Pinned</b>
|
||||||
|
</span>
|
||||||
|
<Show when={quickSwitchHint()}>
|
||||||
|
{(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
) : undefined,
|
||||||
footer,
|
footer,
|
||||||
gutter,
|
gutter,
|
||||||
}
|
}
|
||||||
|
|
@ -224,8 +240,11 @@ export function SessionSwitcherDialog() {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
const showPreview = createMemo(() => dimensions().width >= 100)
|
||||||
dialog.setSize("xlarge")
|
const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
dialog.setSize(showPreview() ? "xlarge" : "large")
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
|
|
@ -253,6 +272,7 @@ export function SessionSwitcherDialog() {
|
||||||
title: "pin/unpin",
|
title: "pin/unpin",
|
||||||
onTrigger: (option: { value: string }) => {
|
onTrigger: (option: { value: string }) => {
|
||||||
local.session.togglePin(option.value)
|
local.session.togglePin(option.value)
|
||||||
|
queueMicrotask(() => select?.moveTo(option.value))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -311,19 +331,20 @@ export function SessionSwitcherDialog() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
footerHints={quickSwitchFooterHints()}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" width="100%">
|
<box flexDirection="row" width="100%" height={height()}>
|
||||||
<box flexBasis={68} flexShrink={0}>
|
<box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
|
||||||
{list}
|
{list}
|
||||||
</box>
|
</box>
|
||||||
<box width={1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
|
<Show when={showPreview()}>
|
||||||
<box flexGrow={1} flexShrink={1} flexDirection="column">
|
<box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
|
||||||
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
|
<box flexGrow={1} flexShrink={1} flexDirection="column">
|
||||||
</box>
|
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
|
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
|
||||||
import { TextAttributes, type RGBA } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
|
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
|
||||||
import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util"
|
import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
|
||||||
|
|
||||||
type WithParts = { info: Message; parts: Part[] }
|
type WithParts = { info: Message; parts: Part[] }
|
||||||
|
|
||||||
|
|
@ -16,7 +16,6 @@ type Sdk = ReturnType<typeof useSDK>
|
||||||
type Sync = ReturnType<typeof useSync>
|
type Sync = ReturnType<typeof useSync>
|
||||||
|
|
||||||
const messageCache = new Map<string, Promise<WithParts[]>>()
|
const messageCache = new Map<string, Promise<WithParts[]>>()
|
||||||
const diffCache = new Map<string, Promise<SnapshotFileDiff[]>>()
|
|
||||||
|
|
||||||
function cacheKey(sessionID: string, version: number) {
|
function cacheKey(sessionID: string, version: number) {
|
||||||
return `${sessionID}:${version}`
|
return `${sessionID}:${version}`
|
||||||
|
|
@ -36,41 +35,21 @@ function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<Wit
|
||||||
const promise = sdk.client.session
|
const promise = sdk.client.session
|
||||||
.messages({ sessionID, limit: 50 })
|
.messages({ sessionID, limit: 50 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.error) messageCache.delete(key)
|
if (res.error) throw res.error
|
||||||
return (res.data as WithParts[] | undefined) ?? []
|
return (res.data as WithParts[] | undefined) ?? []
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
messageCache.delete(key)
|
messageCache.delete(key)
|
||||||
return [] as WithParts[]
|
throw error
|
||||||
})
|
})
|
||||||
messageCache.set(key, promise)
|
messageCache.set(key, promise)
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDiff(sdk: Sdk, sessionID: string, version: number): Promise<SnapshotFileDiff[]> {
|
|
||||||
const key = cacheKey(sessionID, version)
|
|
||||||
const cached = diffCache.get(key)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const promise = sdk.client.session
|
|
||||||
.diff({ sessionID })
|
|
||||||
.then((res) => {
|
|
||||||
if (res.error) diffCache.delete(key)
|
|
||||||
return (res.data as SnapshotFileDiff[] | undefined) ?? []
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
diffCache.delete(key)
|
|
||||||
return [] as SnapshotFileDiff[]
|
|
||||||
})
|
|
||||||
diffCache.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
|
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
|
||||||
for (const id of sessionIDs) {
|
for (const id of sessionIDs) {
|
||||||
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
|
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
|
||||||
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
|
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
|
||||||
if (!sync.data.session_diff[id]?.length) loadDiff(sdk, id, version).catch(() => {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,13 +100,6 @@ export function SessionPreviewPane(props: {
|
||||||
return hydrateFromSync(sync, id)
|
return hydrateFromSync(sync, id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const syncedDiff = createMemo(() => {
|
|
||||||
const id = props.sessionID()
|
|
||||||
if (!id) return undefined
|
|
||||||
const diff = sync.data.session_diff[id]
|
|
||||||
return diff && diff.length > 0 ? (diff as SnapshotFileDiff[]) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const [fetchedMessages] = createResource(
|
const [fetchedMessages] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const id = props.sessionID()
|
const id = props.sessionID()
|
||||||
|
|
@ -137,31 +109,7 @@ export function SessionPreviewPane(props: {
|
||||||
async (input) => loadMessages(sdk, input.sessionID, input.version),
|
async (input) => loadMessages(sdk, input.sessionID, input.version),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [fetchedDiff] = createResource(
|
|
||||||
() => {
|
|
||||||
const id = props.sessionID()
|
|
||||||
if (!id || syncedDiff()) return undefined
|
|
||||||
return { sessionID: id, version: session()?.time.updated ?? 0 }
|
|
||||||
},
|
|
||||||
async (input) => loadDiff(sdk, input.sessionID, input.version),
|
|
||||||
)
|
|
||||||
|
|
||||||
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
|
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
|
||||||
const diff = createMemo(() => syncedDiff() ?? fetchedDiff() ?? [])
|
|
||||||
|
|
||||||
const diffSummary = createMemo(() => {
|
|
||||||
const live = diff()
|
|
||||||
if (live && live.length > 0) {
|
|
||||||
let additions = 0
|
|
||||||
let deletions = 0
|
|
||||||
for (const file of live) {
|
|
||||||
additions += file.additions ?? 0
|
|
||||||
deletions += file.deletions ?? 0
|
|
||||||
}
|
|
||||||
return formatDiffSummary({ additions, deletions, files: live.length })
|
|
||||||
}
|
|
||||||
return formatDiffSummary(session()?.summary)
|
|
||||||
})
|
|
||||||
|
|
||||||
const exchange = createMemo(() => {
|
const exchange = createMemo(() => {
|
||||||
const items = messages()
|
const items = messages()
|
||||||
|
|
@ -174,13 +122,13 @@ export function SessionPreviewPane(props: {
|
||||||
return { user, assistant }
|
return { user, assistant }
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange())
|
const loading = createMemo(() => fetchedMessages.loading && !exchange())
|
||||||
|
|
||||||
const statusLabel = createMemo(() => {
|
const statusLabel = createMemo(() => {
|
||||||
const s = status()
|
const s = status()
|
||||||
if (s === "busy") return { text: "working", color: theme.warning }
|
if (s === "busy") return "working"
|
||||||
if (s === "retry") return { text: "retrying", color: theme.warning }
|
if (s === "retry") return "retrying"
|
||||||
return { text: "idle", color: theme.textMuted }
|
return "idle"
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -191,7 +139,7 @@ export function SessionPreviewPane(props: {
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
gap={1}
|
gap={1}
|
||||||
maxHeight={maxHeight()}
|
height={maxHeight()}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
|
|
@ -204,7 +152,7 @@ export function SessionPreviewPane(props: {
|
||||||
>
|
>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<>
|
<>
|
||||||
<Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} />
|
<Header session={s()} statusLabel={statusLabel()} />
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<Spinner>loading preview...</Spinner>
|
<Spinner>loading preview...</Spinner>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -213,7 +161,7 @@ export function SessionPreviewPane(props: {
|
||||||
fallback={
|
fallback={
|
||||||
<Show when={!loading()}>
|
<Show when={!loading()}>
|
||||||
<text fg={theme.textMuted} wrapMode="word">
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
No messages yet
|
{fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|
@ -241,24 +189,12 @@ function messageParentID(item: WithParts) {
|
||||||
|
|
||||||
const ROW_WIDTH = 40
|
const ROW_WIDTH = 40
|
||||||
|
|
||||||
function Header(props: {
|
function Header(props: { session: SdkSession; statusLabel: string }) {
|
||||||
session: SdkSession
|
|
||||||
statusLabel: { text: string; color: RGBA }
|
|
||||||
diff: { additions: number; deletions: number; files: number } | undefined
|
|
||||||
}) {
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
|
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
|
||||||
const modelAgent = createMemo(() => {
|
|
||||||
const m = shortModelLabel(props.session.model)
|
|
||||||
const a = props.session.agent ?? ""
|
|
||||||
if (m && a) return Locale.truncate(`${m} · ${a}`, ROW_WIDTH)
|
|
||||||
if (m) return Locale.truncate(m, ROW_WIDTH)
|
|
||||||
if (a) return Locale.truncate(a, ROW_WIDTH)
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
const statusRest = createMemo(() => {
|
const statusRest = createMemo(() => {
|
||||||
const joined = ` · ${relativeTime(props.session.time.updated)}`
|
const joined = ` · ${relativeTime(props.session.time.updated)}`
|
||||||
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length))
|
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -268,20 +204,12 @@ function Header(props: {
|
||||||
{title()}
|
{title()}
|
||||||
</text>
|
</text>
|
||||||
</Row>
|
</Row>
|
||||||
<Show when={modelAgent()}>
|
|
||||||
<Row height={1}>
|
|
||||||
<text fg={theme.text} wrapMode="none" overflow="hidden">
|
|
||||||
{modelAgent()}
|
|
||||||
</text>
|
|
||||||
</Row>
|
|
||||||
</Show>
|
|
||||||
<Row height={1}>
|
<Row height={1}>
|
||||||
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
|
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
|
||||||
<span style={{ fg: props.statusLabel.color }}>{props.statusLabel.text}</span>
|
<span>{props.statusLabel}</span>
|
||||||
<span>{statusRest()}</span>
|
<span>{statusRest()}</span>
|
||||||
</text>
|
</text>
|
||||||
</Row>
|
</Row>
|
||||||
<Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -294,28 +222,6 @@ function Row(props: { height: number; children: JSX.Element }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffRow(props: { diff: { additions: number; deletions: number; files: number } }) {
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const showAdds = () => props.diff.additions > 0
|
|
||||||
const showDels = () => props.diff.deletions > 0
|
|
||||||
if (!showAdds() && !showDels()) return null
|
|
||||||
return (
|
|
||||||
<Row height={1}>
|
|
||||||
<text wrapMode="none" overflow="hidden">
|
|
||||||
<Show when={showAdds()}>
|
|
||||||
<span style={{ fg: theme.diffAdded }}>+{props.diff.additions}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={showAdds() && showDels()}>
|
|
||||||
<span> </span>
|
|
||||||
</Show>
|
|
||||||
<Show when={showDels()}>
|
|
||||||
<span style={{ fg: theme.diffRemoved }}>−{props.diff.deletions}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROMPT_MAX_CHARS = 240
|
const PROMPT_MAX_CHARS = 240
|
||||||
const REPLY_MAX_LINES = 12
|
const REPLY_MAX_LINES = 12
|
||||||
const REPLY_MAX_CHARS = 800
|
const REPLY_MAX_CHARS = 800
|
||||||
|
|
|
||||||
|
|
@ -52,19 +52,3 @@ function collectTextParts(parts: readonly Part[]): string[] {
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDiffSummary(
|
|
||||||
summary: { additions: number; deletions: number; files: number } | undefined,
|
|
||||||
): { additions: number; deletions: number; files: number } | undefined {
|
|
||||||
if (!summary) return undefined
|
|
||||||
if (!summary.additions && !summary.deletions && !summary.files) return undefined
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shortModelLabel(model: { id: string; providerID?: string; variant?: string } | undefined): string {
|
|
||||||
if (!model) return ""
|
|
||||||
const id = model.id ?? ""
|
|
||||||
const stripped =
|
|
||||||
model.providerID && id.startsWith(`${model.providerID}/`) ? id.slice(model.providerID.length + 1) : id
|
|
||||||
return model.variant ? `${stripped} (${model.variant})` : stripped
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export type DialogSelectRef<T> = {
|
||||||
filter: string
|
filter: string
|
||||||
filtered: DialogSelectOption<T>[]
|
filtered: DialogSelectOption<T>[]
|
||||||
selected: DialogSelectOption<T> | undefined
|
selected: DialogSelectOption<T> | undefined
|
||||||
|
moveTo(value: T): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
|
|
@ -341,6 +342,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
get selected() {
|
get selected() {
|
||||||
return selected()
|
return selected()
|
||||||
},
|
},
|
||||||
|
moveTo(value) {
|
||||||
|
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
|
||||||
|
if (index >= 0) moveTo(index, true)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
props.ref?.(ref)
|
props.ref?.(ref)
|
||||||
|
|
||||||
|
|
@ -551,7 +556,7 @@ function Option(props: {
|
||||||
●
|
●
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!props.current && props.gutter}>
|
<Show when={props.gutter}>
|
||||||
<box flexShrink={0} marginRight={0}>
|
<box flexShrink={0} marginRight={0}>
|
||||||
{props.gutter?.()}
|
{props.gutter?.()}
|
||||||
</box>
|
</box>
|
||||||
|
|
|
||||||
|
|
@ -1017,20 +1017,14 @@ export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModels
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: () => Effect.Effect<Record<ProviderV2.ID, Info>>
|
readonly list: () => Effect.Effect<Record<ProviderV2.ID, Info>>
|
||||||
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
|
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
|
||||||
readonly getModel: (
|
readonly getModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<Model, ModelNotFoundError>
|
||||||
providerID: ProviderV2.ID,
|
|
||||||
modelID: ModelV2.ID,
|
|
||||||
) => Effect.Effect<Model, ModelNotFoundError>
|
|
||||||
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
|
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
|
||||||
readonly closest: (
|
readonly closest: (
|
||||||
providerID: ProviderV2.ID,
|
providerID: ProviderV2.ID,
|
||||||
query: string[],
|
query: string[],
|
||||||
) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined>
|
) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined>
|
||||||
readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect<Model | undefined>
|
readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect<Model | undefined>
|
||||||
readonly defaultModel: () => Effect.Effect<
|
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ModelV2.ID }, DefaultModelError>
|
||||||
{ providerID: ProviderV2.ID; modelID: ModelV2.ID },
|
|
||||||
DefaultModelError
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,15 @@ type RouteRequirements =
|
||||||
export function createRoutes(
|
export function createRoutes(
|
||||||
corsOptions?: CorsOptions,
|
corsOptions?: CorsOptions,
|
||||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, v2Routes, docRoute, uiRoute).pipe(
|
return Layer.mergeAll(
|
||||||
|
rootApiRoutes,
|
||||||
|
eventApiRoutes,
|
||||||
|
ptyConnectApiRoutes,
|
||||||
|
instanceRoutes,
|
||||||
|
v2Routes,
|
||||||
|
docRoute,
|
||||||
|
uiRoute,
|
||||||
|
).pipe(
|
||||||
Layer.provide([
|
Layer.provide([
|
||||||
errorLayer,
|
errorLayer,
|
||||||
compressionLayer,
|
compressionLayer,
|
||||||
|
|
|
||||||
|
|
@ -574,10 +574,7 @@ export const layer: Layer.Layer<
|
||||||
}
|
}
|
||||||
log.info("created", result)
|
log.info("created", result)
|
||||||
|
|
||||||
yield* events.publish(
|
yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result })
|
||||||
SessionV1.Event.Created,
|
|
||||||
{ sessionID: result.id, info: result },
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
@ -664,10 +661,7 @@ export const layer: Layer.Layer<
|
||||||
yield* remove(child.id)
|
yield* remove(child.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* events.publish(
|
yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session })
|
||||||
SessionV1.Event.Deleted,
|
|
||||||
{ sessionID, info: session },
|
|
||||||
)
|
|
||||||
yield* events.remove(sessionID)
|
yield* events.remove(sessionID)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(e)
|
log.error(e)
|
||||||
|
|
@ -682,14 +676,11 @@ export const layer: Layer.Layer<
|
||||||
|
|
||||||
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
|
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* events.publish(
|
yield* events.publish(SessionV1.Event.PartUpdated, {
|
||||||
SessionV1.Event.PartUpdated,
|
sessionID: part.sessionID,
|
||||||
{
|
part: structuredClone(part),
|
||||||
sessionID: part.sessionID,
|
time: Date.now(),
|
||||||
part: structuredClone(part),
|
})
|
||||||
time: Date.now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return part
|
return part
|
||||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||||
|
|
||||||
|
|
@ -892,13 +883,10 @@ export const layer: Layer.Layer<
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
}) {
|
}) {
|
||||||
yield* events.publish(
|
yield* events.publish(SessionV1.Event.MessageRemoved, {
|
||||||
SessionV1.Event.MessageRemoved,
|
sessionID: input.sessionID,
|
||||||
{
|
messageID: input.messageID,
|
||||||
sessionID: input.sessionID,
|
})
|
||||||
messageID: input.messageID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return input.messageID
|
return input.messageID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -907,14 +895,11 @@ export const layer: Layer.Layer<
|
||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
partID: PartID
|
partID: PartID
|
||||||
}) {
|
}) {
|
||||||
yield* events.publish(
|
yield* events.publish(SessionV1.Event.PartRemoved, {
|
||||||
SessionV1.Event.PartRemoved,
|
sessionID: input.sessionID,
|
||||||
{
|
messageID: input.messageID,
|
||||||
sessionID: input.sessionID,
|
partID: input.partID,
|
||||||
messageID: input.messageID,
|
})
|
||||||
partID: input.partID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return input.partID
|
return input.partID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,46 @@ describe("ACP service sessions", () => {
|
||||||
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("replays loaded session transcript chunks", async () => {
|
||||||
|
const { service, updates } = makeService([
|
||||||
|
{
|
||||||
|
info: { id: "msg_user", sessionID: "ses_loaded", role: "user" },
|
||||||
|
parts: [{ id: "part_user", sessionID: "ses_loaded", messageID: "msg_user", type: "text", text: "hello" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "msg_assistant", sessionID: "ses_loaded", role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "part_assistant",
|
||||||
|
sessionID: "ses_loaded",
|
||||||
|
messageID: "msg_assistant",
|
||||||
|
type: "text",
|
||||||
|
text: "hi there",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
updates
|
||||||
|
.map((item) => item.update)
|
||||||
|
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
sessionUpdate: "user_message_chunk",
|
||||||
|
messageId: "msg_user",
|
||||||
|
content: { type: "text", text: "hello" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
messageId: "msg_assistant",
|
||||||
|
content: { type: "text", text: "hi there" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it("lists sessions sorted by updated time with cursor support", async () => {
|
it("lists sessions sorted by updated time with cursor support", async () => {
|
||||||
const { service } = makeService()
|
const { service } = makeService()
|
||||||
const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
|
const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
|
||||||
|
|
|
||||||
|
|
@ -980,14 +980,8 @@ it.instance(
|
||||||
it.instance("getModel returns consistent results", () =>
|
it.instance("getModel returns consistent results", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const model1 = yield* Provider.use.getModel(
|
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||||
ProviderV2.ID.anthropic,
|
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||||
ModelV2.ID.make("claude-sonnet-4-20250514"),
|
|
||||||
)
|
|
||||||
const model2 = yield* Provider.use.getModel(
|
|
||||||
ProviderV2.ID.anthropic,
|
|
||||||
ModelV2.ID.make("claude-sonnet-4-20250514"),
|
|
||||||
)
|
|
||||||
expect(model1.providerID).toEqual(model2.providerID)
|
expect(model1.providerID).toEqual(model2.providerID)
|
||||||
expect(model1.id).toEqual(model2.id)
|
expect(model1.id).toEqual(model2.id)
|
||||||
expect(model1).toEqual(model2)
|
expect(model1).toEqual(model2)
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ describe("v2 location HttpApi", () => {
|
||||||
for (const route of ["/api/command", "/api/skill"]) {
|
for (const route of ["/api/command", "/api/skill"]) {
|
||||||
const response = await request(route, tmp.path)
|
const response = await request(route, tmp.path)
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
|
const body = (await response.json()) as {
|
||||||
|
location: { directory: string; project: { id: string } }
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
expect(body.data).toBeArray()
|
expect(body.data).toBeArray()
|
||||||
expect(body.location.directory).toBe(tmp.path)
|
expect(body.location.directory).toBe(tmp.path)
|
||||||
expect(body.location.project.id).toBeTruthy()
|
expect(body.location.project.id).toBeTruthy()
|
||||||
|
|
|
||||||
|
|
@ -1671,10 +1671,7 @@ describe("session.llm.stream", () => {
|
||||||
]
|
]
|
||||||
const request = waitRequest("/messages", createEventResponse(chunks))
|
const request = waitRequest("/messages", createEventResponse(chunks))
|
||||||
|
|
||||||
const resolved = yield* Provider.use.getModel(
|
const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ModelV2.ID.make(model.id))
|
||||||
ProviderV2.ID.make("anthropic"),
|
|
||||||
ModelV2.ID.make(model.id),
|
|
||||||
)
|
|
||||||
const sessionID = SessionID.make("session-test-anthropic-tools")
|
const sessionID = SessionID.make("session-test-anthropic-tools")
|
||||||
const agent = {
|
const agent = {
|
||||||
name: "test",
|
name: "test",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,13 +34,15 @@ export const eventHandlers = HttpApiBuilder.group(V2Api, "v2.event", (handlers)
|
||||||
return HttpServerResponse.stream(
|
return HttpServerResponse.stream(
|
||||||
Stream.make(connected).pipe(
|
Stream.make(connected).pipe(
|
||||||
Stream.concat(
|
Stream.concat(
|
||||||
events.all().pipe(
|
events
|
||||||
Stream.filter(
|
.all()
|
||||||
(event) =>
|
.pipe(
|
||||||
event.location?.directory === location.directory &&
|
Stream.filter(
|
||||||
event.location.workspaceID === location.workspaceID,
|
(event) =>
|
||||||
|
event.location?.directory === location.directory &&
|
||||||
|
event.location.workspaceID === location.workspaceID,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Stream.map(eventData),
|
Stream.map(eventData),
|
||||||
Stream.pipeThroughChannel(Sse.encode()),
|
Stream.pipeThroughChannel(Sse.encode()),
|
||||||
|
|
|
||||||
|
|
@ -14,23 +14,24 @@ import { schemaErrorLayer } from "./middleware/schema-error"
|
||||||
|
|
||||||
export function createRoutes(password?: string) {
|
export function createRoutes(password?: string) {
|
||||||
return HttpApiBuilder.layer(V2Api).pipe(
|
return HttpApiBuilder.layer(V2Api).pipe(
|
||||||
Layer.provide(v2Handlers),
|
Layer.provide(v2Handlers),
|
||||||
Layer.provide(v2AuthorizationLayer),
|
Layer.provide(v2AuthorizationLayer),
|
||||||
Layer.provide(schemaErrorLayer),
|
Layer.provide(schemaErrorLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
password
|
password
|
||||||
? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
|
? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
|
||||||
: ServerAuth.Config.defaultLayer,
|
: ServerAuth.Config.defaultLayer,
|
||||||
),
|
),
|
||||||
Layer.provide(LocationServiceMap.layer),
|
Layer.provide(LocationServiceMap.layer),
|
||||||
Layer.provide(PermissionSaved.layer),
|
Layer.provide(PermissionSaved.layer),
|
||||||
Layer.provide(SessionV2.defaultLayer),
|
Layer.provide(SessionV2.defaultLayer),
|
||||||
Layer.provide(Database.defaultLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = createRoutes()
|
export const routes = createRoutes()
|
||||||
|
|
||||||
export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })
|
export const webHandler = () =>
|
||||||
|
HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ Affected schema:
|
||||||
|
|
||||||
Change:
|
Change:
|
||||||
|
|
||||||
- Add and backfill `session_message.seq` from matching synchronized events.
|
- Reset pre-launch Session-message projections and add `session_message.seq` for newly projected synchronized events.
|
||||||
- Add event aggregate-sequence and aggregate-type-sequence indexes.
|
- Add event aggregate-sequence and aggregate-type-sequence indexes.
|
||||||
- Add Session-message sequence, type-sequence, and compatibility timestamp indexes.
|
- Add Session-message sequence, type-sequence, and compatibility timestamp indexes.
|
||||||
|
|
||||||
|
|
@ -114,7 +114,8 @@ Reason:
|
||||||
|
|
||||||
Compatibility:
|
Compatibility:
|
||||||
|
|
||||||
- Migration fails rather than inventing chronology if an existing projected Session message has no matching durable event.
|
- Pre-launch Session-message projections are disposable because historical versions could write them without durable creator events.
|
||||||
|
- The migration resets those projections rather than inventing chronology or blocking startup.
|
||||||
- The timestamp compatibility index remains for legacy or transitional query shapes.
|
- The timestamp compatibility index remains for legacy or transitional query shapes.
|
||||||
|
|
||||||
### Structured Tool Registry And Canonical Output
|
### Structured Tool Registry And Canonical Output
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue