Skip to content

Commit e5f0bf0

Browse files
feat: upgrade to support opencode v2
1 parent c63feec commit e5f0bf0

11 files changed

Lines changed: 285 additions & 80 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
* support OpenCode 1.x and 2.x from one package version using separate lazy-loaded TUI adapters
88

9+
910
## [1.4.0](https://github.com/christiangalsterer/opencode-requesty-plugin/compare/v1.3.1...v1.4.0) (2026-09-26)
1011

1112

‎README.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ For a project-specific installation, add the package to that project's `opencode
113113
}
114114
```
115115

116-
V2 loads the package's `setup` implementation using its CLI plugin API. Since this package exposes a TUI component, OpenCode 2 loads it from the `plugins` list in `opencode.json`; it does not need to be added to `cli.json`. V1 `tui.json` configuration remains for OpenCode 1.x. The v2 prompt indicator is appended to the prompt footer status area, while the usage widget remains in the sidebar.
117116

118117
## Updating
119118

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
"LICENSE"
9292
],
9393
"scripts": {
94-
"ci": "bun run clean && bun install --frozen-lockfile && bun test && bun run build && bun run renovate:config && bun pm pack && npm i --omit=dev --dry-run *.tgz",
94+
"ci": "bun run clean && bun install --frozen-lockfile && bun test && bun run build && bun run package:check && bun run renovate:config && bun pm pack --ignore-scripts && npm i --omit=dev --dry-run *.tgz",
9595
"biome": "biome check",
9696
"biome:fix": "biome check --write",
9797
"build": "bun run typecheck && bun run build.ts",
@@ -110,6 +110,7 @@
110110
"test": "bun run typecheck && bun test",
111111
"test:coverage": "bun run typecheck && bun test --coverage",
112112
"release:prepare": "bun run clean && bun install && bun run format && bun test",
113+
"package:check": "bun scripts/check-package-artifact.ts",
113114
"publish-npm": "bun run ci && bun publish --access=public",
114115
"publish-npm:dry-run": "bun run ci && bun publish --access=public --dry-run",
115116
"prepare": "husky",

‎scripts/check-package-artifact.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { fileURLToPath, pathToFileURL } from 'node:url'
5+
6+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
7+
const temporary = await mkdtemp(path.join(os.tmpdir(), 'requesty-plugin-package-'))
8+
9+
try {
10+
const packed = Bun.spawnSync([process.execPath, 'pm', 'pack', '--ignore-scripts', '--destination', temporary, '--quiet'], { cwd: root })
11+
if (packed.exitCode !== 0) {
12+
throw new Error(packed.stderr.toString() || 'bun pm pack failed')
13+
}
14+
15+
const archives = (await readdir(temporary)).filter((name) => name.endsWith('.tgz'))
16+
if (archives.length !== 1) {
17+
throw new Error(`Expected one package tarball, found ${archives.length}`)
18+
}
19+
const archive = archives[0]
20+
if (!archive) {
21+
throw new Error('Packed tarball name was empty')
22+
}
23+
24+
const unpacked = Bun.spawnSync(['tar', '-xzf', path.join(temporary, archive), '-C', temporary])
25+
if (unpacked.exitCode !== 0) {
26+
throw new Error(unpacked.stderr.toString() || 'tar extraction failed')
27+
}
28+
29+
const packageRoot = path.join(temporary, 'package')
30+
const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')) as {
31+
exports?: { './tui'?: { import?: string } }
32+
}
33+
const entry = manifest.exports?.['./tui']?.import
34+
if (!entry) {
35+
throw new Error('Package is missing exports["./tui"].import')
36+
}
37+
38+
const entryPath = path.join(packageRoot, entry)
39+
const entrySource = await readFile(entryPath, 'utf8')
40+
if (!entrySource.includes('async tui(') || !entrySource.includes('async setup(')) {
41+
throw new Error('Packed TUI entrypoint is missing a v1 tui or v2 setup adapter')
42+
}
43+
44+
const chunks = [...entrySource.matchAll(/import\("([^"]+\.js)"\)/g)].map((match) => match[1]).filter(Boolean)
45+
if (chunks.length !== 2) {
46+
throw new Error(`Expected two lazy adapter chunks, found ${chunks.length}`)
47+
}
48+
await Promise.all(chunks.map((chunk) => readFile(path.resolve(path.dirname(entryPath), chunk))))
49+
50+
const plugin = await import(`${pathToFileURL(entryPath).href}?package-check=${Date.now()}`)
51+
if (plugin.default?.id !== 'opencode-requesty-plugin') {
52+
throw new Error('Packed plugin has an unexpected id')
53+
}
54+
if (typeof plugin.default?.tui !== 'function' || typeof plugin.default?.setup !== 'function') {
55+
throw new Error('Packed plugin must expose both the v1 tui and v2 setup adapters')
56+
}
57+
} finally {
58+
await rm(temporary, { recursive: true, force: true })
59+
}

‎src/host-store.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { ProjectionSettings } from './settings'
2+
import { createRequestyStore, type ActiveSession, type RequestyStore } from './state'
3+
import { descendantSessionIDs, rootSessionID } from './descendants'
4+
5+
export interface RequestyHostStoreOptions {
6+
apiKey: string
7+
projection: ProjectionSettings
8+
createSignal: NonNullable<Parameters<typeof createRequestyStore>[0]['createSignal']>
9+
getSessionParentID: (sessionID: string) => string | undefined
10+
getSessionCreatedAt: (sessionID: string) => number | undefined
11+
fetchSessionChildren: (sessionID: string) => Promise<string[]>
12+
onError: (message: string) => void
13+
onRender: () => void
14+
}
15+
16+
export function createRequestyHostStore(options: RequestyHostStoreOptions): RequestyStore {
17+
const activeSession = (sessionID: string): ActiveSession => {
18+
const rootID = rootSessionID(sessionID, options.getSessionParentID)
19+
return { id: rootID, created: options.getSessionCreatedAt(rootID) }
20+
}
21+
22+
return createRequestyStore({
23+
apiKey: options.apiKey,
24+
projection: options.projection,
25+
createSignal: options.createSignal,
26+
activeSession,
27+
fetchSessionChildren: (sessionID) => descendantSessionIDs(sessionID, options.fetchSessionChildren),
28+
onError: options.onError,
29+
onRender: options.onRender
30+
})
31+
}

‎src/key.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function environmentKey(provider: ProviderConfig): string | undefined {
6666
}
6767

6868
function fromConfig(config: SdkConfigLike | undefined): string | undefined {
69-
const providers = config?.provider ?? config?.providers
69+
const providers = { ...config?.provider, ...config?.providers }
7070
if (!providers) return undefined
7171
// Prefer the canonical provider id, then any custom Requesty provider.
7272
const names = Object.keys(providers).sort((a, b) => (a === 'requesty' ? -1 : b === 'requesty' ? 1 : a.localeCompare(b)))

‎src/tui-v1.tsx‎

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import type { TuiPluginApi } from '@opencode-ai/plugin/tui'
33
import { createSignal } from 'solid-js'
44
import { setApiLogger } from './api'
5-
import { descendantSessionIDs, rootSessionID } from './descendants'
65
import { RequestyDetailDialog } from './dialog'
6+
import { createRequestyHostStore } from './host-store'
77
import { detectApiKey } from './key'
88
import { RequestyPromptWidget } from './prompt'
99
import { sessionIDFromRoute } from './route'
1010
import { readSettings } from './settings'
11-
import { createRequestyStore, type RequestyStore } from './state'
11+
import type { RequestyStore } from './state'
1212
import { RequestySidebarWidget } from './widget'
1313

1414
const COMMAND_OPEN = 'requesty.open'
@@ -45,7 +45,7 @@ export function runV1(api: TuiPluginApi, rawOptions: Record<string, unknown> | u
4545
return
4646
}
4747

48-
const store: RequestyStore = createRequestyStore({
48+
const store: RequestyStore = createRequestyHostStore({
4949
apiKey: key.apiKey,
5050
projection: settings.projection,
5151
// Use this module's `createSignal` (the same Solid instance the widget
@@ -54,18 +54,9 @@ export function runV1(api: TuiPluginApi, rawOptions: Record<string, unknown> | u
5454
onError: (message) => {
5555
api.ui.toast({ variant: 'error', title: 'Requesty', message })
5656
},
57-
activeSession: (sessionID) => {
58-
const rootID = rootSessionID(sessionID, (id) => api.state.session.get(id)?.parentID)
59-
const session = api.state.session.get(rootID)
60-
if (!session) {
61-
return { id: rootID, created: undefined }
62-
}
63-
return { id: rootID, created: session.time?.created }
64-
},
65-
fetchSessionChildren: (sessionID) =>
66-
descendantSessionIDs(sessionID, (id) =>
67-
api.client.session.children({ sessionID: id }).then((result) => (result.data ?? []).map((child) => child.id))
68-
),
57+
getSessionParentID: (sessionID) => api.state.session.get(sessionID)?.parentID,
58+
getSessionCreatedAt: (sessionID) => api.state.session.get(sessionID)?.time?.created,
59+
fetchSessionChildren: (sessionID) => api.client.session.children({ sessionID }).then((result) => (result.data ?? []).map((child) => child.id)),
6960
onRender: () => api.renderer.requestRender()
7061
})
7162

‎src/tui-v2.tsx‎

Lines changed: 11 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
/** @jsxImportSource @opentui/solid */
22

3-
import type { ProviderInfo, SessionInfo } from '@opencode/client'
3+
import type { SessionInfo } from '@opencode/client'
44
import type { Context } from '@opencode/plugin/tui/context'
55
import { createSignal } from 'solid-js'
66
import { setApiLogger } from './api'
7-
import { descendantSessionIDs, rootSessionID } from './descendants'
7+
import { createRequestyHostStore } from './host-store'
88
import { RequestyDetailDialog } from './dialog'
99
import { detectApiKey } from './key'
1010
import { RequestyPromptWidget } from './prompt'
1111
import { readSettings } from './settings'
12-
import { createRequestyStore, type RequestyStore } from './state'
12+
import type { RequestyStore } from './state'
1313
import { RequestySidebarWidget } from './widget'
1414
import type { RequestyTheme, RequestyWidgetHost } from './ui-types'
15+
import { normalizeV2ConfigDocuments, type V2ConfigDocument } from './v2-config'
1516

1617
const REFRESH_DEBOUNCE_MS = 2000
1718
const REFRESH_SESSION_TYPES = new Set([
@@ -25,17 +26,6 @@ const REFRESH_SESSION_TYPES = new Set([
2526
'session.usage.recorded'
2627
])
2728

28-
interface V2ConfigDocument {
29-
info?: {
30-
providers?: Record<string, ProviderInfo | Record<string, unknown>>
31-
provider?: Record<string, Record<string, unknown>>
32-
}
33-
}
34-
35-
interface V2Config {
36-
providers?: Record<string, { options?: { apiKey?: unknown; baseURL?: unknown } }>
37-
}
38-
3929
function themeFor(context: Context): RequestyTheme {
4030
const theme = context.theme
4131
return {
@@ -49,39 +39,12 @@ function themeFor(context: Context): RequestyTheme {
4939
}
5040
}
5141

52-
function configForKey(entries: readonly V2ConfigDocument[]): V2Config {
53-
const providers: Record<string, Record<string, unknown>> = {}
54-
for (const entry of entries) {
55-
const info = entry.info
56-
for (const [id, provider] of Object.entries(info?.provider ?? {})) {
57-
providers[id] = { ...providers[id], ...provider }
58-
}
59-
for (const [id, provider] of Object.entries(info?.providers ?? {})) {
60-
const candidate = provider as Record<string, unknown>
61-
providers[id] = {
62-
...providers[id],
63-
env: candidate.env,
64-
options: {
65-
...(providers[id]?.options as Record<string, unknown> | undefined),
66-
...(candidate.settings as Record<string, unknown> | undefined)
67-
}
68-
}
69-
}
70-
}
71-
return { providers }
72-
}
73-
7442
function sessionCostHost(context: Context): RequestyWidgetHost {
7543
return {
7644
sessionMessages: (sessionID) => context.data.session.message.list(sessionID)
7745
}
7846
}
7947

80-
function rootSession(context: Context, sessionID: string): SessionInfo | undefined {
81-
const rootID = context.data.session.root(sessionID)
82-
return context.data.session.get(rootID)
83-
}
84-
8548
function sessionChildren(context: Context, sessionID: string): Promise<string[]> {
8649
return Promise.resolve(context.data.session.family(sessionID).filter((id) => id !== sessionID))
8750
}
@@ -90,15 +53,14 @@ async function runV2(context: Context): Promise<(() => void) | undefined> {
9053
const settings = readSettings(context.options)
9154
setApiLogger(undefined)
9255

93-
let config: V2Config = {}
56+
let config: readonly V2ConfigDocument[] = []
9457
try {
95-
const entries = (await context.client.config.get()) as readonly V2ConfigDocument[]
96-
config = configForKey(entries)
58+
config = (await context.client.config.get()) as V2ConfigDocument[]
9759
} catch {
9860
// A config lookup failure is reported as the normal missing-key state.
9961
}
10062

101-
const key = detectApiKey(config)
63+
const key = detectApiKey(normalizeV2ConfigDocuments(config))
10264
if (!key.ok) {
10365
if (settings.sidebar.enabled) {
10466
context.ui.slot({
@@ -116,18 +78,14 @@ async function runV2(context: Context): Promise<(() => void) | undefined> {
11678
return undefined
11779
}
11880

119-
const activeSession = (sessionID: string) => {
120-
const session = rootSession(context, sessionID)
121-
return { id: context.data.session.root(sessionID), created: session?.time.created }
122-
}
123-
124-
const store: RequestyStore = createRequestyStore({
81+
const store: RequestyStore = createRequestyHostStore({
12582
apiKey: key.apiKey,
12683
projection: settings.projection,
12784
createSignal,
85+
getSessionParentID: (sessionID) => context.data.session.get(sessionID)?.parentID,
86+
getSessionCreatedAt: (sessionID) => context.data.session.get(context.data.session.root(sessionID))?.time.created,
87+
fetchSessionChildren: (sessionID) => sessionChildren(context, sessionID),
12888
onError: (message) => context.ui.toast.show({ variant: 'error', title: 'Requesty', message }),
129-
activeSession,
130-
fetchSessionChildren: (id) => descendantSessionIDs(id, (parentID) => sessionChildren(context, parentID)),
13189
onRender: () => context.renderer.requestRender()
13290
})
13391
const widgetHost = sessionCostHost(context)

‎src/v2-config.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ProviderInfo } from '@opencode/client'
2+
3+
export interface V2ConfigDocument {
4+
type?: 'document' | 'directory'
5+
path?: string
6+
info?: {
7+
providers?: Record<string, ProviderInfo | Record<string, unknown>>
8+
provider?: Record<string, Record<string, unknown>>
9+
}
10+
}
11+
12+
export interface RequestyProviderConfig {
13+
env?: unknown
14+
options?: Record<string, unknown>
15+
settings?: Record<string, unknown>
16+
}
17+
18+
export interface RequestyConfig {
19+
providers: Record<string, RequestyProviderConfig>
20+
provider: Record<string, RequestyProviderConfig>
21+
}
22+
23+
/** Merge V2 config documents in precedence order into the shared key-detector shape. */
24+
export function normalizeV2ConfigDocuments(entries: readonly V2ConfigDocument[]): RequestyConfig {
25+
const config: RequestyConfig = { provider: {}, providers: {} }
26+
27+
for (const entry of entries) {
28+
const info = entry.info
29+
if (!info) continue
30+
mergeProviders(config.provider, info.provider)
31+
mergeProviders(config.providers, info.providers, true)
32+
}
33+
34+
return config
35+
}
36+
37+
function mergeProviders(target: Record<string, RequestyProviderConfig>, source: Record<string, unknown> | undefined, useSettings = false): void {
38+
for (const [id, value] of Object.entries(source ?? {})) {
39+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue
40+
const provider = value as Record<string, unknown>
41+
const current = target[id]
42+
const nextSettings = asRecord(provider.settings)
43+
const nextOptions = asRecord(provider.options)
44+
45+
target[id] = {
46+
...current,
47+
...provider,
48+
env: provider.env ?? current?.env,
49+
options: {
50+
...current?.options,
51+
...nextOptions,
52+
...(useSettings ? nextSettings : undefined)
53+
},
54+
settings: {
55+
...current?.settings,
56+
...nextSettings
57+
}
58+
}
59+
}
60+
}
61+
62+
function asRecord(value: unknown): Record<string, unknown> | undefined {
63+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
64+
return value as Record<string, unknown>
65+
}

0 commit comments

Comments
 (0)