mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed473870ab | |||
| 60a24a760b |
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,7 +18,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -41,24 +40,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -205,33 +204,7 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export * as LocationWatcher from "./location-watcher"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Watcher } from "./watcher"
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const configService = yield* Config.Service
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
bus.publish(FileSystem.Event.Changed, {
|
||||
file: update.path,
|
||||
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
if (!config.includes(".hg") && !config.includes(vcs)) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Image } from "./image"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
@@ -98,6 +99,8 @@ const locationServiceNodes = [
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
|
||||
|
||||
+20
-28
@@ -3,6 +3,7 @@ export * as Vcs from "./vcs"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -10,8 +11,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "./bus"
|
||||
import { Git } from "./git"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
@@ -45,36 +44,29 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const git = yield* Git.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
const vcs = location.vcs
|
||||
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
|
||||
|
||||
if (vcs && impl) {
|
||||
yield* Effect.gen(function* () {
|
||||
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
|
||||
const target = discovered ?? vcs.store
|
||||
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
|
||||
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
|
||||
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
|
||||
)
|
||||
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
|
||||
yield* updates.pipe(
|
||||
Stream.filter((update) => keep.includes(path.basename(update.path))),
|
||||
Stream.runForEach((update) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* impl.info()
|
||||
const changed = state.info.branch.current !== next.branch.current
|
||||
state.info = next
|
||||
if (!changed) return
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
|
||||
const isBranchMetadata =
|
||||
vcs.type === "git"
|
||||
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
|
||||
: (file: string) => path.resolve(file) === path.join(store, "branch")
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* impl.info()
|
||||
const changed = state.info.branch.current !== next.branch.current
|
||||
state.info = next
|
||||
if (!changed) return
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
@@ -96,5 +88,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -465,7 +465,6 @@ Use native v2 fields.`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
|
||||
@@ -185,7 +185,6 @@ Review files`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
const configLayer = Config.testLayer()
|
||||
|
||||
describe("Watcher.testLayer", () => {
|
||||
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
|
||||
@@ -27,6 +40,7 @@ describe("Watcher.testLayer", () => {
|
||||
yield* test.emit({ type: "update", path: "/root/file.md" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
|
||||
// subscriptions() reports acquired watches, so paths come back resolved.
|
||||
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
|
||||
}).pipe(Effect.provide(Watcher.testLayer)),
|
||||
)
|
||||
@@ -112,20 +126,167 @@ describe("Watcher lifecycle", () => {
|
||||
expect(counts.unsubscribes).toBe(0)
|
||||
return consumer
|
||||
}).pipe(withNative(native))
|
||||
// Closing the layer scope tears the native subscription down while the
|
||||
// consumer still holds a reference; the consumer's own release as its
|
||||
// stream ends must not tear it down a second time.
|
||||
yield* Fiber.join(consumer)
|
||||
expect(counts.unsubscribes).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
const built = AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
return Effect.provide(built)
|
||||
}
|
||||
|
||||
describeNative("Watcher", () => {
|
||||
function withTmp<A, E, R>(
|
||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||
options?: {
|
||||
vcs?: "git" | "hg"
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const tmp = await tmpdir()
|
||||
if (options?.vcs === "hg") {
|
||||
await fs.mkdir(path.join(tmp.path, ".hg"))
|
||||
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
|
||||
}
|
||||
if (options?.vcs !== "git") return { tmp, vcs: undefined }
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
|
||||
await $`git config user.name Test`.cwd(tmp.path).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
|
||||
await options.init?.(tmp.path)
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
||||
}
|
||||
|
||||
describe("LocationWatcher subscriptions", () => {
|
||||
it.live("watches only exact Git branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "git", watcher },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("watches only exact Hg branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function wait(check: (event: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
return { deferred, fiber }
|
||||
})
|
||||
}
|
||||
|
||||
function maybeNextUpdate<E>(
|
||||
check: (event: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
timeout: Duration.Input = "5 seconds",
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* maybeNextUpdate(check, trigger)
|
||||
if (Option.isSome(result)) return result.value
|
||||
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||
})
|
||||
}
|
||||
|
||||
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
while (true) {
|
||||
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
|
||||
if (Option.isSome(result)) return result.value
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(file: string, eventFile = file) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
|
||||
yield* eventuallyUpdate(
|
||||
(event) => event.file === eventFile,
|
||||
() => fs.writeFileString(file, content),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
describeNative("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -172,4 +333,68 @@ describeNative("Watcher", () => {
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes .git/HEAD events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* ready(head)
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toEqual({ file: head, event: "change" })
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
||||
describeSymlink("symlinked .git", () => {
|
||||
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
yield* ready(head, path.join(actual, "HEAD"))
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate(
|
||||
(event) => event.file === path.join(actual, "HEAD"),
|
||||
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||
),
|
||||
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
||||
}),
|
||||
{
|
||||
vcs: "git",
|
||||
init: async (directory) => {
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
await fs.rename(path.join(directory, ".git"), actual)
|
||||
await fs.symlink(actual, path.join(directory, ".git"))
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("publishes .hg/branch events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
}),
|
||||
{ vcs: "hg" },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -286,30 +286,27 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"permissions compound commands separately",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -41,9 +42,7 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
|
||||
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
withTmp((directory) =>
|
||||
Effect.promise(() => hg(directory, "init")).pipe(
|
||||
Effect.andThen(f(directory).pipe(provide(directory))),
|
||||
),
|
||||
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
|
||||
)
|
||||
|
||||
async function hg(directory: string, ...args: string[]) {
|
||||
@@ -125,7 +124,13 @@ describeHg("Vcs mercurial", () => {
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
event: "change",
|
||||
})
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
|
||||
+37
-125
@@ -8,66 +8,27 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
|
||||
const locationLayer = (directory: string, git?: boolean) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
|
||||
)
|
||||
|
||||
function fakeWatcher() {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const active = new Set<(update: Watcher.Update) => void>()
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
subscriptions.push(
|
||||
input.type === "file"
|
||||
? { path: input.target, type: "file" }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
)
|
||||
active.add(input.publish)
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
active.delete(input.publish)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
return {
|
||||
subscriptions: () => [...subscriptions],
|
||||
emit: (update: Watcher.Update) => {
|
||||
for (const publish of active) publish(update)
|
||||
},
|
||||
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
|
||||
}
|
||||
}
|
||||
|
||||
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[Location.node, locationLayer(directory, git)],
|
||||
[Watcher.node, fake.layer],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -132,84 +93,35 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches git branch metadata", () =>
|
||||
withTmp((directory) => {
|
||||
const fake = fakeWatcher()
|
||||
return Effect.promise(() => initRepo(directory)).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
yield* Vcs.Service
|
||||
expect(fake.subscriptions()).toHaveLength(1)
|
||||
const git = fake.subscriptions()[0]
|
||||
if (git?.type !== "directory") throw new Error("expected a directory watch")
|
||||
expect(git.path).toBe(path.join(directory, ".git"))
|
||||
expect(git.ignore ?? []).not.toContain("HEAD")
|
||||
expect(git.ignore ?? []).toContain("objects")
|
||||
}).pipe(provideFake(directory, fake)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes HEAD changes", () =>
|
||||
withTmp((directory) => {
|
||||
const fake = fakeWatcher()
|
||||
return Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
|
||||
}).pipe(provideFake(directory, fake)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
describeNative("native watches", () => {
|
||||
it.live("publishes branch updates on git checkout", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
|
||||
}),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("diffs the working copy against HEAD with patches", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -84,15 +84,6 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Transcript images",
|
||||
category: "Session",
|
||||
path: ["session", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
|
||||
@@ -131,9 +131,6 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -54,7 +54,6 @@ import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
@@ -1593,9 +1592,8 @@ function SessionGroupView(props: {
|
||||
</InlineToolRow>
|
||||
</Show>
|
||||
<Show when={expanded() && grouped().length > 0}>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
<ToolImages parts={grouped()} />
|
||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1899,11 +1897,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const skills = createMemo(() => props.message.skills ?? [])
|
||||
const images = createMemo(() =>
|
||||
files().flatMap((file) =>
|
||||
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
|
||||
),
|
||||
)
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -1925,7 +1918,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
@@ -2218,7 +2210,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
@@ -2242,7 +2234,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
},
|
||||
}
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={display() === "shell"}>
|
||||
<Shell {...toolprops} />
|
||||
@@ -2288,87 +2280,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
|
||||
return <SessionImages images={images()} />
|
||||
}
|
||||
|
||||
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
|
||||
const ctx = use()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
|
||||
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const visible = createMemo(() => images().slice(0, 3))
|
||||
|
||||
return (
|
||||
<Show when={visible().length > 0}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingLeft={props.paddingLeft ?? 3}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={height() * 2}
|
||||
height={height()}
|
||||
flexBasis={height() * 2}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
|
||||
}}
|
||||
>
|
||||
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||
<image
|
||||
source={image.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={images().length > visible().length}>
|
||||
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
|
||||
<text wrapMode="none" truncate>
|
||||
+{images().length - visible().length} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function inlineToolImages(part: SessionMessageAssistantTool) {
|
||||
return toolDisplayContent(part.state).flatMap((content) =>
|
||||
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||
? [{ uri: content.uri }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
|
||||
@@ -24,7 +24,6 @@ test("validates the session tabs setting", () => {
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
|
||||
Reference in New Issue
Block a user