mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 047d9255ce |
@@ -1,9 +1,8 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
@@ -38,14 +37,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined || found.legacy) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return found
|
||||
})
|
||||
|
||||
@@ -94,7 +93,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return Option.some(service)
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -28,7 +27,7 @@ export async function discover(options: DiscoverOptions = {}) {
|
||||
async function discoverLocal(options: DiscoverOptions) {
|
||||
const found = (await registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -77,7 +76,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { DiscoverOptions } from "./service.js"
|
||||
|
||||
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
|
||||
if (options.version === undefined) return true
|
||||
if (version === undefined) return false
|
||||
if (typeof options.version === "function") return options.version(version)
|
||||
return version === options.version
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export type Endpoint = {
|
||||
export type DiscoverOptions = {
|
||||
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||
readonly file?: string
|
||||
/** Required exact service version or compatibility predicate. */
|
||||
readonly version?: string | ((version: string) => boolean)
|
||||
/** Required service version. */
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
/** Reason ensuring the service requires a new process. */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { DiscoverOptions } from "../src/service"
|
||||
|
||||
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
|
||||
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
|
||||
@@ -9,9 +8,6 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
|
||||
declare const effectClient: EffectClient
|
||||
declare const promiseClient: PromiseClient
|
||||
|
||||
const exactVersion: DiscoverOptions = { version: "2.0.0" }
|
||||
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
|
||||
|
||||
const effectApi: EffectApi<unknown> = effectClient
|
||||
|
||||
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
|
||||
@@ -46,14 +42,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
|
||||
key: "review-notes",
|
||||
})
|
||||
|
||||
void [
|
||||
effectSession,
|
||||
effectList,
|
||||
effectPut,
|
||||
effectRemove,
|
||||
promiseList,
|
||||
promisePut,
|
||||
promiseRemove,
|
||||
exactVersion,
|
||||
compatibleVersion,
|
||||
]
|
||||
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
|
||||
|
||||
@@ -27,10 +27,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
}
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
|
||||
const id = crypto.randomUUID()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
|
||||
@@ -25,19 +25,6 @@ test("discovers a registered service", async () => {
|
||||
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("discovers a compatible registered service", async () => {
|
||||
const registration = await setup("compatible")
|
||||
|
||||
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
|
||||
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
|
||||
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||
)
|
||||
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
|
||||
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||
)
|
||||
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ensures a missing service with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -47,52 +47,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||
})
|
||||
|
||||
test("reuses a compatible registered service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "compatible")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: (version) => version.startsWith("2."),
|
||||
command: [],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
|
||||
expect(starts).toEqual([])
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("replaces an incompatible registered service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "incompatible")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: (version) => version.startsWith("2."),
|
||||
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.version).toBe("2.1.0-next.1")
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("waits for a registered service to finish starting", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -113,8 +113,8 @@ const layer = Layer.effect(
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
/**
|
||||
@@ -144,6 +144,7 @@ const layer = Layer.effect(
|
||||
let step = 1
|
||||
while (true) {
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
if (step === 1) yield* startTitle(sessionID)
|
||||
yield* runPendingCompaction(sessionID)
|
||||
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
||||
promotable = "steer"
|
||||
@@ -235,7 +236,6 @@ const layer = Layer.effect(
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promoted > 0) yield* startTitle(sessionID)
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
|
||||
@@ -286,6 +286,7 @@ export const Plugin = {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
status: output.status,
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
|
||||
@@ -816,7 +816,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("generates the title while the first model step is still running", () =>
|
||||
it.effect("retries title generation from the first prompt after execution and title failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
@@ -831,48 +831,16 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const runner = yield* SessionRunner.Service
|
||||
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Fiber.join(renamed)
|
||||
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries title generation from the first prompt after title and execution failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()))
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
|
||||
|
||||
yield* admit(session, "Second prompt")
|
||||
const titleFailed = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Recovered", "text-recovered"),
|
||||
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
|
||||
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
|
||||
),
|
||||
TestLLM.text("Recovered", "text-recovered"),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* Deferred.await(titleFailed)
|
||||
@@ -888,13 +856,13 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "Third prompt")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
TestLLM.text("Recovered again", "text-recovered-again"),
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* Fiber.join(renamed)
|
||||
|
||||
expect(requests).toHaveLength(6)
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
|
||||
@@ -143,47 +143,44 @@ describe("Snapshot", () => {
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live(
|
||||
"isolates snapshot indexes by canonical Git worktree",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await initGit(project, true)
|
||||
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await initGit(project, true)
|
||||
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const capture = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
const capture = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
|
||||
).toBeDefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
|
||||
).toBeDefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -387,63 +387,57 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"approves an explicit external workdir before shell execution",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
it.live("approves an explicit external workdir before shell execution", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"approves an external directory used by a directory-change command",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
it.live("approves an external directory used by a directory-change command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an expanded external home directory", () =>
|
||||
@@ -465,31 +459,28 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"does not execute after external-directory or shell denial",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
)
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
it.live("does not execute after external-directory or shell denial", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
)
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
|
||||
reset()
|
||||
denyAction = "shell"
|
||||
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
reset()
|
||||
denyAction = "shell"
|
||||
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps non-zero exits useful", () =>
|
||||
@@ -628,7 +619,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
@@ -639,7 +630,7 @@ describe("ShellTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
@@ -676,7 +667,7 @@ describe("ShellTool", () => {
|
||||
)
|
||||
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
const shell = yield* Shell.Service
|
||||
@@ -761,7 +752,7 @@ describe("ShellTool", () => {
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({
|
||||
type: "text",
|
||||
text: "The command was moved to the background.",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isAllowedOpenApp } from "./apps"
|
||||
|
||||
describe("open application policy", () => {
|
||||
test.each([
|
||||
["darwin", "Visual Studio Code"],
|
||||
["darwin", "Cursor"],
|
||||
["darwin", "Zed"],
|
||||
["darwin", "TextMate"],
|
||||
["darwin", "Antigravity"],
|
||||
["darwin", "Terminal"],
|
||||
["darwin", "iTerm"],
|
||||
["darwin", "Ghostty"],
|
||||
["darwin", "Warp"],
|
||||
["darwin", "Xcode"],
|
||||
["darwin", "Android Studio"],
|
||||
["darwin", "Sublime Text"],
|
||||
["win32", "code"],
|
||||
["win32", "cursor"],
|
||||
["win32", "zed"],
|
||||
["win32", "powershell"],
|
||||
["win32", "Sublime Text"],
|
||||
["linux", "code"],
|
||||
["linux", "cursor"],
|
||||
["linux", "zed"],
|
||||
["linux", "Sublime Text"],
|
||||
] as const)("allows %s application %s", (platform, app) => {
|
||||
expect(isAllowedOpenApp(platform, app)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects applications outside the configured list", () => {
|
||||
expect(isAllowedOpenApp("darwin", "Calculator")).toBe(false)
|
||||
expect(isAllowedOpenApp("win32", "cmd.exe")).toBe(false)
|
||||
expect(isAllowedOpenApp("linux", "sh")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,22 +4,6 @@ import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
const macosOpenApps = new Set([
|
||||
"Visual Studio Code",
|
||||
"Cursor",
|
||||
"Zed",
|
||||
"TextMate",
|
||||
"Antigravity",
|
||||
"Terminal",
|
||||
"iTerm",
|
||||
"Ghostty",
|
||||
"Warp",
|
||||
"Xcode",
|
||||
"Android Studio",
|
||||
"Sublime Text",
|
||||
])
|
||||
const windowsOpenApps = new Set(["code", "cursor", "zed", "powershell", "Sublime Text"])
|
||||
const linuxOpenApps = new Set(["code", "cursor", "zed", "Sublime Text"])
|
||||
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
@@ -32,19 +16,11 @@ export function checkAppExists(appName: string) {
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export async function resolveAppPath(appName: string) {
|
||||
if (!isAllowedOpenApp(process.platform, appName)) return null
|
||||
export function resolveAppPath(appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
export function isAllowedOpenApp(platform: NodeJS.Platform, appName: string) {
|
||||
if (platform === "darwin") return macosOpenApps.has(appName)
|
||||
if (platform === "win32") return windowsOpenApps.has(appName)
|
||||
if (platform === "linux") return linuxOpenApps.has(appName)
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkMacosApp(appName: string) {
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
|
||||
@@ -199,11 +199,9 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
|
||||
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
|
||||
if (!app) return shell.openPath(path)
|
||||
const executable = await deps.resolveAppPath(app)
|
||||
if (!executable) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? (["open", ["-a", executable, path]] as const) : ([executable, [path]] as const)
|
||||
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
|
||||
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,7 +215,11 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
openLocalFile(url: string) {
|
||||
window.api.openLocalFile(url)
|
||||
},
|
||||
openPath(path: string, app?: string) {
|
||||
async openPath(path: string, app?: string) {
|
||||
if (os === "windows") {
|
||||
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
||||
return window.api.openPath(path, resolvedApp ?? undefined)
|
||||
}
|
||||
return window.api.openPath(path, app)
|
||||
},
|
||||
async revealPath(path: string) {
|
||||
|
||||
@@ -151,7 +151,6 @@ export function DialogOpen() {
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
@@ -18,15 +18,12 @@ import {
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { moveSelection } from "../ui/select-controller"
|
||||
import { marqueeText } from "../util/marquee"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -34,15 +31,6 @@ const FADE_WIDTH = 4
|
||||
const ADD_TAB_WIDTH = 3
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
const CONTEXT_MENU_WIDTH = 16
|
||||
const RIGHT_MOUSE_BUTTON = 2
|
||||
|
||||
type TabContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
sessionID?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
@@ -72,188 +60,27 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
|
||||
return opacity === 0 ? color : tint(color, background, opacity)
|
||||
}
|
||||
|
||||
function createMarquee(animations: () => boolean) {
|
||||
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const [active, setActive] = createSignal<string>()
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
let delay: ReturnType<typeof setTimeout> | undefined
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
let cycleWidth = 0
|
||||
let returning = false
|
||||
|
||||
const clear = () => {
|
||||
if (delay) clearTimeout(delay)
|
||||
if (interval) clearInterval(interval)
|
||||
delay = undefined
|
||||
interval = undefined
|
||||
}
|
||||
const scroll = () => {
|
||||
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
|
||||
}
|
||||
const enter = (sessionID: string, title: string, width: number) => {
|
||||
if (active() === sessionID && !returning) return
|
||||
clear()
|
||||
if (active() === sessionID) {
|
||||
returning = false
|
||||
return scroll()
|
||||
}
|
||||
if (!marqueeOverflows(title, width)) return
|
||||
cycleWidth = marqueeCycleWidth(title)
|
||||
setActive(sessionID)
|
||||
setOffset(0)
|
||||
returning = false
|
||||
leading.jump({ opacity: 0 })
|
||||
delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
leading.animate({ opacity: 1 })
|
||||
scroll()
|
||||
}, MARQUEE_DELAY)
|
||||
}
|
||||
const leave = (sessionID: string) => {
|
||||
if (active() !== sessionID) return
|
||||
clear()
|
||||
if (offset() === 0) {
|
||||
setActive(undefined)
|
||||
return
|
||||
}
|
||||
returning = true
|
||||
interval = setInterval(() => {
|
||||
setOffset((value) => {
|
||||
const next = (value + 1) % cycleWidth
|
||||
if (next !== 0) return next
|
||||
clear()
|
||||
returning = false
|
||||
setActive(undefined)
|
||||
leading.animate({ opacity: 0 })
|
||||
return 0
|
||||
})
|
||||
}, MARQUEE_INTERVAL)
|
||||
}
|
||||
const reset = () => {
|
||||
clear()
|
||||
returning = false
|
||||
setActive(undefined)
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
}
|
||||
onCleanup(clear)
|
||||
|
||||
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
|
||||
}
|
||||
|
||||
function createTabMarquee(animations: () => boolean) {
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(animations)
|
||||
let hoverClear: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const enter = (sessionID: string, title: string, width: number) => {
|
||||
if (hoverClear) clearTimeout(hoverClear)
|
||||
setHovered(sessionID)
|
||||
marquee.enter(sessionID, title, width)
|
||||
}
|
||||
const leave = (sessionID: string) => {
|
||||
if (hoverClear) clearTimeout(hoverClear)
|
||||
hoverClear = setTimeout(() => {
|
||||
if (hovered() !== sessionID) return
|
||||
setHovered(undefined)
|
||||
marquee.leave(sessionID)
|
||||
})
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (hoverClear) clearTimeout(hoverClear)
|
||||
})
|
||||
|
||||
return { ...marquee, hovered, enter, leave }
|
||||
}
|
||||
|
||||
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const dialog = useDialog()
|
||||
const keymap = Keymap.use()
|
||||
const actions = createMemo(() => {
|
||||
const sessionID = props.state.sessionID
|
||||
return [
|
||||
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
|
||||
...(sessionID
|
||||
? [
|
||||
{
|
||||
title: "Rename",
|
||||
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
|
||||
},
|
||||
{ title: "Close", run: () => props.tabs.close(sessionID) },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
|
||||
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
|
||||
const run = (index: number) => {
|
||||
props.onClose()
|
||||
actions()[index]?.run()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const popMode = keymap.mode.push("modal")
|
||||
onCleanup(popMode)
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
if (!hovered()) return
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
const delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
leading.animate({ opacity: 1 })
|
||||
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
|
||||
}, MARQUEE_DELAY)
|
||||
onCleanup(() => {
|
||||
clearTimeout(delay)
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
|
||||
{
|
||||
bind: "up",
|
||||
title: "Previous tab menu item",
|
||||
group: "Tabs",
|
||||
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
|
||||
},
|
||||
{
|
||||
bind: "down",
|
||||
title: "Next tab menu item",
|
||||
group: "Tabs",
|
||||
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
|
||||
},
|
||||
{ bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
|
||||
],
|
||||
}))
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={left()}
|
||||
top={top()}
|
||||
height={actions().length}
|
||||
width={CONTEXT_MENU_WIDTH}
|
||||
zIndex={2500}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<For each={actions()}>
|
||||
{(action, index) => (
|
||||
<box
|
||||
width="100%"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
run(index())
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{action.title}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
|
||||
return { offset, leading: () => leading.value().opacity }
|
||||
}
|
||||
|
||||
export function SessionTabs(
|
||||
@@ -278,12 +105,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
@@ -292,10 +118,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = ordered
|
||||
createEffect(() => {
|
||||
const active = marquee.active()
|
||||
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
|
||||
})
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -315,7 +137,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
),
|
||||
)
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let rail: { screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
@@ -345,7 +167,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
@@ -362,19 +183,16 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => 2
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), titleWidth(), marquee.offset())
|
||||
: Locale.takeWidth(title(), titleWidth()),
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
@@ -456,29 +274,13 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
setDragging(undefined)
|
||||
if (!rail) return
|
||||
setContextMenu({
|
||||
x: event.x - rail.screenX,
|
||||
y: event.y - rail.screenY,
|
||||
sessionID: tab.sessionID,
|
||||
title: tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
@@ -587,7 +389,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
@@ -639,15 +440,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
onMouseUp={() => {
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
@@ -676,7 +469,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
@@ -689,9 +481,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={contextMenu()}>
|
||||
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -703,16 +492,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
// so the strip never flashes the pre-drag order while the write is in flight.
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
let strip: { screenX: number } | undefined
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
@@ -742,10 +530,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
previous?.start,
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
const active = marquee.active()
|
||||
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
|
||||
})
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -896,9 +680,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
||||
@@ -906,7 +690,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
@@ -957,28 +741,13 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
setDragging(undefined)
|
||||
setContextMenu({
|
||||
x: event.x - (strip?.screenX ?? 0),
|
||||
y: event.y - (strip?.screenY ?? 0),
|
||||
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
|
||||
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
@@ -1029,7 +798,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
fg={closeColor()}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
@@ -1057,23 +825,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
tabs.add?.()
|
||||
}}
|
||||
onMouseUp={() => tabs.add?.()}
|
||||
>
|
||||
{" + "}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={contextMenu()}>
|
||||
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,15 +96,13 @@ export const Definitions = {
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"session.list": keybind("<leader>l", "List all sessions"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
|
||||
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
|
||||
"session.tab.history.back": keybind("none", "Go back in session tab history"),
|
||||
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
|
||||
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
|
||||
"session.tab.next_unread": keybind("alt+shift+down", "Switch to next unread session tab"),
|
||||
"session.tab.previous_unread": keybind("alt+shift+up", "Switch to previous unread session tab"),
|
||||
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
|
||||
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
"session.tab.close": keybind("<leader>w", "Close current session tab"),
|
||||
"session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"),
|
||||
"session.timeline": keybind("<leader>g", "Show session timeline"),
|
||||
"session.fork": keybind("none", "Fork session from message"),
|
||||
"session.rename": keybind("ctrl+r", "Rename session"),
|
||||
@@ -141,7 +139,6 @@ export const Definitions = {
|
||||
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||
"session.tab.select.10": keybind("<leader>0,ctrl+0", "Switch to session tab 10"),
|
||||
|
||||
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
|
||||
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -167,10 +164,10 @@ export const Definitions = {
|
||||
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
|
||||
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||
"session.message.next": keybind("none", "Navigate to next message"),
|
||||
"session.message.previous": keybind("none", "Navigate to previous message"),
|
||||
"session.message.user.next": keybind("none", "Navigate to next user message"),
|
||||
"session.message.user.previous": keybind("none", "Navigate to previous user message"),
|
||||
"session.message.next": keybind("alt+down", "Navigate to next message"),
|
||||
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
|
||||
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
|
||||
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
|
||||
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
|
||||
"messages.copy": keybind("<leader>y", "Copy message"),
|
||||
"session.undo": keybind("<leader>u", "Undo message"),
|
||||
@@ -180,7 +177,6 @@ export const Definitions = {
|
||||
"prompt.submit": keybind("none", "Submit prompt"),
|
||||
"prompt.queue": keybind("alt+return", "Queue prompt"),
|
||||
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
||||
"prompt.images.view": keybind("<leader>i", "View image attachments"),
|
||||
"prompt.skills": keybind("none", "Open skill selector"),
|
||||
"prompt.stash": keybind("none", "Stash prompt"),
|
||||
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
|
||||
@@ -206,8 +202,8 @@ export const Definitions = {
|
||||
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
|
||||
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
|
||||
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
|
||||
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
|
||||
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
|
||||
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
|
||||
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
|
||||
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
|
||||
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
|
||||
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
|
||||
|
||||
@@ -1983,7 +1983,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
@@ -2780,7 +2779,9 @@ function Shell(props: ToolProps) {
|
||||
const permission = useToolPermission(() => props.part)
|
||||
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
|
||||
const shellID = createMemo(() => stringValue(props.metadata.shellID))
|
||||
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
|
||||
const background = createMemo(
|
||||
() => props.part.state.status === "completed" && props.metadata.status === "running",
|
||||
)
|
||||
const backgroundRunning = createMemo(() => {
|
||||
const id = shellID()
|
||||
return Boolean(id && data.shell.get(id))
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface DialogSelectProps<T> {
|
||||
bindings?: readonly KeymapCommand[]
|
||||
current?: T
|
||||
focusCurrent?: boolean
|
||||
sectionNavigation?: boolean
|
||||
}
|
||||
|
||||
type DialogSelectActionBase<T> = {
|
||||
@@ -328,15 +327,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
|
||||
}
|
||||
|
||||
function moveSection(direction: 1 | -1) {
|
||||
if (props.locked) return
|
||||
const sections = grouped().filter(([_, options]) => options.length > 0)
|
||||
if (sections.length === 0) return
|
||||
const current = sections.findIndex(([category]) => category === selected()?.category)
|
||||
const section = sections[(current + direction + sections.length) % sections.length]
|
||||
moveTo(flat().indexOf(section[1][0]), true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false, preserve = true) {
|
||||
setFocusedAction(undefined)
|
||||
setStore("selected", next)
|
||||
@@ -498,22 +488,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
]
|
||||
: []),
|
||||
...(props.bindings ?? []),
|
||||
...(props.sectionNavigation
|
||||
? [
|
||||
{
|
||||
bind: "alt+up",
|
||||
title: "Previous section",
|
||||
group: "Dialog",
|
||||
run: () => moveSection(-1),
|
||||
},
|
||||
{
|
||||
bind: "alt+down",
|
||||
title: "Next section",
|
||||
group: "Dialog",
|
||||
run: () => moveSection(1),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { Locale } from "./locale"
|
||||
import { stringWidth } from "./string-width"
|
||||
|
||||
const GAP = " · "
|
||||
|
||||
export function marqueeCycleWidth(value: string) {
|
||||
return stringWidth(value + GAP)
|
||||
}
|
||||
|
||||
export function marqueeOverflows(value: string, width: number) {
|
||||
return stringWidth(value) > width
|
||||
}
|
||||
const GAP = " "
|
||||
|
||||
export function marqueeText(value: string, width: number, offset: number) {
|
||||
if (width <= 0) return ""
|
||||
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
|
||||
|
||||
const loop = value + GAP
|
||||
const cursor = offset % marqueeCycleWidth(value)
|
||||
const cursor = offset % stringWidth(loop)
|
||||
const segments = Locale.graphemes(loop + loop)
|
||||
const start = segments.reduce(
|
||||
(state, segment, index) =>
|
||||
|
||||
@@ -186,94 +186,6 @@ test("preserves a moved project when sessions arrive", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows jump between sections", async () => {
|
||||
const handler: FetchHandler = (url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_recent",
|
||||
projectID: "proj_recent",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/tmp/opencode/recent" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_recent",
|
||||
canonical: "/tmp/opencode/recent",
|
||||
name: "Recent project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const next = await renderOpen(handler)
|
||||
try {
|
||||
await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
|
||||
next.app.mockInput.pressArrow("down", { meta: true })
|
||||
next.app.mockInput.pressEnter()
|
||||
await next.app.waitFor(() => next.route.data.type === "home")
|
||||
expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
|
||||
} finally {
|
||||
await next.dispose()
|
||||
}
|
||||
|
||||
const previous = await renderOpen(handler)
|
||||
try {
|
||||
await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
|
||||
previous.app.mockInput.pressArrow("up", { meta: true })
|
||||
previous.app.mockInput.pressEnter()
|
||||
await previous.app.waitFor(() => previous.route.data.type === "home")
|
||||
expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
|
||||
} finally {
|
||||
await previous.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows stay in the only visible section", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_effect",
|
||||
canonical: "/tmp/effect",
|
||||
name: "Effect",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_opencode",
|
||||
canonical: "/tmp/opencode",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode"))
|
||||
await fixture.app.mockInput.typeText("Effect")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode"))
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
TabPulse,
|
||||
blendTabPulseColor,
|
||||
completionPulseOpacity,
|
||||
glowIgnitionLevel,
|
||||
@@ -8,6 +12,50 @@ import {
|
||||
} from "../../src/component/tab-pulse"
|
||||
import { tint } from "../../src/theme/color"
|
||||
|
||||
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const flash = RGBA.fromHex("#f0f0f0")
|
||||
const [promptPulse, setPromptPulse] = createSignal(0)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
active={true}
|
||||
promptPulse={promptPulse()}
|
||||
color={background}
|
||||
flashColor={flash}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(1)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
|
||||
|
||||
await Bun.sleep(800)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(2)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
|
||||
@@ -74,25 +74,6 @@ test("uses command IDs as keybind keys", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves current navigation defaults", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }])
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
|
||||
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
|
||||
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
|
||||
expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }])
|
||||
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
|
||||
expect(config.keybinds.get("session.message.next")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.previous")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.user.next")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
|
||||
expect(config.keybinds.get("input.buffer.home")).toEqual([])
|
||||
expect(config.keybinds.get("input.buffer.end")).toEqual([])
|
||||
expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
|
||||
})
|
||||
|
||||
test("preserves migrated v1 keybind defaults", () => {
|
||||
const pairs = [
|
||||
["app.exit", "app_exit"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee"
|
||||
import { marqueeText } from "../../src/util/marquee"
|
||||
import { stringWidth } from "../../src/util/string-width"
|
||||
|
||||
describe("marquee text", () => {
|
||||
@@ -7,22 +7,11 @@ describe("marquee text", () => {
|
||||
expect(marqueeText("Short", 10, 8)).toBe("Short")
|
||||
})
|
||||
|
||||
test("does not classify an exact fit as overflow", () => {
|
||||
expect(marqueeOverflows("Exact fit", 9)).toBe(false)
|
||||
expect(marqueeOverflows("Exact fit", 8)).toBe(true)
|
||||
})
|
||||
|
||||
test("starts clipped and scrolls through a long title", () => {
|
||||
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
|
||||
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
|
||||
expect(marqueeText("A long session title", 8, 15)).toBe("title · ")
|
||||
expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon")
|
||||
})
|
||||
|
||||
test("loops after one spaced dot separator", () => {
|
||||
const title = "A long session title"
|
||||
expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon")
|
||||
expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s")
|
||||
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
|
||||
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
|
||||
})
|
||||
|
||||
test("clips wide graphemes to terminal cells", () => {
|
||||
|
||||
+3
-4
@@ -94,14 +94,13 @@ const client = OpenCode.make({
|
||||
const health = await client.health.get()
|
||||
```
|
||||
|
||||
`Service.ensure()` accepts an optional registration file, version, service
|
||||
command, and `onStart` callback. `version` accepts either an exact value or a
|
||||
compatibility predicate:
|
||||
`Service.ensure()` accepts an optional registration file, required version,
|
||||
service command, and `onStart` callback:
|
||||
|
||||
```ts
|
||||
const endpoint = await Service.ensure({
|
||||
file: "/var/run/opencode/service.json",
|
||||
version: (version) => version.startsWith("2."),
|
||||
version: "2.0.0",
|
||||
command: ["opencode", "serve", "--service"],
|
||||
onStart(reason, previousVersion) {
|
||||
console.log(reason, previousVersion)
|
||||
|
||||
Reference in New Issue
Block a user