Compare commits

..
33 Commits
Author SHA1 Message Date
Kit LangtonandGitHub b47cfbee7c fix(tui): reduce tab pulse allocations (#39433) 2026-07-28 22:43:46 -04:00
Kit LangtonandGitHub 5504245f7b feat(tui): add session tab playground (#39432) 2026-07-28 22:36:53 -04:00
Kit LangtonandGitHub f64b50d71b feat(tui): add unread tab glow (#39428) 2026-07-28 22:34:33 -04:00
Kit LangtonandGitHub a7b2ea94e5 fix(tui): always show session tab (#39429) 2026-07-28 22:33:34 -04:00
Kit LangtonandGitHub 7b775c2582 fix(cli): embed native watcher binding 2026-07-28 22:32:08 -04:00
DaxandGitHub 12a931a220 feat(tui): filter subagents by activity 2026-07-28 22:13:46 -04:00
Dax Raad 90100c1365 docs: clarify side-by-side V1 and V2 installs 2026-07-28 22:02:19 -04:00
Dax Raad 139c9febe4 refactor(tui): group tab settings 2026-07-28 21:23:53 -04:00
Dax Raad 06290907a9 feat(tui): restore plugin manager dialog 2026-07-28 21:19:17 -04:00
Kit LangtonandGitHub 1c8175a61a fix(tui): preserve tab context on home and close (#39421) 2026-07-29 01:03:17 +00:00
Dax Raad fe91698ed6 fix(tui): initialize external plugin runtime 2026-07-28 21:00:09 -04:00
Dax Raad 068c32df39 feat(tui): discover project plugins 2026-07-28 20:21:38 -04:00
Kit LangtonandGitHub a2885d1662 feat(tui): add session tab history (#39411) 2026-07-28 19:38:46 -04:00
Kit LangtonandGitHub 38a3dbb4c4 fix(tui): fade full-width tab titles (#39409) 2026-07-28 19:38:28 -04:00
Kit LangtonandGitHub 43383d4fba fix(tui): hide single session tab (#39408) 2026-07-28 18:25:46 -04:00
40c4c3918a feat(core): enable fff in node runtimes (#38776)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-28 17:14:11 -05:00
Aiden ClineandGitHub 754ea99d86 fix(core): preserve shell output tail (#39403) 2026-07-28 16:32:11 -05:00
Kit LangtonandGitHub 37a1b80d5a feat(tui): add adaptive session tabs (#39396) 2026-07-28 17:12:07 -04:00
Aiden ClineandGitHub f95d04fea0 feat(core): improve shell tool guidance (#39401) 2026-07-28 15:53:19 -05:00
James LongandGitHub 08b80da931 refactor(tui): split theme hooks (#39395) 2026-07-28 16:25:32 -04:00
Aiden ClineandGitHub f6fb1a7cdd fix(ai): retry transient client statuses (#39391) 2026-07-28 14:25:33 -05:00
Dax Raad 5bcc0016a6 feat(tui): add plugin context hook 2026-07-28 14:57:59 -04:00
Aiden ClineandGitHub 771174b5c3 fix(cli): align auto permission flags (#39384) 2026-07-28 13:31:57 -05:00
Dax Raad 44cd984589 feat(tui): refine plugin context slots 2026-07-28 14:19:31 -04:00
James LongandGitHub c445d98188 feat(theme): extract TUI theme package (#39378) 2026-07-28 14:14:39 -04:00
Kit LangtonandGitHub 27e7b0558a fix(core): preserve plugin update order (#39372) 2026-07-28 13:00:24 -04:00
Kit LangtonandGitHub fb975eeb7c test(ai): add scoped test LLM (#39223) 2026-07-28 16:41:09 +00:00
Kit LangtonandGitHub e556aca833 refactor(core): simplify plugin reload loop (#39356) 2026-07-28 12:21:29 -04:00
73bd8a264b fix(app): keep new tab button visible (#39366)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:18:26 +00:00
077338fcc8 fix(app): hide delete for provided servers (#39363)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:00:47 +00:00
Dax Raad b671a77145 fix(tui): simplify form field labels 2026-07-28 11:56:11 -04:00
Dax Raad 30d09a7d7e fix(core): improve web search consent flow 2026-07-28 11:54:44 -04:00
Dax Raad ee02fb4fce Websearch tweaks 2026-07-28 11:10:33 -04:00
180 changed files with 5333 additions and 4341 deletions
+16
View File
@@ -0,0 +1,16 @@
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,6 +147,11 @@
"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",
@@ -164,6 +169,11 @@
"@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:",
},
@@ -364,6 +374,7 @@
"@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:*",
@@ -596,17 +607,20 @@
"@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": {
@@ -859,6 +873,20 @@
"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",
@@ -868,6 +896,7 @@
"@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:",
@@ -1030,6 +1059,7 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
@@ -1677,6 +1707,8 @@
"@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=="],
@@ -2075,6 +2107,8 @@
"@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"],
@@ -3251,6 +3285,28 @@
"@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=="],
@@ -4015,6 +4071,8 @@
"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,6 +15,7 @@
],
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
+1 -2
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status !== undefined && input.status >= 500)
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,7 +145,6 @@ 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
@@ -0,0 +1,157 @@
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,6 +19,7 @@ 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", () => {
@@ -28,6 +29,7 @@ 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,6 +58,12 @@ 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,6 +562,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
@@ -649,13 +650,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</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>
<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.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -21,6 +21,7 @@ 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)}
@@ -47,6 +48,7 @@ export const ServerRowMenuView: Component<{
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
canRemove: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
@@ -84,10 +86,10 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
<Show when={props.canRemove}>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
</Show>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
+19 -21
View File
@@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<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>
<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>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
+16
View File
@@ -178,6 +178,17 @@ 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 }
@@ -312,6 +323,10 @@ 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 },
@@ -350,6 +365,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
canRemove,
scope,
projects: {
...projects,
@@ -60,6 +60,7 @@ 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,6 +47,7 @@ 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
@@ -192,6 +193,7 @@ function HomeServerRow(props: {
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
@@ -277,6 +279,7 @@ 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,6 +24,7 @@ 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}
@@ -53,6 +53,7 @@ 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" })
@@ -63,6 +64,7 @@ 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")
@@ -145,6 +147,7 @@ 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" })
@@ -152,6 +155,7 @@ 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,6 +56,16 @@
"@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",
+13 -2
View File
@@ -5,6 +5,7 @@ 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"
@@ -22,7 +23,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 plugin = createSolidTransformPlugin()
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -55,6 +56,16 @@ 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,
@@ -69,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+2
View File
@@ -37,6 +37,8 @@ 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)),
+36 -1
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.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const env = {
...process.env,
HOME: root,
@@ -29,6 +29,7 @@ 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()
@@ -49,6 +50,11 @@ 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),
@@ -88,6 +94,7 @@ 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)
@@ -133,3 +140,31 @@ 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")
}
+14 -5
View File
@@ -14,10 +14,23 @@ 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,
@@ -196,11 +209,7 @@ 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)),
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),
...PermissionParams,
},
}),
Spec.make("service", {
@@ -59,6 +59,7 @@ 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,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
}),
+6
View File
@@ -11,6 +11,8 @@ 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,
@@ -19,6 +21,10 @@ 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,6 +30,44 @@ 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" },
@@ -156,6 +194,11 @@ 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"`
}
@@ -174,6 +217,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
solid({
solid: {
generate: "universal",
+61 -75
View File
@@ -152,19 +152,15 @@ 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 = { 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 = {
export type Endpoint5_9Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
}
export type Endpoint5_10Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_10Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -174,10 +170,10 @@ export type Endpoint5_11Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_11Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -189,19 +185,19 @@ export type Endpoint5_12Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -210,81 +206,81 @@ export type Endpoint5_14Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_15Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, 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_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_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_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_18Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_18Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
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_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_24Input = {
export type Endpoint5_23Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_24Output = void
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> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, 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_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_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 = {
export type Endpoint5_26Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_27Output =
export type Endpoint5_26Output =
| (
| {
readonly id: Event.ID
@@ -327,15 +323,6 @@ export type Endpoint5_27Output =
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
@@ -838,19 +825,19 @@ export type Endpoint5_27Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E>
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 Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, 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 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 interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -862,7 +849,6 @@ 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>
+57 -65
View File
@@ -76,8 +76,6 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -360,19 +358,14 @@ 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_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -390,8 +383,8 @@ 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_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -411,16 +404,16 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -437,29 +430,29 @@ const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14I
),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -469,19 +462,27 @@ const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18I
),
)
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.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -489,7 +490,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.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -497,37 +498,29 @@ 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_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveStream<Endpoint5_27Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -539,18 +532,18 @@ const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27I
),
)
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.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.background"]({ 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),
@@ -567,24 +560,23 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(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),
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),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -28,8 +28,6 @@ import type {
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionArchiveInput,
SessionArchiveOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -555,17 +553,6 @@ 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,16 +611,6 @@ 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
@@ -2169,7 +2159,6 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionDeleted
| SessionForked
| SessionInputPromoted
@@ -2264,7 +2253,6 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2742,10 +2730,6 @@ 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,7 +432,6 @@ 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",
@@ -468,7 +467,6 @@ 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,6 +92,7 @@
"@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,71 +1,52 @@
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 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 type Init = InitOptions
export interface Search {
items: File[]
scores: Array<{ total: number }>
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: Directory[]
scores: Array<{ total: number }>
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: Mixed[]
scores: Array<{ total: number }>
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
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 type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: Hit[]
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
@@ -128,11 +109,30 @@ export interface Picker {
}
export function available() {
return false
return FileFinder?.isAvailable() ?? false
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on node runtime" }
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 * as Fff from "./fff.node"
@@ -0,0 +1,13 @@
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"}` : ""}`,
)
}
+2 -11
View File
@@ -9,23 +9,14 @@ 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 { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
import loadBinding from "./watcher-binding"
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 {
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")
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
} catch {
return
}
+31 -54
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, Semaphore, Stream } from "effect"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
@@ -230,10 +230,8 @@ 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.
@@ -265,61 +263,40 @@ const layer = Layer.effect(
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
yield* lock.withPermit(
Effect.gen(function* () {
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 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 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 }))),
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
Effect.forkScoped({ startImmediately: true }),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() => Effect.sync(() => ++observed)),
)
yield* signals.pipe(
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.mapEffect(attempt),
Stream.filter((settled) => settled),
Stream.take(1),
Stream.runDrain,
Effect.andThen(Deferred.succeed(ready, undefined)),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Deferred.await(ready) })
-6
View File
@@ -227,7 +227,6 @@ 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
@@ -707,11 +706,6 @@ 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 !== null ? DateTime.makeUnsafe(row.time_archived) : undefined,
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
@@ -171,7 +171,6 @@ 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,17 +609,6 @@ 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) =>
+10 -3
View File
@@ -44,6 +44,7 @@ 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[]>
@@ -134,6 +135,13 @@ 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
@@ -167,8 +175,7 @@ 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 configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell, options)
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
+39 -11
View File
@@ -15,22 +15,40 @@ 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. Defaults to the active Location; relative paths resolve from that Location.",
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.`,
}),
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.",
@@ -69,13 +87,11 @@ 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")
@@ -144,7 +160,7 @@ export const Plugin = {
({
name,
options: { codemode: false },
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.`,
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
@@ -189,8 +205,12 @@ export const Plugin = {
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
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 notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
@@ -291,5 +311,13 @@ 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())
}),
)
}),
}
+101 -77
View File
@@ -32,85 +32,109 @@ 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
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" },
],
},
],
})
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)
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" },
],
},
],
})
}),
)
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 }),
),
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 })),
},
],
})
: 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}` : ""}`
})
.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)
+4 -17
View File
@@ -1,6 +1,7 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
import { 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"
@@ -9,7 +10,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, Stream } from "effect"
import { Effect, Layer } from "effect"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -64,21 +65,7 @@ const aisdk = Layer.mock(AISDK.Service, {
},
model: () => Effect.succeed(runtime),
})
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 client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
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,32 +660,4 @@ 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
+11 -6
View File
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
: "printf body && exit 7"
const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
? `[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`
const progressOverflowCommand = (bytes: number, release: string) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
@@ -200,10 +200,11 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
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")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
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({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
+147 -16
View File
@@ -30,6 +30,15 @@ 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: {} }],
@@ -38,6 +47,11 @@ 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: {} }],
@@ -60,11 +74,15 @@ const websearch = Layer.succeed(
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed([]),
providers: () => Effect.succeed(providers),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.sync(() => {
Effect.gen(function* () {
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
}),
}),
@@ -73,7 +91,11 @@ const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
@@ -84,22 +106,19 @@ const form = Layer.succeed(
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
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),
}),
)
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", () => {
@@ -202,4 +221,116 @@ 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)
}),
)
})
+6 -1
View File
@@ -32,7 +32,8 @@
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5"
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -43,6 +44,9 @@
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -52,6 +56,7 @@
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
+25 -6
View File
@@ -113,7 +113,22 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
export type Slot = (props: 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 ToastVariant = "info" | "success" | "warning" | "error"
@@ -215,11 +230,11 @@ export interface DialogSelectOptions<Value> {
}
export interface Dialog {
/** 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. */
/** Shows a dialog. */
show(render: () => JSX.Element, onClose?: () => void): void
/** Sets the active dialog's presentation options. */
set(options: DialogOptions): void
/** Closes this plugin's active dialog. */
/** Closes the active dialog. */
clear(): void
alert(options: DialogAlertOptions): Promise<void>
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
@@ -308,17 +323,21 @@ 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: string, render: Slot) => () => void
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => 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 +1,2 @@
export * as Plugin from "./plugin.js"
export { PluginContextProvider, usePlugin } from "./solid.js"
+19
View File
@@ -0,0 +1,19 @@
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,21 +269,6 @@ 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,13 +86,6 @@ 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,
@@ -557,7 +550,6 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Archived,
UsageUpdated,
Deleted,
Forked,
@@ -104,7 +104,6 @@ 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,22 +201,6 @@ 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
@@ -0,0 +1,36 @@
{
"$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
@@ -0,0 +1,9 @@
#!/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
@@ -0,0 +1,45 @@
#!/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
@@ -0,0 +1,77 @@
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)}`
}
@@ -1,4 +1,4 @@
import type { HueName, ThemeDocument } from "./schema"
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
@@ -4,8 +4,8 @@ import type {
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
} from "./index.js"
import { ActionState } from "./schema.js"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
+57
View File
@@ -0,0 +1,57 @@
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,
},
}
}
@@ -29,7 +29,7 @@ export {
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema"
} from "./schema.js"
export type {
Categorical,
@@ -42,7 +42,9 @@ export type {
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types"
export { DEFAULT_CATEGORICAL } from "./defaults"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
export { generateSyntax } from "./syntax.js"
@@ -1,8 +1,8 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import { expandTheme, expandTokens, mergeTheme } from "./expand"
import { fallback } from "./fallback"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
@@ -12,7 +12,7 @@ import {
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema"
} from "./schema.js"
import type {
ActionStateKey,
HueDefinition,
@@ -22,8 +22,8 @@ import type {
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
@@ -1,4 +1,4 @@
import { expandTheme, mergeTheme } from "./expand"
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
@@ -6,7 +6,7 @@ import type {
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index"
} from "./index.js"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
@@ -1,5 +1,5 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index"
import type { Mode, ResolvedThemeView } from "./index.js"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
@@ -9,7 +9,7 @@ import type {
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema"
} from "./schema.js"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeV1Json } from "../v1"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
import { HueStep } from "./schema"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
+76
View File
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,10 @@
/* 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
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"$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
}
}
+1 -4
View File
@@ -11,7 +11,6 @@
},
"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",
@@ -37,9 +36,6 @@
"./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",
@@ -83,6 +79,7 @@
"@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:",
+200 -112
View File
@@ -51,6 +51,7 @@ 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"
@@ -65,8 +66,9 @@ 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 } from "./context/theme"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
@@ -79,7 +81,6 @@ 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"
@@ -92,9 +93,28 @@ import { AttentionProvider } from "./context/attention"
registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
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 = [
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -254,8 +274,6 @@ 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)
@@ -277,113 +295,113 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiLifecycleProvider
<TuiPathsProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiTerminalEnvironmentProvider
<TuiLifecycleProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: 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
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<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
}
>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<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>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>
</EpilogueProvider>
@@ -419,17 +437,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 themeState = useTheme()
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
const location = useLocation()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const plugins = usePlugin()
const clipboard = useClipboard()
@@ -623,9 +641,71 @@ function App(props: { pair?: DialogPairCredentials }) {
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
palette: undefined,
run: () => {
local.session.quickSwitch(i + 1)
},
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),
})),
{
name: "model.list",
@@ -1004,6 +1084,18 @@ 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
@@ -1090,7 +1182,7 @@ function App(props: { pair?: DialogPairCredentials }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
onMouseDown={(evt) => {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
@@ -1099,16 +1191,15 @@ 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 />
@@ -1127,10 +1218,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
<PluginSlot name="app" input={{}} mode="all" />
</Show>
</box>
</box>
+7 -5
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
@@ -91,9 +93,9 @@ export function BgPulse() {
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={themeV2.background.default}
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.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 } from "../context/theme"
import { useTheme, useThemes } 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 theme = useTheme()
const themes = useThemes()
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = themes.contextual("elevated")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -196,8 +196,8 @@ export function DevToolsBar() {
})),
},
theme: {
name: theme.selected,
mode: theme.mode(),
name: themes.selected,
mode: themes.mode(),
},
},
null,
@@ -213,7 +213,7 @@ export function DevToolsBar() {
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<Show when={panel()}>
<box
position="absolute"
@@ -230,12 +230,12 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "connected"
? themeV2.text.feedback.success.default
? theme.text.feedback.success.default
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.default
? theme.text.feedback.error.default
: theme.text.default
}
>
{serverIndicator().icon}
@@ -243,10 +243,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -279,10 +279,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{statusIcon(runtime())}
@@ -290,10 +290,10 @@ export function DevToolsBar() {
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{" "}
@@ -320,11 +320,11 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={theme.selected} />
<Row label="Name" value={themes.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" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.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={themeV2.text.subdued} label="Time to first draw" />
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const { themeV2 } = useTheme()
const theme = 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 ? themeV2.background.action.primary.focused : undefined}
backgroundColor={props.active ? theme.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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const renderer = useRenderer()
return (
<box
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{props.value}</text>
<text fg={theme.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.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 ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
<text fg={props.disabled ? theme.text.subdued : theme.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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>{props.label}</text>
<text fg={theme.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
</box>
</box>
)
+12 -4
View File
@@ -1,6 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
@@ -93,6 +93,14 @@ 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",
@@ -267,7 +275,7 @@ export function settingID(setting: Setting) {
export function DialogConfig(props: { current?: string }) {
const config = useConfig()
const toast = useToast()
const themeState = useTheme()
const themes = useThemes()
const current = Math.max(
0,
settings.findIndex((setting) => settingID(setting) === props.current),
@@ -280,12 +288,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 ?? themeState.selected
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
? Object.keys(themes.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 { themeV2 } = useTheme()
const theme = 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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}>
<text flexShrink={0} fg={theme.text.subdued}>
{entry.label.padEnd(10)}
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.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={themeV2.text.subdued}>Share this when reporting an issue.</text>
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: themeV2.text.subdued }}>enter</span>
<span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.feedback.success.default}></text>
? () => <text fg={theme.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={themeV2.text.subdued}>No integrations available</text>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations found</text>
<text fg={theme.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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
</box>
)
@@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
)}
/>
)
@@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -550,9 +550,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<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>
<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>
</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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={themeV2.markdown.link} />
<Link href={url()} fg={theme.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={themeV2.text.subdued}>{props.message}</text>
<text fg={theme.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={themeV2.text.default}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
<text fg={theme.text.default}>
c <span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}> Loading</span>
const theme = useThemes().contextual("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: themeV2.text.subdued }}> Disabled</span>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>enter to view error</text>
<text fg={theme.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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={themeV2.text.feedback.error.default}> Failed</text>
<text fg={theme.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={themeV2.text.subdued}> scroll</text>
<text fg={themeV2.text.subdued} onMouseUp={copy}>
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: themeV2.text.action.destructive.default }}>
<span style={{ fg: theme.text.action.destructive.default }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? themeV2.background.action.destructive.default : undefined,
bg: deleting ? theme.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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.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={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.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={themeV2.text.subdued}>Loading project directories</text>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories available</text>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No project directories found</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
<text fg={theme.text.subdued}>URLs</text>
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
</box>
<box>
<text fg={themeV2.text.subdued}>Username</text>
<text fg={themeV2.text.default}>{value.username}</text>
<text fg={theme.text.subdued}>Username</text>
<text fg={theme.text.default}>{value.username}</text>
</box>
<box>
<text fg={themeV2.text.subdued}>Password</text>
<text fg={theme.text.subdued}>Password</text>
<text
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
fg={passwordHover() ? theme.text.default : theme.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={themeV2.text.subdued} wrapMode="word">
<text fg={theme.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={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<text fg={theme.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={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Pair
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information</text>}>
<Show when={info()} fallback={<text fg={theme.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={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Name project copy
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.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={themeV2.text.subdued}
textColor={themeV2.text.formfield.default}
focusedTextColor={themeV2.text.formfield.default}
cursorColor={themeV2.text.formfield.default}
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
cursorColor={theme.text.formfield.default}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={themeV2.text.default}>
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
<text fg={themeV2.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(theme.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={themeV2.text.default} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={themeV2.text.subdued} bg={textBg()}>
<text fg={theme.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={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
</box>
)
) : (
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.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" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={selected() === "action" ? theme.text.action.primary.focused : theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.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 ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item.id === store.active ? theme.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 ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -15,14 +15,18 @@ 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 { themeV2, mode } = useTheme().contextual("elevated")
const themes = useThemes()
const theme = themes.contextual("elevated")
const mode = themes.mode
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
@@ -77,6 +81,7 @@ 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
@@ -94,7 +99,7 @@ export function DialogSessionList() {
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinned = sessionTabs.enabled() ? [] : 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]))
@@ -102,20 +107,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 = slotByID.get(session.id)
const slot = sessionTabs.enabled() ? undefined : 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 ? themeV2.background.action.destructive.focused : undefined,
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
@@ -143,12 +148,12 @@ export function DialogSessionList() {
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No sessions available</text>
<text fg={theme.text.subdued}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
@@ -162,7 +167,8 @@ export function DialogSessionList() {
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
hidden: sessionTabs.enabled(),
onTrigger: (option) => 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 { themeV2 } = useTheme()
const theme = 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={themeV2.text.subdued}>No skills available</text>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.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={themeV2.text.subdued}>Loading skills</text>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No skills found</text>
<text fg={theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().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 ? themeV2.background.action.destructive.focused : undefined,
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
fg: isDeleting ? theme.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
const color = (status: string) => {
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
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
}
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
<box>
<text fg={themeV2.text.default}>
<text fg={theme.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={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
<b>{item.name}</b>{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.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 { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()
const options = Object.keys(theme.all())
const themes = useThemes()
const options = Object.keys(themes.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 = theme.selected
const initial = themes.selected
onCleanup(() => {
if (!confirmed) theme.set(initial)
if (!confirmed) themes.set(initial)
})
return (
@@ -26,10 +26,10 @@ export function DialogThemeList() {
options={options}
current={initial}
onMove={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
}}
onSelect={(opt) => {
theme.set(opt.value)
themes.set(opt.value)
confirmed = true
dialog.clear()
}}
@@ -38,12 +38,12 @@ export function DialogThemeList() {
}}
onFilter={(query) => {
if (query.length === 0) {
theme.set(initial)
themes.set(initial)
return
}
const first = ref.filtered[0]
if (first) theme.set(first.value)
if (first) themes.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 { useTheme } from "../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().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={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title ?? "File Changes Found"}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued} wrapMode="word">
<text fg={theme.text.subdued} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
{item}
</text>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.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 { themeV2 } = useTheme()
const theme = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(themeV2.background.default, fg, 0.25)
const shadow = tint(theme.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, themeV2.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.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 { themeV2 } = useTheme()
const theme = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={themeV2.text.feedback.warning.default}>
<text fg={theme.text.feedback.warning.default}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box
onMouseUp={props.onHome}
backgroundColor={themeV2.background.action.primary.hovered}
backgroundColor={theme.background.action.primary.hovered}
paddingLeft={1}
paddingRight={1}
>
<text fg={themeV2.text.action.primary.hovered}>go home</text>
<text fg={theme.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 { useTheme } from "../../context/theme"
import { useThemes } 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 { themeV2 } = useTheme().contextual("overlay")
const theme = useThemes().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={themeV2.border.default}
borderColor={theme.border.default}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
{emptyMessage()}
</text>
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
</box>
}
>
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
flexShrink={0}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
+43 -42
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 } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const { themeV2, syntax } = useTheme()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
if (!props.disabled) input.cursorColor = themeV2.text.default
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
})
const usage = createMemo(() => {
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (leader()) return themeV2.border.default
if (store.mode === "shell") return themeV2.text.action.primary.selected
if (leader()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return themeV2.border.default
if (!agent) return theme.border.default
return local.agent.color(agent.id)
})
const agentLabel = createMemo(() => {
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1344,7 +1345,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) : themeV2.border.default
const color = agent ? local.agent.color(agent.id) : theme.border.default
return {
frames: createFrames({
color,
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
return (
<>
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={themeV2.text.subdued}
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
placeholderColor={theme.text.subdued}
textColor={leader() ? theme.text.subdued : theme.text.default}
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = themeV2.text.default
input.cursorColor = theme.text.default
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
r.target?.focus()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1470,24 +1471,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(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
style={{
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
bold: true,
}}
>
@@ -1541,12 +1542,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={themeV2.text.subdued}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
wrapMode="none"
truncate
flexShrink={1}
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={themeV2.hue.accent[500]}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
>
{file()}
</text>
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}> · </span>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={themeV2.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
<text fg={theme.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={themeV2.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
<text fg={theme.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={themeV2.text.default} flexShrink={0}>
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
<text fg={theme.text.default} flexShrink={0}>
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
+5 -5
View File
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
return (
<box
@@ -21,15 +21,15 @@ export function Reconnecting() {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
backgroundColor={theme.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
<Spinner color={theme.text.default}>Restarting service...</Spinner>
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
+250
View File
@@ -0,0 +1,250 @@
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 { themeV2 } = useTheme()
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? themeV2.text.subdued
const color = () => props.color ?? theme.text.subdued
return (
<Show
when={config.animations ?? true}
@@ -1,9 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useThemes } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = useThemes().contextual("elevated")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
let wait: NodeJS.Timeout | undefined
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
return (
<Show when={show()}>
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.text.subdued}>{text()}</Spinner>
</box>
</box>
</Show>

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