Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 182494f885 test(app): restore archive compatibility 2026-07-28 11:20:23 -04:00
Kit Langton 2b767d8cb1 feat(session): add durable session archival 2026-07-28 11:18:37 -04:00
190 changed files with 4924 additions and 6076 deletions
-16
View File
@@ -1,16 +0,0 @@
import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(context: Context) {
const timer = setTimeout(() => {
context.ui.toast.show({
title: "TUI plugin discovery works",
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
variant: "success",
duration: 30_000,
})
}, 1_000)
return () => clearTimeout(timer)
},
}
-58
View File
@@ -147,11 +147,6 @@
"ws": "8.21.0",
},
"devDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
@@ -169,11 +164,6 @@
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
@@ -374,7 +364,6 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/ai": "workspace:*",
@@ -607,20 +596,17 @@
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
"solid-js",
],
},
"packages/protocol": {
@@ -873,20 +859,6 @@
"vite": "catalog:",
},
},
"packages/theme": {
"name": "@opencode-ai/theme",
"version": "0.0.0",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.4",
@@ -896,7 +868,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
@@ -1059,7 +1030,6 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
@@ -1707,8 +1677,6 @@
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.10.1", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-I2TIWHkey4wpLCncUQymBz/yWrjp/TE91yjrPvSLaPMUMRf3wXwEmelGNnZ5ynxy3cUauYg5b5hsUwhOK4skfA=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
@@ -2107,8 +2075,6 @@
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"],
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
@@ -3285,28 +3251,6 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA=="],
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g=="],
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA=="],
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="],
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA=="],
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="],
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w=="],
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg=="],
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.2", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w=="],
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
@@ -4071,8 +4015,6 @@
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
-1
View File
@@ -15,7 +15,6 @@
],
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
+2 -1
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
if (input.status !== undefined && input.status >= 500)
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,6 +145,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
-157
View File
@@ -1,157 +0,0 @@
export * as TestLLM from "./testing"
import { LLMClient, type Interface as LLMClientShape } from "./route/client"
import {
LLMEvent,
LLMResponse,
type FinishReasonDetails,
type LLMError,
type LLMRequest,
type UsageInput,
} from "./schema"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
export interface Interface {
readonly requests: LLMRequest[]
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
readonly always: (response: Response) => Effect.Effect<void>
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
readonly client: LLMClientShape
}
export interface LayerOptions {
readonly transformRequest?: (request: LLMRequest) => LLMRequest
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
readonly fallback?: Response
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
...events: readonly LLMEvent[]
) => [
LLMEvent.stepStart({ index: 0 }),
...events,
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
LLMEvent.finish({ reason: options.reason }),
]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
export const toolCalls = (...events: readonly LLMEvent[]) =>
complete({ reason: { normalized: "tool-calls" } }, ...events)
const textEvents = (value: string, id: string) => [
LLMEvent.textStart({ id }),
LLMEvent.textDelta({ id, text: value }),
LLMEvent.textEnd({ id }),
]
export const text = (value: string, id: string) => stop(...textEvents(value, id))
export const textWithUsage = (value: string, id: string, inputTokens: number) =>
complete(
{ reason: { normalized: "stop" }, usage: { inputTokens, nonCachedInputTokens: inputTokens } },
...textEvents(value, id),
)
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
export const layer = (options: LayerOptions = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const requests: LLMRequest[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
let fallback = options.fallback
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const stream = ((request: LLMRequest) => {
requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
Deferred.doneUnsafe(waiting, Effect.void)
const response = responses.shift() ?? fallback
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
const streamed = toStream(response)
const gate = activeGate
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
}) as LLMClientShape["stream"]
const client = LLMClient.Service.of({
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream,
generate: (request) =>
stream(request).pipe(
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.flatMap((state) => {
const response = LLMResponse.complete(state)
if (response) return Effect.succeed(response)
return Effect.die("TestLLM response ended without a terminal finish event")
}),
),
})
return Service.of({
requests,
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
always: (response) =>
Effect.sync(() => {
fallback = response
}),
wait,
gate: Effect.gen(function* () {
const gate = {
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
release: yield* Latch.make(),
}
activeGate = gate
const release = Effect.sync(() => {
if (activeGate === gate) activeGate = undefined
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
yield* Effect.addFinalizer(() => release)
return {
started: Queue.take(gate.started),
release,
}
}),
client,
})
}),
)
export const clientLayer = Layer.effect(
LLMClient.Service,
Effect.map(Service, (service) => service.client),
)
export const push = (...responses: readonly Response[]) => Service.use((service) => service.push(...responses))
export const always = (response: Response) => Service.use((service) => service.always(response))
export const wait = (count: number) => Service.use((service) => service.wait(count))
export const gate = Service.use((service) => service.gate)
-2
View File
@@ -19,7 +19,6 @@ import {
OpenResponses,
} from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing"
describe("public exports", () => {
test("root exposes app-facing runtime APIs", () => {
@@ -29,7 +28,6 @@ describe("public exports", () => {
expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make)
expect(TestLLM.layer).toBeFunction()
})
test("route barrel exposes route-authoring APIs", () => {
-6
View File
@@ -58,12 +58,6 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -562,7 +562,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
@@ -650,15 +649,13 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.controller.canRemove(key)}>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -21,7 +21,6 @@ export const ServerRowMenu: Component<{
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
canRemove={props.controller.canRemove(key)}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
@@ -48,7 +47,6 @@ export const ServerRowMenuView: Component<{
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
canRemove: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
@@ -86,10 +84,10 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<Show when={props.canRemove}>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
+21 -19
View File
@@ -395,25 +395,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
-16
View File
@@ -178,17 +178,6 @@ export function resolveServerList(input: {
return [...deduped.values()]
}
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
@@ -323,10 +312,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
function canRemove(key: ServerConnection.Key) {
return canRemoveServer({ key, provided: props.servers, stored: store.list })
}
const isReady = Object.assign(
createMemo(() => ready() && !!state.active),
{ promise: ready.promise },
@@ -365,7 +350,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
canRemove,
scope,
projects: {
...projects,
@@ -60,7 +60,6 @@ export function createHomeProjectsController(home: HomeController) {
defaultKey: serverManagement.defaultKey,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
@@ -47,7 +47,6 @@ export type HomeProjectsViewProps = {
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
@@ -193,7 +192,6 @@ function HomeServerRow(props: {
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
@@ -279,7 +277,6 @@ function HomeServerRow(props: {
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
@@ -24,7 +24,6 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
+51 -30
View File
@@ -57,7 +57,7 @@ import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider } from "@/context/terminal"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useSettingsCommand } from "@/components/settings-dialog"
@@ -70,11 +70,11 @@ import {
createSessionComposerRegionController,
SessionComposerRegion,
} from "@/pages/session/composer"
import { createOpenReviewFile, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createSessionController } from "@/pages/session/session-controller"
import { useSessionLayout } from "@/pages/session/session-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
@@ -101,6 +101,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string }
@@ -365,28 +366,15 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const navigate = useNavigate()
const isDesktop = createMediaQuery("(min-width: 768px)")
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
const canReview = createMemo(() => !!sync().project)
const session = createSessionController({
review: isDesktop,
hasReview: canReview,
fileBrowser: (sessionID) => newSessionDesign() && isDesktop() && !!sessionID,
})
const params = session.identity.params
const sessionKey = session.identity.sessionKey
const workspaceKey = session.identity.workspaceKey
const tabs = session.layout.tabs
const view = session.layout.view
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = session.ownership
const info = session.data.info
const isChildSession = session.data.isChild
const revertMessageID = session.data.revertMessageID
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -445,8 +433,8 @@ export default function Page() {
const current = tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = session.tabs.normalizeAll(from.all)
const active = from.active ? session.tabs.normalize(from.active) : undefined
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
@@ -457,6 +445,7 @@ export default function Page() {
),
)
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
@@ -521,16 +510,46 @@ export default function Page() {
}),
)
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
function normalizeTabs(list: string[]) {
const seen = new Set<string>()
const next: string[] = []
for (const item of list) {
const value = normalizeTab(item)
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
return next
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const activeTab = session.tabs.activeTab
const activeFileTab = session.tabs.activeFileTab
const timeline = createTimelineModel({ session })
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const canReview = createMemo(() => !!sync().project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: canReview,
})
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
const lastUserMessage = timeline.lastUserMessage
const messages = timeline.messages
const messagesReady = timeline.ready
const sessionSync = timeline.resource
const userMessages = timeline.userMessages
@@ -1119,10 +1138,11 @@ export default function Page() {
useComposerCommands()
useSessionCommands({
session,
navigateMessageByOffset,
setActiveMessage,
focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
@@ -1672,6 +1692,8 @@ export default function Page() {
})
}
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const session = target.session.get(sessionID)
if (!session) return
@@ -1732,7 +1754,7 @@ export default function Page() {
const queueEnabled = createMemo(() => {
const id = params.id
if (!id) return false
return settings.general.followup() === "queue" && session.data.working() && !composer.blocked() && !isChildSession()
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
})
const followupText = (item: FollowupDraft) => {
@@ -1910,7 +1932,7 @@ export default function Page() {
if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return
if (session.data.working()) return
if (busy(sessionID)) return
void sendFollowup(sessionID, item.id)
})
@@ -2058,7 +2080,6 @@ export default function Page() {
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={session}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -2136,7 +2157,7 @@ export default function Page() {
: undefined,
onResponseSubmit: resumeScroll,
openParent: () => {
const id = session.data.parentID()
const id = info()?.parentID
if (!id) return
navigate(
params.serverKey
@@ -1,71 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
id,
sessionID: "session",
role: "user",
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
id: "msg_2",
sessionID: "session",
role: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
describe("session controller invariants", () => {
test("normalizes file tabs once while preserving non-file tabs and order", () => {
const normalize = (tab: string) => normalizeSessionTab(tab, (value) => value.toLowerCase())
expect(normalizeSessionTabs(["review", "file://SRC/A.TS", "file://src/a.ts", "context"], normalize)).toEqual([
"review",
"file://src/a.ts",
"context",
])
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant, user("msg_3"), user("msg_5")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleSessionUserMessages(users, "msg_3").map((message) => message.id)).toEqual(["msg_1"])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
test("rejects work captured by a previous session", () => {
createRoot((dispose) => {
const [key, setKey] = createSignal("session-a")
const ownership = createSessionOwnership(key)
const captured = ownership.capture()
let ran = false
setKey("session-b")
expect(captured.current()).toBe(false)
expect(captured.run(() => (ran = true))).toBeUndefined()
expect(ran).toBe(false)
dispose()
})
})
})
@@ -1,101 +0,0 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
const file = useFile()
const sync = useSync()
const layout = useSessionLayout()
const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => {
const id = sessionID()
return id ? sync().session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? sync().session.get(id) : undefined
})
const status = createMemo(() => {
const id = sessionID()
return id ? (sync().data.session_status[id] ?? idle) : idle
})
const messages = createMemo(() => {
const id = sessionID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
() => selectVisibleSessionUserMessages(userMessages(), revertMessageID()),
emptyUserMessages,
{ equals: same },
)
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
})
return {
identity: {
params: layout.params,
sessionID,
sessionKey: layout.sessionKey,
workspaceKey: layout.workspaceKey,
},
data: {
info,
parent,
parentID,
isChild: createMemo(() => !!parentID()),
status,
working: createMemo(() => {
const id = sessionID()
return id ? sync().data.session_working(id) : false
}),
revertMessageID,
},
history: {
messages,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
},
layout: {
tabs: layout.tabs,
view: layout.view,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
...tabs,
normalize: normalizeTab,
normalizeAll: (values: string[]) => normalizeSessionTabs(values, normalizeTab),
},
}
}
export type SessionController = ReturnType<typeof createSessionController>
@@ -1,19 +0,0 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
return normalizeFileTab(tab)
}
export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) => string) {
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
@@ -1,26 +0,0 @@
export function timelineChildTitle(input: {
parentID?: string
taskDescription?: string
title?: string
fallback: string
}) {
if (!input.parentID) return input.title ?? ""
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -1,30 +0,0 @@
import { describe, expect, test } from "bun:test"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
describe("timeline controller", () => {
test("projects child titles from task descriptions and session fallbacks", () => {
expect(timelineChildTitle({ title: "Root", fallback: "New session" })).toBe("Root")
expect(
timelineChildTitle({ parentID: "parent", taskDescription: "Investigate timeline", fallback: "New session" }),
).toBe("Investigate timeline")
expect(
timelineChildTitle({ parentID: "parent", title: "Fallback (@build subagent)", fallback: "New session" }),
).toBe("Fallback")
expect(timelineChildTitle({ parentID: "parent", fallback: "New session" })).toBe("New session")
})
test("collects the removed session and all descendants", () => {
const removed = timelineRemovedSessionIDs(
[
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "sibling", parentID: "root" },
{ id: "unrelated" },
],
"root",
)
expect([...removed]).toEqual(["root", "child", "grandchild", "sibling"])
})
})
@@ -1,328 +0,0 @@
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return undefined
const value = part.state.input?.description
if (typeof value === "string" && value) return value
return undefined
}
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
const navigate = useNavigate()
const serverSDK = useServerSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const params = input.session.identity.params
const sessionKey = input.session.identity.sessionKey
const sessionID = input.session.identity.sessionID
const status = input.session.data.status
const messages = input.session.history.messages
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = sync().data.session_message[id] ?? []
return boundary ? projected.filter((message) => message.id < boundary) : projected
})
const info = input.session.data.info
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = input.session.data.parentID
const parent = input.session.data.parent
const parentMessages = createMemo(() => {
const id = parentID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parts(message.id))
.map((item) => taskDescription(item, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
const errorMessage = (error: unknown) => {
if (error && typeof error === "object" && "data" in error) {
const data = error.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
}
if (error instanceof Error) return error.message
return language.t("common.requestFailed")
}
const rename = async (title: string) => {
const id = sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await sdk()
.api.session.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
return false
})
setPending("rename", false)
if (!success) return false
sync().set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
return true
}
const share = async () => {
const id = sessionID()
if (!id || pending.share || !shareEnabled()) return
setPending("share", true)
await serverSDK()
.client.session.share({ sessionID: id })
.catch((error) => console.error("Failed to share session", error))
setPending("share", false)
}
const unshare = async () => {
const id = sessionID()
if (!id || pending.unshare || !shareEnabled()) return
setPending("unshare", true)
await serverSDK()
.client.session.unshare({ sessionID: id })
.catch((error) => console.error("Failed to unshare session", error))
setPending("unshare", false)
}
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
if (params.serverKey)
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
navigate(`/${params.dir}/session`)
}
const archive = async (id: string) => {
const session = sync().session.get(id)
if (!session || (await sdk().protocol) !== "v1") return
const index = sync().data.session.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sync().data.session[index + 1] ?? sync().data.session[index - 1])
await sdk()
.client.session.update({ sessionID: id, directory: sdk().directory, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((item) => item.id === id)
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync().session.evict(id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [id] })
})
.catch((error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }))
}
const remove = async (id: string) => {
const session = sync().session.get(id)
if (!session) return false
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await sdk()
.api.session.remove({ sessionID: id })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = timelineRemovedSessionIDs(sync().data.session, id)
void navigateAfterRemoval(id, session.parentID, next?.id)
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
removed.forEach((sessionID) => sync().session.evict(sessionID))
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description || sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
return {
data: {
sessionKey,
sessionID,
status,
titleValue,
titleLabel,
shareUrl,
shareEnabled,
parentID,
parentTitle,
childTitle,
showHeader,
parts,
part,
projection,
newLayoutDesigns: settings.general.newLayoutDesigns,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
share: () => pending.share,
unshare: () => pending.unshare,
},
action: {
rename,
share,
unshare,
archive,
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = parentID()
if (id) navigate(href(id))
},
viewShare: () => {
const url = shareUrl()
if (url) platform.openLink(url)
},
copyShareUrl: async () => {
const url = shareUrl()
if (!url) return
await navigator.clipboard.writeText(url).then(
() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
(error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
)
},
},
}
}
export type TimelineController = ReturnType<typeof createTimelineController>
@@ -11,8 +11,10 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
@@ -33,6 +35,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
@@ -41,22 +45,43 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -64,6 +89,14 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
const timelineFallbackItemSize = 60
const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
@@ -204,8 +237,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
)
}
type MessageTimelineProps = {
session: TimelineSessionSource
export function MessageTimeline(props: {
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void
@@ -225,42 +257,88 @@ type MessageTimelineProps = {
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
}
function MessageTimelineView(
props: MessageTimelineProps & {
data: TimelineController["data"]
action: TimelineController["action"]
pending: TimelineController["pending"]
},
) {
}) {
let touchGesture: number | undefined
const navigate = useNavigate()
const serverSDK = useServerSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const ownerSessionKey = props.data.sessionKey()
const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
const platform = usePlatform()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = props.data.sessionID
const sessionStatus = props.data.status
const titleLabel = props.data.titleLabel
const shareUrl = props.data.shareUrl
const shareEnabled = props.data.shareEnabled
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const showHeader = props.data.showHeader
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionID = createMemo(() => params.id)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync().session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => getMsgParts(message.id))
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
@@ -450,9 +528,9 @@ function MessageTimelineView(
virtualizer.scrollToEnd()
}
let measuredSessionKey = props.data.sessionKey()
let measuredSessionKey = sessionKey()
createEffect(() => {
const key = props.data.sessionKey()
const key = sessionKey()
timelineRows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
@@ -565,6 +643,88 @@ function MessageTimelineView(
props.setScrollRef(undefined)
})
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
@@ -576,7 +736,7 @@ function MessageTimelineView(
createEffect(
on(
props.data.sessionKey,
sessionKey,
() =>
setTitle({
draft: "",
@@ -589,6 +749,18 @@ function MessageTimelineView(
),
)
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
@@ -600,12 +772,193 @@ function MessageTimelineView(
}
const closeTitleEditor = () => {
if (props.pending.rename()) return
if (titleMutation.isPending) return
setTitle("editing", false)
}
const saveTitleEditor = async () => {
if (await props.action.rename(title.draft)) setTitle("editing", false)
const saveTitleEditor = () => {
const id = sessionID()
if (!id) return
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) {
setTitle("editing", false)
return
}
titleMutation.mutate({ id, title: next })
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(href(parentID))
return
}
if (nextSessionID) {
navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync().session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return false
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync().data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync().set(
produce((draft) => {
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
for (const id of removed) {
sync().session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
@@ -684,7 +1037,7 @@ function MessageTimelineView(
const defaultOpen = createMemo(() => {
const item = part()
if (!item) return
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
})
return (
@@ -697,7 +1050,7 @@ function MessageTimelineView(
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={props.data.newLayoutDesigns()}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -760,8 +1113,8 @@ function MessageTimelineView(
<div
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": props.data.newLayoutDesigns(),
border: !props.data.newLayoutDesigns(),
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
@@ -796,7 +1149,7 @@ function MessageTimelineView(
if (m?.role === "user") return m
})
const messageComments = createMemo(() => {
if (!props.data.newLayoutDesigns()) return []
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
@@ -809,7 +1162,7 @@ function MessageTimelineView(
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={props.data.newLayoutDesigns()}
useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/>
</div>
@@ -857,7 +1210,7 @@ function MessageTimelineView(
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={props.data.showReasoningSummaries()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
</TimelineRowFrame>
@@ -973,16 +1326,16 @@ function MessageTimelineView(
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"bottom-8": props.data.newLayoutDesigns(),
"bottom-6": !props.data.newLayoutDesigns(),
"bottom-8": settings.general.newLayoutDesigns(),
"bottom-6": !settings.general.newLayoutDesigns(),
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
}}
>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<button
type="button"
@@ -1045,22 +1398,22 @@ function MessageTimelineView(
classList={{
"sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!props.data.newLayoutDesigns(),
!settings.general.newLayoutDesigns(),
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2.5": props.data.newLayoutDesigns(),
"pl-2 md:pl-4": !props.data.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
"pl-2.5": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div
classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !props.data.newLayoutDesigns(),
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
@@ -1069,7 +1422,7 @@ function MessageTimelineView(
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
onClick={navigateParent}
>
{parentTitle()}
</button>
@@ -1090,8 +1443,8 @@ function MessageTimelineView(
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
props.data.newLayoutDesigns(),
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
>
@@ -1105,14 +1458,15 @@ function MessageTimelineView(
}}
data-slot="session-title-child"
value={title.draft}
disabled={props.pending.rename()}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": props.data.newLayoutDesigns()
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
@@ -1140,17 +1494,17 @@ function MessageTimelineView(
<div
classList={{
"shrink-0 flex items-center": true,
"gap-2": props.data.newLayoutDesigns(),
"gap-3": !props.data.newLayoutDesigns(),
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<DropdownMenu
gutter={4}
@@ -1213,11 +1567,13 @@ function MessageTimelineView(
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void props.action.archive(id)}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
@@ -1282,11 +1638,11 @@ function MessageTimelineView(
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void props.action.archive(id)}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
@@ -1298,7 +1654,7 @@ function MessageTimelineView(
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={props.data.newLayoutDesigns() ? 6 : 4}
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
@@ -1310,7 +1666,7 @@ function MessageTimelineView(
data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
@@ -1330,7 +1686,7 @@ function MessageTimelineView(
}}
>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
@@ -1351,10 +1707,10 @@ function MessageTimelineView(
size="large"
variant="primary"
class="w-full"
onClick={() => void props.action.share()}
disabled={props.pending.share()}
onClick={shareSession}
disabled={shareMutation.isPending}
>
{props.pending.share()
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@@ -1374,10 +1730,10 @@ function MessageTimelineView(
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={() => void props.action.unshare()}
disabled={props.pending.unshare()}
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{props.pending.unshare()
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@@ -1385,8 +1741,8 @@ function MessageTimelineView(
size="large"
variant="primary"
class="w-full"
onClick={props.action.viewShare}
disabled={props.pending.unshare()}
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
@@ -1414,10 +1770,10 @@ function MessageTimelineView(
<ButtonV2
variant="contrast"
class="w-full"
onClick={() => void props.action.share()}
disabled={props.pending.share()}
onClick={shareSession}
disabled={shareMutation.isPending}
>
{props.pending.share()
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</ButtonV2>
@@ -1443,7 +1799,7 @@ function MessageTimelineView(
variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={() => void props.action.copyShareUrl()}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
@@ -1451,18 +1807,18 @@ function MessageTimelineView(
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={props.action.viewShare}
disabled={props.pending.unshare()}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={() => void props.action.unshare()}
disabled={props.pending.unshare()}
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{props.pending.unshare()
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</ButtonV2>
@@ -1,24 +1,23 @@
import type { Message } from "@opencode-ai/sdk/v2"
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import type { SessionController } from "../session-controller"
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
export function createTimelineModel(input: {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
const serverSync = useServerSync()
const sync = useSync()
let refreshFrame: number | undefined
let refreshTimer: number | undefined
const [resource] = createResource(
() => input.session.identity.sessionID(),
() => input.sessionID(),
(id) => {
clearRefresh()
if (!id) return
@@ -30,7 +29,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (input.session.identity.sessionID() !== id) return
if (input.sessionID() !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
@@ -40,21 +39,33 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
return sync().session.sync(id)
},
)
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
})
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return id ? sync().session.history.more(id) : false
})
const loading = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return id ? sync().session.history.loading(id) : false
})
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.session.identity.sessionID,
sessionID: input.sessionID,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@@ -67,12 +78,12 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
return {
history: { loadOlder, loading, more },
lastUserMessage: input.session.history.lastUserMessage,
messages: input.session.history.messages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
messages,
ready,
resource,
userMessages: input.session.history.userMessages,
visibleUserMessages: input.session.history.visibleUserMessages,
userMessages,
visibleUserMessages,
}
function clearRefresh() {
@@ -83,10 +94,19 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
}
}
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
}
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
@@ -13,25 +13,19 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
identity: SessionController["identity"]
data: Pick<SessionController["data"], "info" | "revertMessageID">
history: Pick<SessionController["history"], "userMessages" | "visibleUserMessages">
layout: SessionController["layout"]
ownership: SessionController["ownership"]
tabs: Pick<SessionController["tabs"], "activeFileTab" | "closableTab">
}
export type SessionCommandContext = {
session: SessionCommandSource
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -55,17 +49,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const params = actions.session.identity.params
const tabs = actions.session.layout.tabs
const view = actions.session.layout.view
const sessionOwnership = actions.session.ownership
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = sessionOwnership.capture()
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionController["ownership"]["capture"]>
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
@@ -76,13 +68,39 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
input.owner.run(input.updateViewport)
}
const info = actions.session.data.info
const activeFileTab = actions.session.tabs.activeFileTab
const closableTab = actions.session.tabs.closableTab
const info = () => {
const id = params.id
if (!id) return
return sync().session.get(id)
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = settings.visibility.fileTree
const userMessages = actions.session.history.userMessages
const visibleUserMessages = actions.session.history.visibleUserMessages
const messages = () => {
const id = params.id
if (!id) return []
return sync().data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
@@ -291,7 +309,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const session = sdk().api.session
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = actions.session.data.revertMessageID()
const revert = info()?.revert?.messageID
const messages = userMessages()
const message = findLast(messages, (x) => !revert || x.id < revert)
if (!message) return
@@ -320,7 +338,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const messages = userMessages()
const promptSession = prompt.capture()
const revertMessageID = actions.session.data.revertMessageID()
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = messages.find((x) => x.id > revertMessageID)
@@ -53,7 +53,6 @@ function setup(
}
describe("createCompatibleApi", () => {
/*
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
@@ -64,7 +63,6 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
*/
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
@@ -147,7 +145,6 @@ describe("createCompatibleApi", () => {
expect(detections).toBe(1)
})
/*
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
@@ -155,7 +152,6 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
*/
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
+4 -4
View File
@@ -27,7 +27,7 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -183,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
-10
View File
@@ -56,16 +56,6 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
+2 -13
View File
@@ -5,7 +5,6 @@ import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
@@ -23,7 +22,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const solidPlugin = createSolidTransformPlugin()
const plugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -56,16 +55,6 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
loader: "js",
}))
},
}
const target = [
binary,
item.os === "win32" ? "windows" : item.os,
@@ -80,7 +69,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin, parcelWatcherPlugin],
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-2
View File
@@ -37,8 +37,6 @@ export async function collectNodeAssets(target: NodeTarget) {
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
}),
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
{
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
+1 -36
View File
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
const env = {
...process.env,
HOME: root,
@@ -29,7 +29,6 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -50,11 +49,6 @@ try {
{ signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
signal: AbortSignal.timeout(5_000),
@@ -94,7 +88,6 @@ try {
} finally {
processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited))
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
}
const output = await Promise.all(errors)
@@ -140,31 +133,3 @@ async function waitForReady(url: string, headers: HeadersInit) {
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}
function pluginSource() {
return 'export default { id: "smoke", setup: async () => {} }\n'
}
async function pluginIDs(url: string, headers: HeadersInit) {
const endpoint = new URL("/api/plugin", url)
endpoint.searchParams.set("location[directory]", root)
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
const body: unknown = await response.json()
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
throw new Error("Compiled service returned an invalid plugin list")
}
return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
? [plugin.id]
: [],
)
}
async function waitForPlugin(url: string, headers: HeadersInit) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
}
throw new Error("Compiled service did not discover the created plugin")
}
+5 -14
View File
@@ -14,23 +14,10 @@ const ServerParams = {
),
}
const PermissionParams = {
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
Flag.withDefault(false),
Flag.withHidden,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
...PermissionParams,
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
@@ -209,7 +196,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
...PermissionParams,
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("service", {
@@ -59,7 +59,6 @@ export default Runtime.handler(Commands, (input) =>
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
config: {
path: config.path,
+1 -1
View File
@@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
file: [...input.file],
title: Option.getOrUndefined(input.title),
thinking: input.thinking,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
auto: input.auto || input.yolo,
}),
)
}),
-6
View File
@@ -11,8 +11,6 @@ export function nodeTarget(platform: string, arch: string) {
const targetArch = arch as "arm64" | "x64"
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
return {
platform: targetPlatform,
@@ -21,10 +19,6 @@ export function nodeTarget(platform: string, arch: string) {
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
parcelWatcherPackage,
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
fffPackage,
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
}
}
-44
View File
@@ -30,44 +30,6 @@ function runtimeRequirePlugin(): Plugin {
}
}
function fffNodePlugin(): Plugin {
return {
name: "opencode:fff-node",
enforce: "pre",
transform(code, id) {
const normalized = id.replaceAll("\\", "/")
if (normalized.endsWith("/ffi-rs/index.js")) {
const start = code.indexOf("if (!nativeBinding) {")
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
return `const unavailable = () => { throw new Error("ffi-rs native binding unavailable") }
const nativeBinding = globalThis.__OPENCODE_FFF_FFI ?? {
DataType: new Proxy({}, { get: (target, key) => target[key] ?? key }),
PointerType: {},
FFITypeTag: {},
open: unavailable,
close: unavailable,
load: unavailable,
isNullPointer: unavailable,
createPointer: unavailable,
restorePointer: unavailable,
unwrapPointer: unavailable,
wrapPointer: unavailable,
freePointer: unavailable,
}
const loadError = undefined
${code.slice(start)}`
}
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
const transformed = code.replace(
"export function findBinary() {",
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
)
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
return transformed
},
}
}
const resolve = {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
@@ -194,11 +156,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
}
@@ -217,7 +174,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
solid({
solid: {
generate: "universal",
+76 -62
View File
@@ -152,15 +152,19 @@ export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title:
export type Endpoint5_8Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = {
export type Endpoint5_9Input = { readonly sessionID: Session.ID }
export type Endpoint5_9Output = void
export type SessionArchiveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
}
export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_10Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -170,10 +174,10 @@ export type Endpoint5_10Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -185,19 +189,19 @@ export type Endpoint5_11Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -206,81 +210,81 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_15Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_16Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
export type Endpoint5_16Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { readonly sessionID: Session.ID }
export type Endpoint5_17Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_18Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_18Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = {
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_25Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = {
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_26Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_26Output =
export type Endpoint5_27Output =
| (
| {
readonly id: Event.ID
@@ -323,6 +327,15 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.archived"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -825,19 +838,19 @@ export type Endpoint5_26Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_30Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -849,6 +862,7 @@ export interface SessionApi<E = never> {
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
readonly archive: SessionArchiveOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
+65 -57
View File
@@ -76,6 +76,8 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -358,14 +360,19 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.archive"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -383,8 +390,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -404,16 +411,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -430,29 +437,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -462,27 +469,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -490,7 +489,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -498,29 +497,37 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveStream<Endpoint5_27Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -532,18 +539,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -560,23 +567,24 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(raw),
move: Endpoint5_9(raw),
prompt: Endpoint5_10(raw),
command: Endpoint5_11(raw),
skill: Endpoint5_12(raw),
synthetic: Endpoint5_13(raw),
shell: Endpoint5_14(raw),
compact: Endpoint5_15(raw),
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
archive: Endpoint5_9(raw),
move: Endpoint5_10(raw),
prompt: Endpoint5_11(raw),
command: Endpoint5_12(raw),
skill: Endpoint5_13(raw),
synthetic: Endpoint5_14(raw),
shell: Endpoint5_15(raw),
compact: Endpoint5_16(raw),
wait: Endpoint5_17(raw),
revert: { stage: Endpoint5_18(raw), clear: Endpoint5_19(raw), commit: Endpoint5_20(raw) },
context: Endpoint5_21(raw),
pending: { list: Endpoint5_22(raw) },
instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } },
generate: Endpoint5_26(raw),
log: Endpoint5_27(raw),
interrupt: Endpoint5_28(raw),
background: Endpoint5_29(raw),
message: Endpoint5_30(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -28,6 +28,8 @@ import type {
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionArchiveInput,
SessionArchiveOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -553,6 +555,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
archive: (input: SessionArchiveInput, requestOptions?: RequestOptions) =>
request<SessionArchiveOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/archive`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
request<SessionMoveOutput>(
{
@@ -611,6 +611,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionArchived = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.archived"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = {
id: string
created: number
@@ -2159,6 +2169,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionDeleted
| SessionForked
| SessionInputPromoted
@@ -2253,6 +2264,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2730,6 +2742,10 @@ export type SessionRenameInput = {
export type SessionRenameOutput = void
export type SessionArchiveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionArchiveOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
+2
View File
@@ -432,6 +432,7 @@ test("session methods use the public HTTP contract", async () => {
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
await client.session.archive({ sessionID: "ses_test" })
const admitted = await client.session.prompt({
sessionID: "ses_test",
text: "Hello",
@@ -467,6 +468,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/archive"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/generate"],
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
-1
View File
@@ -92,7 +92,6 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
+50 -50
View File
@@ -1,52 +1,71 @@
import type {
DirItem,
DirSearchResult,
FileItem,
GrepCursor,
GrepMatch,
GrepResult,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
export interface Init {
basePath: string
frecencyDbPath?: string
historyDbPath?: string
useUnsafeNoLock?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
disableWatch?: boolean
aiMode?: boolean
logFilePath?: string
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
enableFsRootScanning?: boolean
enableHomeDirScanning?: boolean
}
export interface File {
relativePath: string
fileName: string
modified: number
}
export interface Directory {
relativePath: string
dirName: string
maxAccessFrecency: number
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
items: File[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
items: Directory[]
scores: Array<{ total: number }>
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
items: Mixed[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export type Cursor = null
export interface Hit {
relativePath: string
fileName: string
lineNumber: number
byteOffset: number
lineContent: string
matchRanges: [number, number][]
contextBefore?: string[]
contextAfter?: string[]
}
export interface Grep {
items: GrepResult["items"]
items: Hit[]
totalMatched: number
totalFilesSearched: number
totalFiles: number
@@ -109,30 +128,11 @@ export interface Picker {
}
export function available() {
return FileFinder?.isAvailable() ?? false
return false
}
export function create(opts: Init): Result<Picker> {
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on node runtime" }
}
export * as Fff from "./fff.node"
@@ -1,13 +0,0 @@
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const require = createRequire(import.meta.url)
export default function load() {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
return require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
}
+11 -2
View File
@@ -9,14 +9,23 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
import loadBinding from "./watcher-binding"
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
const require = createRequire(import.meta.url)
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch {
return
}
+56 -33
View File
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
@@ -230,8 +230,10 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Deferred.make<void>()
let observed = 0
let applied = -1
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
@@ -263,42 +265,63 @@ const layer = Layer.effect(
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() => Effect.sync(() => ++observed)),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
if (applied >= target) return
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
applied = target
}),
)
})
const sourceChanges = config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
// Make accepted filesystem work visible to flush before coalescing the burst.
Stream.mapEffect(() => Effect.sync(() => ++observed)),
Stream.debounce("100 millis"),
)
const busUpdates = bus
.subscribe([Event.Updated, SdkPlugins.Updated])
.pipe(Stream.mapEffect(() => Effect.sync(() => ++observed)))
const updates = yield* Stream.merge(busUpdates, sourceChanges).pipe(
Stream.toQueue({ capacity: 1, strategy: "sliding" }),
)
const signals = yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(updates)).pipe(
Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }),
)
const attempt = (target: number) =>
activate(target).pipe(
Effect.map(() => observed === target),
Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))),
)
yield* signals.pipe(
Stream.runForEach((target) =>
activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* signals.pipe(
Stream.debounce("100 millis"),
Stream.mapEffect(attempt),
Stream.filter((settled) => settled),
Stream.take(1),
Stream.runDrain,
Effect.andThen(Deferred.succeed(ready, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Deferred.await(ready) })
}),
)
+6
View File
@@ -227,6 +227,7 @@ export interface Interface {
model: Model.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
@@ -706,6 +707,11 @@ const layer = Layer.effect(
title: input.title,
})
}),
archive: Effect.fn("Session.archive")(function* (sessionID) {
const session = yield* result.get(sessionID)
if (session.time.archived) return
yield* bus.publish(SessionEvent.Archived, { sessionID })
}),
move: Effect.fn("Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
+1 -1
View File
@@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
archived: row.time_archived !== null ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
@@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
"session.archived": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
+11
View File
@@ -609,6 +609,17 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Archived, (event) =>
db
.update(SessionTable)
.set({
time_archived: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InputPromoted, (event) =>
+3 -10
View File
@@ -44,7 +44,6 @@ type Active = {
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
@@ -135,13 +134,6 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
@@ -175,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const shell = yield* resolve()
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell, options)
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@@ -319,7 +312,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, timeout, output, remove })
}),
)
+11 -39
View File
@@ -15,40 +15,22 @@ import { Shell } from "../../shell"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
const OS =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
const description = (shell?: string) =>
[
"Execute a shell command and return its output.",
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
"Quote file paths containing spaces or special characters.",
"Prefer dedicated tools over shell commands when possible.",
"When output is large, the full result is saved to a file and a truncated preview is returned.",
"Rely on automatic truncation unless filtering the output is more useful.",
"Commands accept an optional timeout, background commands have no timeout by default.",
"Background commands return immediately, and you will be notified when they complete.",
].join(" ")
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.optionalKey(Schema.String).annotate({
description:
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
}),
timeout: Schema.optionalKey(NonNegativeInt).annotate({
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}),
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
.annotate({
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
@@ -87,11 +69,13 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
@@ -160,7 +144,7 @@ export const Plugin = {
({
name,
options: { codemode: false },
description: description(),
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
execute: (input, context) =>
@@ -205,12 +189,8 @@ export const Plugin = {
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
})
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
@@ -311,13 +291,5 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(yield* shell.name())
}),
)
}),
}
+76 -100
View File
@@ -32,109 +32,85 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
},
draft.add(
{
name,
options: { codemode: false },
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a provider so the agent can search the web",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
title: "Provider",
description: "OpenCode will use your choice for future searches.",
type: "string",
required: true,
custom: false,
options: [
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
{ value: "__disable__", label: "Disable web search" },
],
})
: undefined
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", providerID)
return yield* ctx.websearch.query(input)
})
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const answer = response.answer.provider
if (answer === "__disable__") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", answer)
return yield* ctx.websearch.query(input)
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
}),
)
const output = {
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
),
),
}),
},
),
)
.pipe(Effect.orDie)
+17 -4
View File
@@ -1,7 +1,6 @@
import { expect } from "bun:test"
import { Model } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
@@ -10,7 +9,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { ID, Info, Ref } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Layer } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -65,7 +64,21 @@ const aisdk = Layer.mock(AISDK.Service, {
},
model: () => Effect.succeed(runtime),
})
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: () => Stream.die("unused"),
generate: () =>
Effect.sync(() => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "OK" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
+28
View File
@@ -660,4 +660,32 @@ describe("Session.create", () => {
).toBe("Session.NotFoundError")
}),
)
it.effect("archives a Session through one durable event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.archive(created.id)
yield* session.archive(created.id)
expect((yield* session.get(created.id)).time.archived).toBeDefined()
const events = Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))
expect(events.map((event) => event.type)).toContain("session.archived")
expect(events.filter((event) => event.type === "session.archived")).toHaveLength(1)
}),
)
it.effect("rejects archiving a missing Session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(
yield* session.archive(Session.ID.make("ses_missing_archive")).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
).toBe("Session.NotFoundError")
}),
)
})
File diff suppressed because it is too large Load Diff
+6 -11
View File
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
: "printf body && exit 7"
const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number, release: string) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
@@ -200,11 +200,10 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
// Code Mode receives the declared output schema, including the command output text.
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
@@ -410,11 +409,7 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text.includes("output-start")).toBe(false)
expect(content.text.includes("output-end")).toBe(true)
expect(content).toMatchObject({
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
+16 -147
View File
@@ -30,15 +30,6 @@ const webSearchToolNode = makeLocationNode({
const sessionID = Session.ID.make("ses_websearch_test")
const assertions: Permission.AssertInput[] = []
const queries: WebSearch.Input[] = []
const formRequests: Form.CreateInput[] = []
const values = new Map<string, KV.Value>()
const providers = [
{ id: WebSearch.ID.make("exa"), name: "Exa" },
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
]
let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -47,11 +38,6 @@ let result = new WebSearch.Response({
beforeEach(() => {
assertions.length = 0
queries.length = 0
formRequests.length = 0
values.clear()
providerRequired = false
formResponse = { status: "cancelled" }
formResponses.length = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -74,15 +60,11 @@ const websearch = Layer.succeed(
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
providers: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.gen(function* () {
Effect.sync(() => {
queries.push(input)
const stored = values.get("websearch:provider")
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
return result
}),
}),
@@ -91,11 +73,7 @@ const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
ask: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
@@ -106,19 +84,22 @@ const form = Layer.succeed(
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
get: (key) => Effect.succeed(values.get(key)),
set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid),
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[Image.node, imagePassthrough],
]),
AppNodeBuilder.build(
LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]),
[
[Permission.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[Image.node, imagePassthrough],
],
),
)
describe("WebSearchTool registration", () => {
@@ -221,116 +202,4 @@ describe("WebSearchTool registration", () => {
})
}),
)
it.effect("asks once and uses the default provider when web search is first enabled", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(values.get("websearch:provider")).toBe("exa")
expect(queries).toHaveLength(2)
expect(formRequests).toEqual([
{
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: "Allow web search via Exa",
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
},
])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(formRequests).toHaveLength(1)
expect(queries).toHaveLength(3)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
providerRequired = true
formResponses.push(
{ status: "answered", answer: { choice: "choose" } },
{ status: "answered", answer: { provider: "parallel" } },
)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
expect(values.get("websearch:provider")).toBe("parallel")
expect(queries).toHaveLength(2)
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
})
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "error" })
expect(values.get("websearch:provider")).toBe(false)
expect(queries).toHaveLength(1)
}),
)
})
+1 -6
View File
@@ -32,8 +32,7 @@
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
"@opentui/solid": ">=0.4.5"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -44,9 +43,6 @@
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -56,7 +52,6 @@
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
+6 -25
View File
@@ -113,22 +113,7 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "sidebar.content": {
readonly sessionID: string
}
readonly "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
export interface App {
readonly version: string
readonly channel: string
}
export type Slot = (props: Record<string, any>) => JSX.Element
export type ToastVariant = "info" | "success" | "warning" | "error"
@@ -230,11 +215,11 @@ export interface DialogSelectOptions<Value> {
}
export interface Dialog {
/** Shows a dialog. */
show(render: () => JSX.Element, onClose?: () => void): void
/** Sets the active dialog's presentation options. */
/** Shows a dialog and returns a function that closes it. */
show(render: () => JSX.Element, onClose?: () => void): () => void
/** Sets the presentation options for this plugin's active dialog. */
set(options: DialogOptions): void
/** Closes the active dialog. */
/** Closes this plugin's active dialog. */
clear(): void
alert(options: DialogAlertOptions): Promise<void>
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
@@ -323,21 +308,17 @@ export interface Keymap {
export interface UI {
readonly dialog: Dialog
readonly toast: Toast
readonly format: {
path(value: string): string
}
readonly router: {
register(page: Page): () => void
navigate(destination: Destination): void
current(): Route
}
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
readonly slot: (name: string, render: Slot) => () => void
}
export interface Context {
readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly app: App
readonly renderer: CliRenderer
readonly client: OpenCodeClient
readonly data: Data
-1
View File
@@ -1,2 +1 @@
export * as Plugin from "./plugin.js"
export { PluginContextProvider, usePlugin } from "./solid.js"
-19
View File
@@ -1,19 +0,0 @@
import { createComponent, createContext, useContext, type JSX } from "solid-js"
import type { Context } from "./context.js"
const PluginContext = createContext<Context>()
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
return createComponent(PluginContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function usePlugin() {
const context = useContext(PluginContext)
if (!context) throw new Error("PluginContextProvider is missing")
return context
}
+15
View File
@@ -269,6 +269,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.archive", "/api/session/:sessionID/archive", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.archive",
summary: "Archive session",
description: "Archive a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
+8
View File
@@ -86,6 +86,13 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Archived = Event.durable({
type: "session.archived",
...options,
schema: Base,
})
export type Archived = typeof Archived.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -550,6 +557,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Archived,
UsageUpdated,
Deleted,
Forked,
@@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.archived.1",
"session.usage.recorded.1",
"session.forked.2",
"session.input.promoted.1",
+16
View File
@@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.archive",
Effect.fn(function* (ctx) {
yield* session.archive(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.move",
Effect.fn(function* (ctx) {
-36
View File
@@ -1,36 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/theme",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/theme"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./tui": "./src/tui/index.ts",
"./tui/v1": "./src/tui/v1.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
-77
View File
@@ -1,77 +0,0 @@
type OklchColor = {
l: number
c: number
h: number
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function hue(value: number) {
return ((value % 360) + 360) % 360
}
function linearToSrgb(value: number) {
if (value <= 0.0031308) return value * 12.92
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
}
function srgbToLinear(value: number) {
if (value <= 0.04045) return value / 12.92
return Math.pow((value + 0.055) / 1.055, 2.4)
}
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
const linearRed = srgbToLinear(red)
const linearGreen = srgbToLinear(green)
const linearBlue = srgbToLinear(blue)
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
const chroma = Math.sqrt(a * a + b * b)
const angle = Math.atan2(b, a) * (180 / Math.PI)
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
}
function oklchToRgb(color: OklchColor) {
const a = color.c * Math.cos((color.h * Math.PI) / 180)
const b = color.c * Math.sin((color.h * Math.PI) / 180)
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
const l = lRoot * lRoot * lRoot
const m = mRoot * mRoot * mRoot
const s = sRoot * sRoot * sRoot
return {
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
}
}
function fitOklch(color: OklchColor): OklchColor {
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
const rgb = oklchToRgb(base)
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
if (result) return result
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
const output = oklchToRgb(next)
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
}, undefined)
return fitted ?? { ...base, c: 0 }
}
export function oklchToHex(color: OklchColor) {
const rgb = oklchToRgb(fitOklch(color))
const toHex = (value: number) =>
Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, "0")
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
}
-57
View File
@@ -1,57 +0,0 @@
import type { ThemeTokensDefinition } from "./index.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
-76
View File
@@ -1,76 +0,0 @@
import type { RGBA } from "@opentui/core"
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
}
}
+4 -1
View File
@@ -11,6 +11,7 @@
},
"exports": {
".": "./src/index.tsx",
"./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx",
"./config/keybind": "./src/config/keybind.ts",
"./config/v1": "./src/config/v1/index.tsx",
@@ -36,6 +37,9 @@
"./context/keymap": "./src/context/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
@@ -79,7 +83,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
+112 -200
View File
@@ -51,7 +51,6 @@ import { StartupLoading } from "./component/startup-loading"
import { DevToolsBar } from "./component/devtools-bar"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
import { LocationProvider, useLocation } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { PermissionProvider } from "./context/permission"
@@ -66,9 +65,8 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { SessionTabs } from "./component/session-tabs"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { ThemeProvider, useTheme } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -81,6 +79,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -93,28 +92,9 @@ import { AttentionProvider } from "./context/attention"
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new"] as const
const sessionTabBindingCommands = [
"session.tab.next",
"session.tab.previous",
"session.tab.history.back",
"session.tab.history.forward",
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
"session.tab.select.1",
"session.tab.select.2",
"session.tab.select.3",
"session.tab.select.4",
"session.tab.select.5",
"session.tab.select.6",
"session.tab.select.7",
"session.tab.select.8",
"session.tab.select.9",
] as const
const pinnedSessionBindingCommands = [
const appGlobalBindingCommands = [
"session.list",
"session.new",
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -274,6 +254,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
const pluginRuntime = createPluginRuntime()
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
@@ -295,113 +277,113 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiLifecycleProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiLifecycleProvider
<TuiTerminalEnvironmentProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
}}
>
<TuiTerminalEnvironmentProvider
<TuiStartupProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
</EpilogueProvider>
@@ -437,17 +419,17 @@ function App(props: { pair?: DialogPairCredentials }) {
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
const toast = useToast()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const themeState = useTheme()
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const data = useData()
const location = useLocation()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const plugins = usePlugin()
const clipboard = useClipboard()
@@ -641,71 +623,9 @@ function App(props: { pair?: DialogPairCredentials }) {
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
palette: undefined,
enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1),
})),
{
name: "session.tab.next",
title: "Next open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1),
},
{
name: "session.tab.previous",
title: "Previous open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.history.back",
title: "Back in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(-1),
},
{
name: "session.tab.history.forward",
title: "Forward in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(1),
},
{
name: "session.tab.next_unread",
title: "Next unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1),
},
{
name: "session.tab.previous_unread",
title: "Previous unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1),
},
{
name: "session.tab.close",
title: "Close current session tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.close(),
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to session tab ${i + 1}`,
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i),
run: () => {
local.session.quickSwitch(i + 1)
},
})),
{
name: "model.list",
@@ -1084,18 +1004,6 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: appGlobalBindingCommands,
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: sessionTabs.enabled,
bindings: sessionTabBindingCommands,
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: () => !sessionTabs.enabled(),
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
@@ -1182,7 +1090,7 @@ function App(props: { pair?: DialogPairCredentials }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={themeV2.background.default}
onMouseDown={(evt) => {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
@@ -1191,15 +1099,16 @@ function App(props: { pair?: DialogPairCredentials }) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
onMouseUp={
copyOnSelectEnabled()
? () => Selection.copy(renderer, toast, clipboard)
: undefined
}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
@@ -1218,7 +1127,10 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<PluginSlot name="app" input={{}} mode="all" />
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
</Show>
</box>
</box>
+5 -7
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@@ -70,9 +70,7 @@ declare module "@opentui/solid" {
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const { themeV2, mode } = useTheme().contextual("elevated")
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
@@ -93,9 +91,9 @@ export function BgPulse() {
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
backgroundPanel={themeV2.background.default}
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
live
/>
)
+40 -40
View File
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
@@ -31,12 +31,12 @@ export function DevToolsBar() {
const location = useLocation()
const route = useRoute()
const plugins = usePlugin()
const themes = useThemes()
const theme = useTheme()
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = themes.contextual("elevated")
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -196,8 +196,8 @@ export function DevToolsBar() {
})),
},
theme: {
name: themes.selected,
mode: themes.mode(),
name: theme.selected,
mode: theme.mode(),
},
},
null,
@@ -213,7 +213,7 @@ export function DevToolsBar() {
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<Show when={panel()}>
<box
position="absolute"
@@ -230,12 +230,12 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? theme.text.action.primary.focused
? themeV2.text.action.primary.focused
: serverIndicator().state === "connected"
? theme.text.feedback.success.default
? themeV2.text.feedback.success.default
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.text.default
? themeV2.text.feedback.error.default
: themeV2.text.default
}
>
{serverIndicator().icon}
@@ -243,10 +243,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? theme.text.action.primary.focused
? themeV2.text.action.primary.focused
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.text.subdued
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
@@ -279,10 +279,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? theme.text.action.primary.focused
? themeV2.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.text.subdued
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{statusIcon(runtime())}
@@ -290,10 +290,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? theme.text.action.primary.focused
? themeV2.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.text.subdued
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
@@ -320,11 +320,11 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={themes.selected} />
<Row label="Name" value={theme.selected} />
<Row label="Mode" value={mode()} />
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
<Show when={canSwitchMode()}>
@@ -336,7 +336,7 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<Show when={panel() === "tools"}>
<PanelBox>
<PanelTitle>Tools</PanelTitle>
@@ -406,14 +406,14 @@ export function DevToolsBar() {
</Show>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const theme = useTheme()
const { themeV2 } = useTheme()
const renderer = useRenderer()
return (
<box
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick()
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const renderer = useRenderer()
return (
<box
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
backgroundColor={themeV2.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
<text fg={themeV2.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={theme.text.default}>{props.value}</text>
<text fg={themeV2.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
</box>
)
}
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
return (
<box flexDirection="row">
<box width={7}>
<text fg={theme.text.subdued}>{props.label}</text>
<text fg={themeV2.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
</box>
</box>
)
+4 -12
View File
@@ -1,6 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
@@ -93,14 +93,6 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Layout",
category: "Diffs",
@@ -275,7 +267,7 @@ export function settingID(setting: Setting) {
export function DialogConfig(props: { current?: string }) {
const config = useConfig()
const toast = useToast()
const themes = useThemes()
const themeState = useTheme()
const current = Math.max(
0,
settings.findIndex((setting) => settingID(setting) === props.current),
@@ -288,12 +280,12 @@ export function DialogConfig(props: { current?: string }) {
if (!result || typeof result !== "object") return undefined
return (result as Record<string, unknown>)[key]
}, config.data)
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
: setting.values
const display = (setting: Setting) => {
const current = value(setting)
+8 -8
View File
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
import { useTuiApp } from "../context/runtime"
export function DialogDebug() {
const theme = useTheme()
const { themeV2 } = useTheme()
const dialog = useDialog()
const route = useRoute()
const local = useLocal()
@@ -55,10 +55,10 @@ export function DialogDebug() {
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -68,10 +68,10 @@ export function DialogDebug() {
<For each={entries()}>
{(entry) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.text.subdued}>
<text flexShrink={0} fg={themeV2.text.subdued}>
{entry.label.padEnd(10)}
</text>
<text fg={theme.text.default} wrapMode="word">
<text fg={themeV2.text.default} wrapMode="word">
{entry.value}
</text>
</box>
@@ -79,12 +79,12 @@ export function DialogDebug() {
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: theme.text.subdued }}>enter</span>
<span style={{ fg: themeV2.text.subdued }}>enter</span>
</text>
</box>
</box>
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
@@ -64,7 +64,7 @@ export function DialogIntegration(
) {
const data = useData()
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
@@ -87,7 +87,7 @@ export function DialogIntegration(
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0
? () => <text fg={theme.text.feedback.success.default}></text>
? () => <text fg={themeV2.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
@@ -103,12 +103,12 @@ export function DialogIntegration(
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No integrations available</text>
<text fg={themeV2.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No integrations found</text>
<text fg={themeV2.text.subdued}>No integrations found</text>
</box>
}
/>
@@ -303,16 +303,16 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.text.subdued}>{props.message}</text>
<text fg={themeV2.text.subdued}>{props.message}</text>
</box>
</box>
)
@@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [error, setError] = createSignal<string>()
return (
@@ -360,7 +360,7 @@ function KeyMethod(props: {
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
)}
/>
)
@@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -550,9 +550,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.markdown.link} />
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
</box>
)}
/>
@@ -561,31 +561,31 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={theme.markdown.link} />
<Link href={url()} fg={themeV2.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<text fg={themeV2.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
<text fg={themeV2.text.default}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
</text>
</Show>
</box>
+14 -14
View File
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
}
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useThemes().contextual("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
const { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
return <span style={{ fg: themeV2.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
@@ -110,7 +110,7 @@ export function DialogMcp() {
]}
footer={
<Show when={focusedError()}>
<text fg={theme.text.subdued}>enter to view error</text>
<text fg={themeV2.text.subdued}>enter to view error</text>
</Show>
}
/>
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
@@ -171,14 +171,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<text fg={themeV2.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
<text fg={themeV2.text.subdued}> scroll</text>
<text fg={themeV2.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return {
title,
titleView: isRemoving ? (
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: theme.text.action.destructive.default }}>
<span style={{ fg: themeV2.text.action.destructive.default }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? theme.background.action.destructive.default : undefined,
bg: deleting ? themeV2.background.action.destructive.default : undefined,
value: {
type: "directory",
directory: item.location,
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>Loading project directories</text>
<text fg={themeV2.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No project directories available</text>
<text fg={themeV2.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No project directories found</text>
<text fg={themeV2.text.subdued}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
+16 -16
View File
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useClient } from "../context/client"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
<text fg={themeV2.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
</box>
<box>
<text fg={theme.text.subdued}>Username</text>
<text fg={theme.text.default}>{value.username}</text>
<text fg={themeV2.text.subdued}>Username</text>
<text fg={themeV2.text.default}>{value.username}</text>
</box>
<box>
<text fg={theme.text.subdued}>Password</text>
<text fg={themeV2.text.subdued}>Password</text>
<text
fg={passwordHover() ? theme.text.default : theme.text.subdued}
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
</text>
</box>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={themeV2.text.subdued} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
</Show>
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
</box>
</box>
)
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
Pair
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information</text>}>
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information</text>}>
<Show
when={dimensions().height >= 36}
fallback={
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
>
{(error) => (
<box>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
</box>
)}
</Show>
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
Name project copy
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
}}
onSubmit={confirm}
placeholder="Project copy name"
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
cursorColor={theme.text.formfield.default}
placeholderColor={themeV2.text.subdued}
textColor={themeV2.text.formfield.default}
focusedTextColor={themeV2.text.formfield.default}
cursorColor={themeV2.text.formfield.default}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>submit</span>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
</text>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
</text>
</box>
</box>
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={theme.text.subdued} bg={textBg()}>
<text fg={themeV2.text.subdued} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
</box>
)
) : (
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
Failed to Delete Session
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={themeV2.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={themeV2.text.subdued} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
wrapMode="word"
>
{item.description}
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -15,18 +15,14 @@ import { useToast } from "../ui/toast"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const { themeV2, mode } = useTheme().contextual("elevated")
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
@@ -81,7 +77,6 @@ export function DialogSessionList() {
})
const quickSwitchHint = createMemo(() => {
if (sessionTabs.enabled()) return
const first = shortcuts.get("session.quick_switch.1")
const last = shortcuts.get("session.quick_switch.9")
if (!first || !last) return
@@ -99,7 +94,7 @@ export function DialogSessionList() {
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
@@ -107,20 +102,20 @@ export function DialogSessionList() {
const directory = session.location.directory
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
value: session.id,
category,
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
@@ -148,12 +143,12 @@ export function DialogSessionList() {
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No sessions available</text>
<text fg={themeV2.text.subdued}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{searchState().message}
</text>
</box>
@@ -167,8 +162,7 @@ export function DialogSessionList() {
{
command: "session.pin.toggle",
title: "pin/unpin",
hidden: sessionTabs.enabled(),
onTrigger: (option) => local.session.togglePin(option.value),
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
},
{
command: "session.delete",
+7 -7
View File
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const data = useData()
const theme = useTheme()
const { themeV2 } = useTheme()
dialog.setSize("large")
const [loadError, setLoadError] = createSignal<unknown>()
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No skills available</text>
<text fg={themeV2.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>Loading skills</text>
<text fg={themeV2.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No skills found</text>
<text fg={themeV2.text.subdued}>No skills found</text>
</box>
}
/>
+4 -4
View File
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
title: isDeleting
? `Press ${shortcuts.get("stash.delete")} again to confirm`
: getStashPreview(entry.prompt.text),
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
+13 -13
View File
@@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
const color = (status: string) => {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
if (status === "connected") return themeV2.text.feedback.success.default
if (status === "failed") return themeV2.text.feedback.error.default
if (status === "needs_auth") return themeV2.text.feedback.warning.default
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
return themeV2.text.subdued
}
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
<box>
<text fg={theme.text.default}>
<text fg={themeV2.text.default}>
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
</text>
<For each={mcp()}>
@@ -40,9 +40,9 @@ export function DialogStatus() {
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
</text>
<text fg={theme.text.default} wrapMode="word">
<text fg={themeV2.text.default} wrapMode="word">
<b>{item.name}</b>{" "}
<span style={{ fg: theme.text.subdued }}>
<span style={{ fg: themeV2.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
@@ -1,11 +1,11 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const themes = useThemes()
const options = Object.keys(themes.all())
const theme = useTheme()
const options = Object.keys(theme.all())
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
.map((value) => ({
title: value,
@@ -14,10 +14,10 @@ export function DialogThemeList() {
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<string>
const initial = themes.selected
const initial = theme.selected
onCleanup(() => {
if (!confirmed) themes.set(initial)
if (!confirmed) theme.set(initial)
})
return (
@@ -26,10 +26,10 @@ export function DialogThemeList() {
options={options}
current={initial}
onMove={(opt) => {
themes.set(opt.value)
theme.set(opt.value)
}}
onSelect={(opt) => {
themes.set(opt.value)
theme.set(opt.value)
confirmed = true
dialog.clear()
}}
@@ -38,12 +38,12 @@ export function DialogThemeList() {
}}
onFilter={(query) => {
if (query.length === 0) {
themes.set(initial)
theme.set(initial)
return
}
const first = ref.filtered[0]
if (first) themes.set(first.value)
if (first) theme.set(first.value)
}}
/>
)
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
{props.title ?? "File Changes Found"}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={themeV2.text.subdued} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
@@ -118,14 +118,16 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
{item}
</text>
</box>
)}
</For>
+4 -4
View File
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {
const theme = useTheme()
const { themeV2 } = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background.default, fg, 0.25)
const shadow = tint(themeV2.background.default, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
@@ -52,8 +52,8 @@ export function Logo() {
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
</box>
)}
</For>
@@ -1,20 +1,20 @@
import { useTheme } from "../context/theme"
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
const theme = useTheme()
const { themeV2 } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.text.feedback.warning.default}>
<text fg={themeV2.text.feedback.warning.default}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box
onMouseUp={props.onHome}
backgroundColor={theme.background.action.primary.hovered}
backgroundColor={themeV2.background.action.primary.hovered}
paddingLeft={1}
paddingRight={1}
>
<text fg={theme.text.action.primary.hovered}>go home</text>
<text fg={themeV2.text.action.primary.hovered}>go home</text>
</box>
</box>
)
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useThemes().contextual("overlay")
const { themeV2 } = useTheme().contextual("overlay")
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border.default}
borderColor={themeV2.border.default}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.background.default}
backgroundColor={themeV2.background.default}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
@@ -711,7 +711,9 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{emptyMessage()}
</text>
</box>
}
>
@@ -719,7 +721,7 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
@@ -735,14 +737,14 @@ export function Autocomplete(props: {
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
flexShrink={0}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
+42 -43
View File
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -189,8 +189,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const { themeV2, syntax } = useTheme()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -297,8 +296,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
if (!props.disabled) input.cursorColor = themeV2.text.default
})
const usage = createMemo(() => {
@@ -1300,10 +1299,10 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (leader()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
if (leader()) return themeV2.border.default
if (store.mode === "shell") return themeV2.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return theme.border.default
if (!agent) return themeV2.border.default
return local.agent.color(agent.id)
})
const agentLabel = createMemo(() => {
@@ -1325,7 +1324,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1345,7 +1344,7 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => {
const agent = status() === "running" ? local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border.default
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
return {
frames: createFrames({
color,
@@ -1365,7 +1364,7 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
return (
<>
@@ -1391,9 +1390,9 @@ export function Prompt(props: PromptProps) {
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.text.subdued}
textColor={leader() ? theme.text.subdued : theme.text.default}
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
placeholderColor={themeV2.text.subdued}
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
@@ -1453,7 +1452,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text.default
input.cursorColor = themeV2.text.default
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
@@ -1461,7 +1460,7 @@ export function Prompt(props: PromptProps) {
r.target?.focus()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1471,24 +1470,24 @@ export function Prompt(props: PromptProps) {
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
style={{
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
bold: true,
}}
>
@@ -1542,12 +1541,12 @@ export function Prompt(props: PromptProps) {
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
wrapMode="none"
truncate
flexShrink={1}
@@ -1555,7 +1554,7 @@ export function Prompt(props: PromptProps) {
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@@ -1566,16 +1565,16 @@ export function Prompt(props: PromptProps) {
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={theme.hue.accent[500]}>
<Spinner color={themeV2.hue.accent[500]}>
{progress()}
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
@@ -1583,7 +1582,7 @@ export function Prompt(props: PromptProps) {
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
@@ -1597,7 +1596,7 @@ export function Prompt(props: PromptProps) {
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
>
{file()}
</text>
@@ -1607,40 +1606,40 @@ export function Prompt(props: PromptProps) {
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: theme.text.subdued }}> · </span>
<span style={{ fg: themeV2.text.subdued }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: theme.text.subdued }}> · </span>
<span style={{ fg: themeV2.text.subdued }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={theme.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
<text fg={themeV2.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
<text fg={themeV2.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text.default} flexShrink={0}>
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
<text fg={themeV2.text.default} flexShrink={0}>
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
+5 -5
View File
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const theme = useThemes().contextual("elevated")
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
@@ -21,15 +21,15 @@ export function Reconnecting() {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={themeV2.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={theme.text.default}>Restarting service...</Spinner>
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
-250
View File
@@ -1,250 +0,0 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
SESSION_TAB_OVERFLOW_WIDTH,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
status(sessionID: string): SessionTabsStatus
}
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
const statuses = createMemo(
() =>
new Map(
layout().tabs.map((tab) => {
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
...status,
complete: sessionTabComplete(status.unread, status.busy),
},
] as const
}),
),
)
const targets = createMemo(() => ({
widths: layout().widths,
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
}))
const motion = createAnimatable(targets(), {
enabled: animations,
transition: spring({ visualDuration: 0.1 }),
})
const identity = createMemo(() =>
layout()
.tabs.map((tab) => tab.sessionID)
.join(":"),
)
let signature = ""
let total = 0
createEffect(() => {
const next = targets()
const nextSignature = identity()
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
signature = nextSignature
total = layout().total
if (reset) return motion.jump(next)
motion.animate(next)
})
const visuals = createMemo(() => {
const current = signature === identity() && total === layout().total ? motion.value() : targets()
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
{
width: widths[index]!,
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
},
]),
)
})
return (
<box
height={1}
flexShrink={0}
position="relative"
flexDirection="row"
zIndex={1}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
const width = Math.min(this.width, buffer.width - x)
if (y < 0 || y >= buffer.height || width <= 0) return
buffer.fillRect(
x,
y,
width,
1,
RGBA.fromValues(
theme.background.default.r,
theme.background.default.g,
theme.background.default.b,
mode() === "light" ? 0.14 : 0.28,
),
)
}}
>
<Show when={layout().before > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().before}
</text>
</Show>
<For each={layout().tabs}>
{(tab) => {
const selected = () => activeID() === tab.sessionID
const status = () => statuses().get(tab.sessionID)!
const width = () => visuals().get(tab.sessionID)?.width ?? 1
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
const background = () => {
const base =
hovered() === tab.sessionID && !selected()
? theme.background.action.primary.hovered
: theme.background.default
return tint(base, theme.raise(theme.background.surface.offset), selection())
}
const pulseBackground = () => background()
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
const title = () => tab.title ?? "Untitled session"
const availableTitleWidth = () => Math.max(1, width() - 3)
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
return tint(base, accent(), activity())
}
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
return (
<box
width={width()}
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => tabs.select(tab.sessionID)}
>
<TabPulse
enabled={animations()}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
color={pulseColor()}
glowColor={accent()}
completionColor={accent()}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1}> </text>
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
</text>
<Show
when={titleFades()}
fallback={
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitle()}
</text>
}
>
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
pulseBackground(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
>
{character}
</span>
)}
</For>
</text>
</Show>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeColor()}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
)
}}
</For>
<Show when={layout().after > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().after}
</text>
</Show>
</box>
)
}
+2 -2
View File
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const theme = useTheme()
const { themeV2 } = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
const color = () => props.color ?? themeV2.text.subdued
return (
<Show
when={config.animations ?? true}

Some files were not shown because too many files have changed in this diff Show More