Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4344098de | ||
|
|
bca26b0094 | ||
|
|
e9ca7d292b | ||
|
|
76d9c2cd76 | ||
|
|
f6101aef8a | ||
|
|
f3874ec2f9 | ||
|
|
6466fcfdea | ||
|
|
1f0390cfbb | ||
|
|
9f06accfb4 | ||
|
|
7a9724496b | ||
|
|
3e931152d1 | ||
|
|
ad1d14775d | ||
|
|
fcf4dff2ce | ||
|
|
56714327f4 | ||
|
|
21f338652d | ||
|
|
8115f0c661 | ||
|
|
87d4cb07b0 | ||
|
|
ee008923f3 | ||
|
|
bbbef0da1c | ||
|
|
1268f8657e | ||
|
|
39e7ff932d | ||
|
|
e92c4fb460 | ||
|
|
4b496066bf | ||
|
|
e63dcd30f3 | ||
|
|
2935d1819e | ||
|
|
86907e2e4a | ||
|
|
31d2d38d7a | ||
|
|
d21477d55a | ||
|
|
80fa6e6c49 | ||
|
|
b99787e95b | ||
|
|
562d299a41 | ||
|
|
9ecb04e35b | ||
|
|
231689c767 | ||
|
|
d70942079a | ||
|
|
0cc55c11ac | ||
|
|
40da77e77c | ||
|
|
0d2de7db65 |
@@ -34,11 +34,25 @@ jobs:
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
if (teamAssociations.includes(item.author_association)) {
|
||||
core.info(`Skipping ${kind} #${item.number}: author association is ${item.author_association}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
if: github.event.action == 'opened'
|
||||
if: github.event.action == 'opened' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||
|
||||
recheck-compliance:
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -11,22 +11,25 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check team membership
|
||||
id: team-check
|
||||
run: |
|
||||
LOGIN="${{ github.event.pull_request.user.login }}"
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||
ASSOCIATION="${{ github.event.pull_request.author_association }}"
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping: $LOGIN is a team member or bot"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.team-check.outputs.is_team != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: steps.team-check.outputs.is_team != 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -28,15 +28,9 @@ jobs:
|
||||
|
||||
// Check if author is a team member or bot
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/TEAM_MEMBERS',
|
||||
ref: 'dev'
|
||||
});
|
||||
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||
if (members.includes(login)) {
|
||||
console.log(`Skipping: ${login} is a team member`);
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
if (teamAssociations.includes(pr.author_association)) {
|
||||
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,15 +169,9 @@ jobs:
|
||||
|
||||
// Check if author is a team member or bot
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/TEAM_MEMBERS',
|
||||
ref: 'dev'
|
||||
});
|
||||
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||
if (members.includes(login)) {
|
||||
console.log(`Skipping: ${login} is a team member`);
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
if (teamAssociations.includes(pr.author_association)) {
|
||||
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
@@ -2177,7 +2178,7 @@
|
||||
|
||||
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
|
||||
|
||||
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
|
||||
|
||||
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
|
||||
|
||||
@@ -5837,6 +5838,8 @@
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
|
||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
@@ -42,10 +42,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
@@ -54,6 +54,7 @@
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
|
||||
@@ -48,22 +48,17 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const loadSession = () => import("@/pages/session")
|
||||
const Session = lazy(loadSession)
|
||||
const Loading = () => <div class="size-full" />
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
|
||||
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
||||
void loadSession()
|
||||
}
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
const SessionRoute = Object.assign(
|
||||
() => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
),
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const SessionIndexRoute = () => <Navigate href="session" />
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
@@ -317,7 +312,7 @@ export function AppInterface(props: {
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
|
||||
@@ -41,9 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
providers.all().find((x) => x.id === props.provider) ??
|
||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||
() => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
|
||||
@@ -106,7 +106,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
|
||||
@@ -91,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
<div class="w-full">
|
||||
<List
|
||||
class="w-full px-0"
|
||||
key={(x) => x?.id}
|
||||
key={(p) => p.id}
|
||||
items={providers.popular}
|
||||
activeIcon="plus-small"
|
||||
sortBy={(a, b) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
|
||||
@@ -132,7 +132,7 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { StatusPopover } from "./status-popover"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -223,19 +225,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const navigate = useNavigate()
|
||||
const homeMatch = useMatch(() => "/")
|
||||
|
||||
const openNewSession = () => {
|
||||
if (params.dir) {
|
||||
navigate(`/${params.dir}/session`)
|
||||
return
|
||||
}
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) {
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
if (!project) return "/"
|
||||
|
||||
navigate(`/${base64Encode(project.worktree)}/session`)
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
|
||||
type Tab = { dir: string; sessionId: string; href: string }
|
||||
@@ -302,14 +298,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const currentSessionTab = () => {
|
||||
if (!params.dir || !params.id) return
|
||||
const href = makeSessionHref(params.dir, params.id)
|
||||
if (!tabsStore.some((tab) => tab.href === href)) return
|
||||
return href
|
||||
return tabsStore.find((tab) => tab.href === href)
|
||||
}
|
||||
|
||||
const closeCurrentSessionTab = () => {
|
||||
const href = currentSessionTab()
|
||||
if (!href) return false
|
||||
tabsStoreActions.removeTab(href)
|
||||
const tab = currentSessionTab()
|
||||
if (!tab) return false
|
||||
tabsStoreActions.removeTab(tab.href)
|
||||
return true
|
||||
}
|
||||
|
||||
const closeNewSessionTab = () => {
|
||||
if (!(params.dir && !params.id)) return false
|
||||
const last = tabsStore[tabsStore.length - 1]
|
||||
if (last) navigate(last.href)
|
||||
else navigate("/")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -319,7 +322,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
(event) => {
|
||||
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "w") return
|
||||
if (!closeCurrentSessionTab()) return
|
||||
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -327,6 +330,63 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
command.register(() => {
|
||||
const commands = [
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(next.href)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(next.href)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) navigate(tab.href)
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return commands
|
||||
})
|
||||
|
||||
const tabsEnriched = iife(() => {
|
||||
const base = mapArray(
|
||||
() => tabsStore,
|
||||
@@ -391,7 +451,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewSession}
|
||||
as="a"
|
||||
href={newSessionHref()}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
}
|
||||
@@ -404,6 +465,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1" />
|
||||
</div>
|
||||
<Show when={currentSessionTab()?.dir} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
<TitlebarUpdatePill update={props.update} />
|
||||
<Show when={windows() && !electronWindows()}>
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
|
||||
@@ -232,6 +232,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSdkContext>>()
|
||||
const dirSdkContextRefCounts = new Map<string, number>()
|
||||
|
||||
return {
|
||||
url: currentServer.http.url,
|
||||
client: sdk,
|
||||
@@ -249,6 +252,58 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
createDirSyncContext: (directory: string) => {
|
||||
onCleanup(() => {
|
||||
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) - 1)
|
||||
if (dirSdkContextRefCounts.get(directory) === 0) {
|
||||
dirSyncContexts.delete(directory)
|
||||
dirSdkContextRefCounts.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
const cached = dirSyncContexts.get(directory)
|
||||
if (cached) {
|
||||
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) + 1)
|
||||
return cached
|
||||
}
|
||||
const ctx = createDirSdkContext(directory)
|
||||
dirSyncContexts.set(directory, ctx)
|
||||
dirSdkContextRefCounts.set(directory, 1)
|
||||
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string) {
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const client = globalSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = globalSDK.event.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
directory,
|
||||
client,
|
||||
event: emitter,
|
||||
get url() {
|
||||
return globalSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
|
||||
return globalSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
@@ -37,6 +29,7 @@ import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -46,7 +39,7 @@ type GlobalStore = {
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -121,7 +114,7 @@ function createGlobalSync() {
|
||||
return pathQuery.data ?? EMPTY
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: [], connected: [], default: {} }
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
@@ -20,6 +19,7 @@ import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery } from "../global-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -28,7 +28,7 @@ type GlobalStore = {
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -208,7 +208,7 @@ export async function bootstrapDirectory(input: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
@@ -220,7 +220,6 @@ export async function bootstrapDirectory(input: {
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
@@ -327,7 +326,5 @@ export async function bootstrapDirectory(input: {
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
@@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { QueryOptionsApi } from "../global-sync"
|
||||
import { directoryKey, type DirectoryKey } from "./utils"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
@@ -27,7 +28,7 @@ export function createChildStoreManager(input: {
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
global: {
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
@@ -190,10 +191,9 @@ export function createChildStoreManager(input: {
|
||||
return !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: [], connected: [], default: {} }
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
|
||||
return input.global.provider
|
||||
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
config: {},
|
||||
@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
|
||||
return { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||
return pathQuery.data
|
||||
},
|
||||
status: "loading" as const,
|
||||
status: "complete" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -38,7 +38,7 @@ export type State = {
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -17,13 +18,23 @@ export function normalizeAgentList(input: unknown): Agent[] {
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: input.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||
})),
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
const provider = providers.all().find((item) => item.id === model.providerID)
|
||||
const provider = providers.all().get(model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,11 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: Accessor<string> }) => {
|
||||
init: (props: { directory: string }) => {
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const directory = createMemo(props.directory)
|
||||
const client = createMemo(() =>
|
||||
globalSDK.createClient({
|
||||
directory: directory(),
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
createEffect(() => {
|
||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
return {
|
||||
get directory() {
|
||||
return directory()
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return globalSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
|
||||
return globalSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
return globalSDK.createDirSyncContext(props.directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -10,26 +9,8 @@ function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
const promise = task().finally(() => {
|
||||
map.delete(key)
|
||||
})
|
||||
map.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||
for (const item of b) map.set(item.id, item)
|
||||
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
@@ -127,40 +108,9 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return [input.message]
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 0, input.message)
|
||||
return next
|
||||
})
|
||||
setStore("part", input.message.id, sortParts(input.parts))
|
||||
export const useSync = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return globalSync.createDirSyncContext(sdk.directory)
|
||||
}
|
||||
|
||||
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return messages
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (!result.found) return messages
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 1)
|
||||
return next
|
||||
})
|
||||
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
||||
if (!(input.messageID in part)) return part
|
||||
const next = { ...part }
|
||||
delete next[input.messageID]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return globalSync.createDirSyncContext(sdk.directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export const popularProviders = [
|
||||
@@ -29,16 +30,32 @@ export function useProviders() {
|
||||
return {
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||
popular: () =>
|
||||
pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter((p) => connected.has(p.id))
|
||||
return pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => connected.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
)
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter(
|
||||
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
||||
)
|
||||
return [
|
||||
...Iterable.filter(
|
||||
providers().all,
|
||||
([id]) =>
|
||||
connected.has(id) &&
|
||||
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||
),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createEffect, createMemo, createResource, type ParentProps, Show } from
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -81,10 +81,8 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
{(resolved) => (
|
||||
<SDKProvider directory={() => resolved}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
<SDKProvider directory={resolved}>
|
||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -89,7 +89,7 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
@@ -154,7 +154,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !USE_HOME_DESIGN,
|
||||
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -1028,19 +1028,6 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+alt+arrowdown",
|
||||
onSelect: () => navigateProjectByOffset(1),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
}
|
||||
}),
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
@@ -1155,6 +1142,21 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!USE_NEW_DESIGN)
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
})
|
||||
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
@@ -1819,7 +1821,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2303,7 +2305,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class="shrink-0 px-3 py-3"
|
||||
classList={{
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
|
||||
}}
|
||||
>
|
||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||
@@ -2361,7 +2363,7 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
if (USE_HOME_DESIGN) {
|
||||
if (USE_NEW_DESIGN) {
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -262,8 +263,9 @@ export default function Page() {
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const isV2NewSessionPage = () => import.meta.env.VITE_OPENCODE_CHANNEL === "prod" || !params.id
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
const sessionPanelWidth = createMemo(() => {
|
||||
if (!desktopSidePanelOpen()) return "100%"
|
||||
@@ -477,7 +479,7 @@ export default function Page() {
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
const reviewDiffs = () => {
|
||||
if (store.changes === "git" || store.changes === "branch")
|
||||
// avoids suspense
|
||||
@@ -1733,12 +1735,12 @@ export default function Page() {
|
||||
</Tabs>
|
||||
</Show>
|
||||
|
||||
{/* Session panel */}
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!size.active() && !ui.reviewSnap,
|
||||
"transition-[width]": !isV2NewSessionPage(),
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
|
||||
@@ -55,7 +55,7 @@ export function SessionSidePanel(props: {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const shown = createMemo(
|
||||
@@ -207,7 +207,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop()}>
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -348,8 +348,8 @@ export const dict = {
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
|
||||
"zen.api.error.modelFormatNotSupported": "Model {{model}} is not supported for format {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "No provider available",
|
||||
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
|
||||
Vendored
+1
-298
@@ -4,304 +4,7 @@
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DISCORD_INCIDENT_WEBHOOK_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_BOT_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_CHANNEL_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"R2SecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK_PRICE": {
|
||||
"plan100": string
|
||||
"plan20": string
|
||||
"plan200": string
|
||||
"product": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_LIMITS": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_LITE_PRICE": {
|
||||
"firstMonth100Coupon": string
|
||||
"firstMonth50Coupon": string
|
||||
"price": string
|
||||
"priceInr": number
|
||||
"product": string
|
||||
"sixMonths100Coupon": string
|
||||
"threeMonths100Coupon": string
|
||||
"twelveMonths100Coupon": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS11": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS12": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS13": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS14": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS15": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS16": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS17": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS18": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS19": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS20": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS5": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS7": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS8": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
+1
-298
@@ -4,304 +4,7 @@
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DISCORD_INCIDENT_WEBHOOK_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_BOT_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_CHANNEL_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"R2SecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK_PRICE": {
|
||||
"plan100": string
|
||||
"plan20": string
|
||||
"plan200": string
|
||||
"product": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_LIMITS": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_LITE_PRICE": {
|
||||
"firstMonth100Coupon": string
|
||||
"firstMonth50Coupon": string
|
||||
"price": string
|
||||
"priceInr": number
|
||||
"product": string
|
||||
"sixMonths100Coupon": string
|
||||
"threeMonths100Coupon": string
|
||||
"twelveMonths100Coupon": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS11": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS12": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS13": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS14": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS15": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS16": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS17": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS18": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS19": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS20": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS5": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS7": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS8": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -5,6 +5,11 @@ export const Resource = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
if (`SST_RESOURCE_${prop}` in env) {
|
||||
// @ts-expect-error
|
||||
const value = env[`SST_RESOURCE_${prop}`]
|
||||
return typeof value === "string" ? JSON.parse(value) : value
|
||||
}
|
||||
if (prop in env) {
|
||||
// @ts-expect-error
|
||||
const value = env[prop]
|
||||
|
||||
+1
-298
@@ -4,304 +4,7 @@
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DISCORD_INCIDENT_WEBHOOK_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_BOT_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_CHANNEL_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"R2SecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK_PRICE": {
|
||||
"plan100": string
|
||||
"plan20": string
|
||||
"plan200": string
|
||||
"product": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_LIMITS": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_LITE_PRICE": {
|
||||
"firstMonth100Coupon": string
|
||||
"firstMonth50Coupon": string
|
||||
"price": string
|
||||
"priceInr": number
|
||||
"product": string
|
||||
"sixMonths100Coupon": string
|
||||
"threeMonths100Coupon": string
|
||||
"twelveMonths100Coupon": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS11": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS12": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS13": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS14": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS15": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS16": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS17": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS18": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS19": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS20": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS5": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS7": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS8": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import { Context, Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { AccountV2 } from "../account"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
|
||||
@@ -25,7 +25,8 @@ function resolveLocation(options: Record<string, any>) {
|
||||
}
|
||||
|
||||
function vertexEndpoint(location: string) {
|
||||
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
function replaceVertexVars(value: string, project: string | undefined, location: string) {
|
||||
@@ -131,16 +132,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
const project =
|
||||
typeof evt.options.project === "string"
|
||||
? evt.options.project
|
||||
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT)
|
||||
const location =
|
||||
typeof evt.options.location === "string"
|
||||
? evt.options.location
|
||||
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global")
|
||||
evt.sdk = mod.createVertexAnthropic({
|
||||
...evt.options,
|
||||
project:
|
||||
typeof evt.options.project === "string"
|
||||
? evt.options.project
|
||||
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT),
|
||||
location:
|
||||
typeof evt.options.location === "string"
|
||||
? evt.options.location
|
||||
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"),
|
||||
project,
|
||||
location,
|
||||
// Continental multi-regions (eu, us) require Regional Endpoint Platform
|
||||
// domains; the default {region}-aiplatform.googleapis.com does not resolve.
|
||||
...((location === "eu" || location === "us") && project && !evt.options.baseURL
|
||||
? {
|
||||
baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const VercelPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("vercel"),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider } from "./provider-helper"
|
||||
import { it, model } from "./provider-helper"
|
||||
|
||||
const cerebrasOptions: Record<string, unknown>[] = []
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
|
||||
|
||||
@@ -109,6 +109,73 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const sdkResult = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "us" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
const languageResult = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
||||
sdk: sdkResult.sdk,
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string }
|
||||
expect(language.config.baseURL).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models",
|
||||
)
|
||||
expect(language.modelId).toBe("claude-sonnet-4-5")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("trims model IDs before selecting language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -160,6 +160,32 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
provider.options.aisdk.provider.location = "eu"
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults location to us-central1 when only project is configured", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
Vendored
+1
-298
@@ -4,304 +4,7 @@
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DISCORD_INCIDENT_WEBHOOK_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_BOT_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_CHANNEL_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"R2SecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK_PRICE": {
|
||||
"plan100": string
|
||||
"plan20": string
|
||||
"plan200": string
|
||||
"product": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_LIMITS": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_LITE_PRICE": {
|
||||
"firstMonth100Coupon": string
|
||||
"firstMonth50Coupon": string
|
||||
"price": string
|
||||
"priceInr": number
|
||||
"product": string
|
||||
"sixMonths100Coupon": string
|
||||
"threeMonths100Coupon": string
|
||||
"twelveMonths100Coupon": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS11": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS12": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS13": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS14": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS15": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS16": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS17": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS18": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS19": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS20": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS5": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS7": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS8": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
Vendored
+1
-298
@@ -4,304 +4,7 @@
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DISCORD_INCIDENT_WEBHOOK_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_BOT_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DISCORD_SUPPORT_CHANNEL_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"FEISHU_APP_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"R2SecretKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK_PRICE": {
|
||||
"plan100": string
|
||||
"plan20": string
|
||||
"plan200": string
|
||||
"product": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_LIMITS": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_LITE_PRICE": {
|
||||
"firstMonth100Coupon": string
|
||||
"firstMonth50Coupon": string
|
||||
"price": string
|
||||
"priceInr": number
|
||||
"product": string
|
||||
"sixMonths100Coupon": string
|
||||
"threeMonths100Coupon": string
|
||||
"twelveMonths100Coupon": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS11": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS12": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS13": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS14": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS15": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS16": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS17": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS18": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS19": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS20": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS5": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS7": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS8": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
type ContentPart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
} from "./schema"
|
||||
import { make as makeTool, type ToolSchema } from "./tool"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { LLMError, TransportReason, type LLMRequest } from "../../schema"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Stream } from "effect"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import type { Tools } from "../../src/tool"
|
||||
import type { RunOptions } from "../../src/tool-runtime"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import {
|
||||
|
||||
@@ -42,9 +42,7 @@ import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Result, Schema } from "effect"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../../config/mcp"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Installation } from "../../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
@@ -1438,7 +1438,10 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = local.agent.current()
|
||||
const agent =
|
||||
status().type !== "idle"
|
||||
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
: local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||
return {
|
||||
frames: createFrames({
|
||||
|
||||
@@ -49,6 +49,7 @@ type Theme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
@@ -718,16 +719,18 @@ export function generateSyntax(theme: Theme) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
export function generateSubtleSyntax(theme: Theme) {
|
||||
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
|
||||
if (rule.style.foreground) {
|
||||
const fg = rule.style.foreground
|
||||
return {
|
||||
...rule,
|
||||
style: {
|
||||
...rule.style,
|
||||
...override,
|
||||
foreground: RGBA.fromInts(
|
||||
Math.round(fg.r * 255),
|
||||
Math.round(fg.g * 255),
|
||||
|
||||
+16
@@ -5,6 +5,7 @@
|
||||
|
||||
export type FileTreeItem = {
|
||||
readonly file: string
|
||||
readonly status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type FileTreeNode = {
|
||||
@@ -125,6 +126,21 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu
|
||||
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (row?.kind !== "directory") return selected
|
||||
const child = rows[index + 1]
|
||||
return child && child.depth > row.depth ? child.id : selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (!row || row.depth === 0) return selected
|
||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFile(
|
||||
rows: readonly FileTreeRow[],
|
||||
selected: number | undefined,
|
||||
|
||||
+74
-39
@@ -1,30 +1,36 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { tint } from "@tui/context/theme"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const FILE_TREE_HORIZONTAL_PADDING = 2
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeTheme = {
|
||||
readonly background: ColorInput
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: ColorInput
|
||||
readonly backgroundElement: ColorInput
|
||||
readonly primary: ColorInput
|
||||
readonly secondary: ColorInput
|
||||
readonly selectedListItemText: ColorInput
|
||||
readonly text: ColorInput
|
||||
readonly textMuted: ColorInput
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly error: ColorInput
|
||||
}
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
readonly error: unknown
|
||||
readonly theme: DiffViewerFileTreeTheme
|
||||
readonly focused?: boolean
|
||||
readonly highlightedNode?: number
|
||||
readonly selectedFileIndex?: number
|
||||
readonly reviewedFileNames?: ReadonlySet<string>
|
||||
readonly expandedNodes?: ReadonlySet<number>
|
||||
}
|
||||
|
||||
@@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
|
||||
|
||||
return (
|
||||
<box
|
||||
width={FILE_TREE_WIDTH}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.backgroundPanel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
minHeight={0}
|
||||
>
|
||||
<Panel border="both" width={props.width}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
@@ -70,29 +67,27 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
{(row) => {
|
||||
{(row, index) => {
|
||||
const highlighted = () => props.focused && props.highlightedNode === row.id
|
||||
const prefix = () =>
|
||||
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`
|
||||
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
|
||||
const reviewed = () => {
|
||||
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
|
||||
return file !== undefined && props.reviewedFileNames?.has(file)
|
||||
}
|
||||
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
|
||||
const status = () => fileTreeRowStatus(row, props.files)
|
||||
const name = () =>
|
||||
Locale.truncate(
|
||||
row.name,
|
||||
Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
|
||||
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
|
||||
)
|
||||
return (
|
||||
<box flexDirection="row" width="100%">
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? props.theme.background
|
||||
: row.kind === "directory"
|
||||
? props.theme.textMuted
|
||||
: props.theme.text
|
||||
}
|
||||
bg={highlighted() ? props.theme.primary : undefined}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? props.theme.primary : undefined}
|
||||
>
|
||||
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
|
||||
{prefix()}
|
||||
</text>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
@@ -100,16 +95,26 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
fg={
|
||||
highlighted()
|
||||
? props.theme.background
|
||||
: row.kind === "directory"
|
||||
: reviewed()
|
||||
? props.theme.textMuted
|
||||
: props.theme.text
|
||||
: selected()
|
||||
? props.theme.primary
|
||||
: row.kind === "directory"
|
||||
? tint(props.theme.text, props.theme.background, 0.35)
|
||||
: props.theme.text
|
||||
}
|
||||
bg={highlighted() ? props.theme.primary : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{name()}
|
||||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? props.theme.background : props.theme.textMuted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{status()}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
@@ -117,7 +122,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,3 +136,33 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
|
||||
scroll.scrollTo(index - scroll.viewport.height + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeRowPrefix(
|
||||
rows: readonly FileTreeRow[],
|
||||
index: number,
|
||||
row: FileTreeRow,
|
||||
expandedNodes: ReadonlySet<number> | undefined,
|
||||
) {
|
||||
const indentation = Array.from({ length: row.depth }, (_, depth) => {
|
||||
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
|
||||
return hasLaterSibling(rows, index, depth) ? "│ " : " "
|
||||
}).join("")
|
||||
const topRoot = index === 0 && row.depth === 0
|
||||
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
|
||||
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
|
||||
|
||||
return `${indentation}${branch}${marker}`
|
||||
}
|
||||
|
||||
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
|
||||
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
|
||||
}
|
||||
|
||||
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
|
||||
if (row.fileIndex === undefined) return ""
|
||||
const status = files[row.fileIndex]?.status
|
||||
if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
return "?".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createContext, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||
|
||||
const PanelGroupContext = createContext<{ axis: Axis }>()
|
||||
|
||||
function crossAxis(axis: Axis) {
|
||||
return axis === "x" ? "y" : "x"
|
||||
}
|
||||
|
||||
function usePanelGroup() {
|
||||
return useContext(PanelGroupContext)
|
||||
}
|
||||
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
||||
return (
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis }}>
|
||||
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||
{local.children}
|
||||
</box>
|
||||
</PanelGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
|
||||
const group = usePanelGroup()
|
||||
const { theme } = useTheme()
|
||||
const [local, boxProps] = splitProps(props, ["border"])
|
||||
const border = local.border ?? "start"
|
||||
const borderProps =
|
||||
border === "none"
|
||||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: theme.border,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
|
||||
{...borderProps}
|
||||
{...boxProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
|
||||
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
|
||||
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
|
||||
}
|
||||
|
||||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
const { theme } = useTheme()
|
||||
const color = () => props.color ?? theme.border
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (axis() === "y") {
|
||||
if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
|
||||
return (
|
||||
<box width={1} flexShrink={0} flexDirection="column">
|
||||
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "├" : "┤"
|
||||
if (edge === "edge-in") return "┴"
|
||||
return "┬"
|
||||
}
|
||||
|
||||
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "┬" : "┴"
|
||||
if (edge === "edge-in") return "┤"
|
||||
return "├"
|
||||
}
|
||||
@@ -9,19 +9,24 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
setFileTreeDirectoryExpanded,
|
||||
toggleFileTreeDirectory,
|
||||
} from "./diff-viewer-file-tree-utils"
|
||||
|
||||
const ROUTE = "diff"
|
||||
const MIN_SPLIT_WIDTH = 100
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||
type DiffMode = "git" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
|
||||
@@ -100,6 +105,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
||||
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
||||
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
||||
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
||||
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
|
||||
@@ -109,6 +116,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
|
||||
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
|
||||
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
|
||||
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
@@ -118,6 +126,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setHighlightedFileNode(undefined)
|
||||
setLastHighlightedFileNode(undefined)
|
||||
setActivePatchFileIndex(undefined)
|
||||
setSelectedFileIndex(undefined)
|
||||
setReviewedFileNames(new Set<string>())
|
||||
})
|
||||
|
||||
const ensureHighlightedFileNode = () => {
|
||||
@@ -144,11 +154,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setActivePatchFileIndex(undefined)
|
||||
}
|
||||
|
||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
|
||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
|
||||
if (!scroll) return
|
||||
scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
||||
const offset = fileIndex === 0 ? 0 : 1
|
||||
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||
requestAnimationFrame(() => {
|
||||
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
||||
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,8 +179,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const scrollToFileIndex = (fileIndex: number | undefined) => {
|
||||
if (fileIndex === undefined) return
|
||||
setActivePatchFileIndex(fileIndex)
|
||||
setSelectedFileIndex(fileIndex)
|
||||
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
||||
if (patchNode) scrollPatchNodeToTop(patchNode)
|
||||
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
|
||||
}
|
||||
|
||||
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
||||
@@ -227,9 +239,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
patchNodeByFileIndex.set(fileIndex, element)
|
||||
if (pendingPatchScrollFileIndex() !== fileIndex) return
|
||||
requestAnimationFrame(() => {
|
||||
scrollPatchNodeToTop(element)
|
||||
scrollPatchNodeToTop(element, fileIndex)
|
||||
requestAnimationFrame(() => {
|
||||
scrollPatchNodeToTop(element)
|
||||
scrollPatchNodeToTop(element, fileIndex)
|
||||
setPendingPatchScrollFileIndex(undefined)
|
||||
})
|
||||
})
|
||||
@@ -244,6 +256,21 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
|
||||
}
|
||||
|
||||
const toggleSelectedFileReviewed = () => {
|
||||
const fileIndex =
|
||||
focus() === "files"
|
||||
? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
||||
: (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
|
||||
const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
|
||||
if (!file) return
|
||||
setReviewedFileNames((reviewed) => {
|
||||
const next = new Set(reviewed)
|
||||
if (next.has(file)) next.delete(file)
|
||||
else next.add(file)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{
|
||||
name: "diff.close",
|
||||
@@ -326,6 +353,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
const highlighted = highlightedFileNode()
|
||||
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
|
||||
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
|
||||
return
|
||||
}
|
||||
setExpandedFileNodes((expanded) =>
|
||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
|
||||
)
|
||||
@@ -339,6 +371,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
const highlighted = highlightedFileNode()
|
||||
const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
|
||||
if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
|
||||
setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
|
||||
return
|
||||
}
|
||||
setExpandedFileNodes((expanded) =>
|
||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
|
||||
)
|
||||
@@ -362,6 +400,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
jumpRelativePatchFile(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.mark_reviewed",
|
||||
title: "Toggle selected diff file reviewed",
|
||||
category: "VCS",
|
||||
run() {
|
||||
toggleSelectedFileReviewed()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.switch_focus",
|
||||
title: "Switch diff viewer focus",
|
||||
@@ -460,6 +506,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
|
||||
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
|
||||
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
|
||||
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
|
||||
...props.api.tuiConfig.keybinds.gather(
|
||||
"diff",
|
||||
commands.map((command) => command.name),
|
||||
@@ -468,186 +515,193 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme().background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme().text}>Diff</text>
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={theme().text}>Diff </text>
|
||||
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme().textMuted}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Panel>
|
||||
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
<Switch>
|
||||
<Match when={diff.loading}>
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme().textMuted}>Loading diff...</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<PanelGroup axis="x">
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
theme={theme()}
|
||||
focused={focus() === "files"}
|
||||
width={FILE_TREE_WIDTH}
|
||||
highlightedNode={highlightedFileNode()}
|
||||
selectedFileIndex={selectedFileIndex()}
|
||||
reviewedFileNames={reviewedFileNames()}
|
||||
expandedNodes={expandedFileNodes()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Panel flexGrow={1} minHeight={0} border="none">
|
||||
<Separator axis="x" start="edge-out" />
|
||||
<Switch>
|
||||
<Match when={diff.error}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().error}>Failed to load diff</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().textMuted}>No diff to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry, index) => {
|
||||
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||
return (
|
||||
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().border}
|
||||
>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
</box>
|
||||
<Separator axis="x" start="edge" />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={["left"]} borderColor={theme().border}>
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? theme().textMuted : theme().text}
|
||||
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
||||
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
||||
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
||||
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
addedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Separator axis="x" start="edge-in" />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<Switch>
|
||||
<Match when={diff.loading}>
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme().textMuted}>Loading diff...</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
theme={theme()}
|
||||
focused={focus() === "files"}
|
||||
highlightedNode={highlightedFileNode()}
|
||||
expandedNodes={expandedFileNodes()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme().background}
|
||||
paddingLeft={0}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={diff.error}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().error}>Failed to load diff</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().textMuted}>No diff to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry) => (
|
||||
<box
|
||||
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
|
||||
marginBottom={1}
|
||||
backgroundColor={theme().backgroundPanel}
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().backgroundPanel}
|
||||
>
|
||||
<text fg={theme().text}>{entry.file.file}</text>
|
||||
<text fg={theme().diffAdded}>+{entry.file.additions}</text>
|
||||
<text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
|
||||
</box>
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme().text}
|
||||
addedBg={theme().diffAddedBg}
|
||||
removedBg={theme().diffRemovedBg}
|
||||
contextBg={theme().diffContextBg}
|
||||
addedSignColor={theme().diffHighlightAdded}
|
||||
removedSignColor={theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
lineNumberBg={theme().diffContextBg}
|
||||
addedLineNumberBg={theme().diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme().diffRemovedLineNumberBg}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleFileTreeShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={singlePatchShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchDiffShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleViewShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleFileTreeShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={singlePatchShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchDiffShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleViewShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={markReviewedShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
SessionMessageCompaction,
|
||||
SessionMessageModelSwitched,
|
||||
SessionMessageShell,
|
||||
SessionMessageSynthetic,
|
||||
SessionMessageUser,
|
||||
ToolFileContent,
|
||||
ToolTextContent,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type {
|
||||
@@ -53,7 +53,6 @@ import type { SkillTool } from "@/tool/skill"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
@@ -1498,7 +1497,7 @@ const PART_MAPPING = {
|
||||
}
|
||||
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
@@ -1520,6 +1519,8 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
// blocks. Surface the title both while streaming and after settling so the
|
||||
// collapsed line carries real signal, not just a duration.
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
// Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
|
||||
const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
@@ -1536,7 +1537,9 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
syntaxStyle={syntax()}
|
||||
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
|
||||
// the syntax override above removes only the italic attribute from that emphasis token.
|
||||
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
@@ -1564,7 +1567,7 @@ function CollapsedReasoningText(props: { title: string | null; duration: number
|
||||
|
||||
return (
|
||||
<text fg={theme.warning} wrapMode="none">
|
||||
<span style={{ fg: theme.warning, italic: true }}>
|
||||
<span style={{ fg: theme.warning }}>
|
||||
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
|
||||
</span>
|
||||
</text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
|
||||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
||||
return copy
|
||||
}
|
||||
|
||||
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return
|
||||
async function substituteWellKnownRemoteConfig(input: {
|
||||
value: unknown
|
||||
dir: string
|
||||
source: string
|
||||
env: Record<string, string>
|
||||
}) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
|
||||
|
||||
const url = await ConfigVariable.substitute({
|
||||
text: input.value.url,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
})
|
||||
const headers = isRecord(input.value.headers)
|
||||
? Object.fromEntries(
|
||||
@@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
@@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||
return { url, headers }
|
||||
}
|
||||
|
||||
const WellKnownConfig = Schema.Struct({
|
||||
config: Schema.optional(Schema.Json),
|
||||
remote_config: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
@@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Fiber.Fiber<void, never>[]
|
||||
deps: Fiber.Fiber<void>[]
|
||||
consoleState: ConsoleState
|
||||
}
|
||||
|
||||
@@ -372,17 +386,38 @@ export const layer = Layer.effect(
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
|
||||
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
|
||||
url: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
schema: S,
|
||||
) {
|
||||
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||
.execute(
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
env?: Record<string, string>,
|
||||
) {
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute(
|
||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
||||
"path" in options
|
||||
? { text, type: "path", path: options.path, env }
|
||||
: { text, type: "virtual", ...options, env },
|
||||
),
|
||||
)
|
||||
const parsed = ConfigParse.jsonc(expanded, source)
|
||||
@@ -398,14 +433,14 @@ export const layer = Layer.effect(
|
||||
return data
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
return yield* loadConfig(text, { path: filepath }, env)
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
|
||||
let result: Info = {}
|
||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
||||
// explicitly routes config through env-provided paths or content.
|
||||
@@ -417,9 +452,9 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
}
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
@@ -477,6 +512,7 @@ export const layer = Layer.effect(
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
const authEnv: Record<string, string> = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
@@ -516,56 +552,56 @@ export const layer = Layer.effect(
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as {
|
||||
config?: Record<string, unknown>
|
||||
remote_config?: unknown
|
||||
}
|
||||
authEnv[value.key] = value.token
|
||||
const wellknownURL = `${url}/.well-known/opencode`
|
||||
log.debug("fetching remote config", { url: wellknownURL })
|
||||
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
|
||||
const remote = yield* Effect.promise(() =>
|
||||
substituteWellKnownRemoteConfig({
|
||||
value: wellknown.remote_config,
|
||||
dir: url,
|
||||
source: `${url}/.well-known/opencode`,
|
||||
source: wellknownURL,
|
||||
env: authEnv,
|
||||
}),
|
||||
)
|
||||
const fetchedConfig = remote
|
||||
? ((yield* Effect.promise(async () => {
|
||||
? yield* Effect.gen(function* () {
|
||||
log.debug("fetching remote config", { url: remote.url })
|
||||
const response = await fetch(remote.url, { headers: remote.headers })
|
||||
if (!response.ok)
|
||||
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
|
||||
const data = await response.json()
|
||||
return isRecord(data) && isRecord(data.config) ? data.config : data
|
||||
})) as Record<string, unknown>)
|
||||
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
|
||||
if (isRecord(data) && isRecord(data.config)) return data.config
|
||||
if (isRecord(data)) return data
|
||||
return yield* Effect.die(
|
||||
new Error(`failed to decode remote config from ${remote.url}: expected object`),
|
||||
)
|
||||
})
|
||||
: {}
|
||||
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
|
||||
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = `${url}/.well-known/opencode`
|
||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
const source = wellknownURL
|
||||
const next = yield* loadConfig(
|
||||
JSON.stringify(remoteConfig),
|
||||
{
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
},
|
||||
authEnv,
|
||||
)
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
yield* merge(file, yield* loadFile(file, authEnv), "local")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,14 +615,14 @@ export const layer = Layer.effect(
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Fiber.Fiber<void, never>[] = []
|
||||
const deps: Fiber.Fiber<void>[] = []
|
||||
|
||||
for (const dir of directories) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
yield* merge(source, yield* loadFile(source, authEnv))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
@@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export * as Config from "./config"
|
||||
|
||||
@@ -5,7 +5,6 @@ import os from "os"
|
||||
import path from "path"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Process } from "@/util/process"
|
||||
import { warn } from "console"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export const OAuth = Schema.Struct({
|
||||
description: "OAuth client secret (if required by the authorization server)",
|
||||
}),
|
||||
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
|
||||
callbackPort: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))).annotate({
|
||||
description:
|
||||
"Port for the local OAuth callback server (default: 19876). Shorthand for redirectUri when only the port needs changing. Ignored if redirectUri is set.",
|
||||
}),
|
||||
redirectUri: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,7 @@ type ParseSource =
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
function source(input: ParseSource) {
|
||||
@@ -33,7 +34,7 @@ function dir(input: ParseSource) {
|
||||
export async function substitute(input: SubstituteInput) {
|
||||
const missing = input.missing ?? "error"
|
||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
return (input.env?.[varName] ?? process.env[varName]) || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
@@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
const index = match.index
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
|
||||
@@ -16,11 +16,10 @@ import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Installation } from "../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { McpOAuthProvider } from "./oauth-provider"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { BusEvent } from "../bus/bus-event"
|
||||
@@ -319,6 +318,7 @@ export const layer = Layer.effect(
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
callbackPort: oauthConfig?.callbackPort,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
@@ -770,8 +770,15 @@ export const layer = Layer.effect(
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
|
||||
// Resolve effective redirect URI: explicit redirectUri > callbackPort shorthand > default
|
||||
const effectiveRedirectUri =
|
||||
oauthConfig?.redirectUri ??
|
||||
(oauthConfig?.callbackPort
|
||||
? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}`
|
||||
: undefined)
|
||||
|
||||
// Start the callback server with custom redirectUri if configured
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri))
|
||||
|
||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
@@ -785,7 +792,7 @@ export const layer = Layer.effect(
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
redirectUri: effectiveRedirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface McpOAuthConfig {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
callbackPort?: number
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
if (this.config.redirectUri) {
|
||||
return this.config.redirectUri
|
||||
}
|
||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||
const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT
|
||||
return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
@@ -49,6 +51,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
...(this.config.scope ? { scope: this.config.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
|
||||
@@ -85,6 +85,13 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
|
||||
if (!project) return
|
||||
if (location !== "eu" && location !== "us") return
|
||||
// Continental multi-regions require Regional Endpoint Platform domains.
|
||||
return `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`
|
||||
}
|
||||
|
||||
type BundledSDK = {
|
||||
languageModel(modelId: string): LanguageModelV3
|
||||
}
|
||||
@@ -507,11 +514,13 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
const baseURL = googleVertexAnthropicBaseURL(project, location)
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
project,
|
||||
location,
|
||||
...(baseURL && { baseURL }),
|
||||
},
|
||||
async getModel(sdk: any, modelID) {
|
||||
const id = String(modelID).trim()
|
||||
@@ -1516,6 +1525,18 @@ export const layer = Layer.effect(
|
||||
const provider = s.providers[model.providerID]
|
||||
const options = { ...provider.options }
|
||||
|
||||
if (
|
||||
model.providerID === "google-vertex" &&
|
||||
model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
|
||||
!options.baseURL
|
||||
) {
|
||||
const baseURL = googleVertexAnthropicBaseURL(
|
||||
typeof options.project === "string" ? options.project : undefined,
|
||||
typeof options.location === "string" ? options.location : undefined,
|
||||
)
|
||||
if (baseURL) options.baseURL = baseURL
|
||||
}
|
||||
|
||||
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
|
||||
delete options.fetch
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ function proxyRemote(
|
||||
const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request)
|
||||
const sync = Fence.parse(new Headers(response.headers))
|
||||
if (sync) {
|
||||
const syncFailure = yield* Fence.waitEffect(
|
||||
const syncFailure = yield* Fence.wait(
|
||||
workspace.id,
|
||||
sync,
|
||||
request.source instanceof Request ? request.source.signal : undefined,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const HEADER = "x-opencode-sync"
|
||||
@@ -20,7 +19,7 @@ export function load(ids?: string[]) {
|
||||
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
|
||||
})
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
|
||||
}
|
||||
|
||||
export function diff(prev: State, next: State) {
|
||||
@@ -31,15 +30,14 @@ export function diff(prev: State, next: State) {
|
||||
.filter(([id, seq]) => {
|
||||
return (prev[id] ?? -1) !== seq
|
||||
}),
|
||||
) as State
|
||||
)
|
||||
}
|
||||
|
||||
export function parse(headers: Headers) {
|
||||
export function parse(headers: Headers): State | undefined {
|
||||
const raw = headers.get(HEADER)
|
||||
if (!raw) return
|
||||
|
||||
let data
|
||||
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
@@ -49,13 +47,13 @@ export function parse(headers: Headers) {
|
||||
if (!data || typeof data !== "object") return
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([id, seq]) => {
|
||||
return typeof id === "string" && Number.isInteger(seq)
|
||||
Object.entries(data).filter((entry): entry is [string, number] => {
|
||||
return typeof entry[0] === "string" && Number.isInteger(entry[1])
|
||||
}),
|
||||
) as State
|
||||
)
|
||||
}
|
||||
|
||||
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
export function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("waiting for state", {
|
||||
workspaceID,
|
||||
@@ -68,7 +66,3 @@ export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: Abor
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -47,7 +47,6 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import type { Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
buildFileTree,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
setFileTreeDirectoryExpanded,
|
||||
toggleFileTreeDirectory,
|
||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
@@ -177,6 +179,41 @@ describe("diff viewer file tree utilities", () => {
|
||||
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves directory selection to first visible child", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
|
||||
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves collapsed chain selection to first visible child", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
|
||||
})
|
||||
|
||||
test("moves file and collapsed directory selection to visible parent", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
const app = rows.find((row) => row.name === "app.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves file selection relative to the highlighted row", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
||||
|
||||
@@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -14,6 +18,7 @@ const theme = {
|
||||
backgroundPanel: RGBA.fromHex("#111111"),
|
||||
backgroundElement: RGBA.fromHex("#333333"),
|
||||
primary: RGBA.fromHex("#00ffff"),
|
||||
secondary: RGBA.fromHex("#0088ff"),
|
||||
selectedListItemText: RGBA.fromHex("#ffffff"),
|
||||
text: RGBA.fromHex("#ffffff"),
|
||||
textMuted: RGBA.fromHex("#888888"),
|
||||
@@ -23,29 +28,38 @@ const theme = {
|
||||
describe("DiffViewerFileTree", () => {
|
||||
test("renders sorted hierarchical file rows", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<DiffViewerFileTree
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
),
|
||||
() =>
|
||||
withTheme(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
)),
|
||||
{ width: 40, height: 20 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await renderOnceSettled(app)
|
||||
const lines = visibleLines(app.captureCharFrame())
|
||||
|
||||
expect(lines).toEqual(["▾ a", " alpha.ts", " zeta.ts", "▾ b", " alpha.ts", " file.ts", " z-file.ts"])
|
||||
expect(lines).toEqual([
|
||||
"▾ a",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ zeta.ts ?",
|
||||
"├─ ▾ b",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ file.ts ?",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -53,13 +67,13 @@ describe("DiffViewerFileTree", () => {
|
||||
|
||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||
const loading = await renderFrame(() => (
|
||||
<DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
|
||||
))
|
||||
const failed = await renderFrame(() => (
|
||||
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
))
|
||||
const empty = await renderFrame(() => (
|
||||
<DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
|
||||
))
|
||||
|
||||
expect(loading).not.toContain("Loading diff...")
|
||||
@@ -76,6 +90,7 @@ describe("DiffViewerFileTree", () => {
|
||||
const focused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
@@ -86,7 +101,9 @@ describe("DiffViewerFileTree", () => {
|
||||
)),
|
||||
)
|
||||
const unfocused = visibleLines(
|
||||
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
|
||||
)),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src/config")
|
||||
@@ -105,16 +122,24 @@ describe("DiffViewerFileTree", () => {
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={collapsed}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src/config", " README.md"])
|
||||
).toEqual(["▸ src/config"])
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
files={files}
|
||||
width={32}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
@@ -122,25 +147,41 @@ describe("DiffViewerFileTree", () => {
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▾ src/config", " tui.ts", " README.md"])
|
||||
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
||||
})
|
||||
})
|
||||
|
||||
async function renderFrame(component: () => JSX.Element) {
|
||||
const app = await testRender(component, { width: 40, height: 10 })
|
||||
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await renderOnceSettled(app)
|
||||
return app.captureCharFrame()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
await app.renderOnce()
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleLines(frame: string) {
|
||||
return frame
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
||||
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└/.test(line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
|
||||
import type { McpAuth } from "../../src/mcp/auth"
|
||||
|
||||
// Stub auth — only synchronous getters are exercised in these tests
|
||||
const stubAuth = {} as McpAuth.Interface
|
||||
|
||||
const makeProvider = (config: ConstructorParameters<typeof McpOAuthProvider>[2]) =>
|
||||
new McpOAuthProvider("test-server", "https://mcp.example.com/mcp", config, { onRedirect: async () => {} }, stubAuth)
|
||||
|
||||
describe("McpOAuthProvider.redirectUrl", () => {
|
||||
test("defaults to 127.0.0.1:19876/mcp/oauth/callback", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.redirectUrl).toBe(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`)
|
||||
})
|
||||
|
||||
test("uses callbackPort when set", () => {
|
||||
const provider = makeProvider({ callbackPort: 6620 })
|
||||
expect(provider.redirectUrl).toBe(`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`)
|
||||
})
|
||||
|
||||
test("redirectUri takes precedence over callbackPort", () => {
|
||||
const provider = makeProvider({
|
||||
callbackPort: 6620,
|
||||
redirectUri: "http://127.0.0.1:9999/custom/callback",
|
||||
})
|
||||
expect(provider.redirectUrl).toBe("http://127.0.0.1:9999/custom/callback")
|
||||
})
|
||||
|
||||
test("uses explicit redirectUri when set without callbackPort", () => {
|
||||
const provider = makeProvider({ redirectUri: "http://127.0.0.1:8080/oauth/callback" })
|
||||
expect(provider.redirectUrl).toBe("http://127.0.0.1:8080/oauth/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthProvider.clientMetadata", () => {
|
||||
test("includes redirect_uris from redirectUrl", () => {
|
||||
const provider = makeProvider({ callbackPort: 6620 })
|
||||
expect(provider.clientMetadata.redirect_uris).toEqual([`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`])
|
||||
})
|
||||
|
||||
test("includes scope when set in config", () => {
|
||||
const provider = makeProvider({ scope: "openid offline_access" })
|
||||
expect(provider.clientMetadata.scope).toBe("openid offline_access")
|
||||
})
|
||||
|
||||
test("omits scope when not set in config", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.clientMetadata.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sets token_endpoint_auth_method to client_secret_post when clientSecret provided", () => {
|
||||
const provider = makeProvider({ clientSecret: "secret" })
|
||||
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("client_secret_post")
|
||||
})
|
||||
|
||||
test("sets token_endpoint_auth_method to none when no clientSecret", () => {
|
||||
const provider = makeProvider({})
|
||||
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
@@ -15,22 +14,18 @@ import { Plugin } from "../../src/plugin/index"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
@@ -24,22 +23,18 @@ import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
|
||||
@@ -73,6 +73,8 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
|
||||
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||
}
|
||||
|
||||
const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer))
|
||||
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
|
||||
|
||||
@@ -1546,6 +1548,48 @@ it.instance(
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "eu")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "us")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderID.make("google-vertex-anthropic"),
|
||||
ModelID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1/projects/test-project/locations/us/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "europe-west1")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("cloudflare-ai-gateway loads with env variables", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("CLOUDFLARE_ACCOUNT_ID", "test-account")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const baseModel: Provider.Model = {
|
||||
id: ModelID.make("gpt-5-mini"),
|
||||
@@ -69,6 +70,10 @@ const providerInfo: Provider.Info = {
|
||||
models: {},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||
)
|
||||
|
||||
function responsesStream(chunks: unknown[]) {
|
||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
|
||||
status: 200,
|
||||
@@ -76,6 +81,72 @@ function responsesStream(chunks: unknown[]) {
|
||||
})
|
||||
}
|
||||
|
||||
type NativeRequestInput = Parameters<typeof LLMNative.request>[0]
|
||||
|
||||
const sessionText = (text: string) => ({ type: "text" as const, text })
|
||||
|
||||
const sessionOpenAIReasoning = (
|
||||
text: string,
|
||||
options: {
|
||||
readonly storedAs: "providerMetadata" | "providerOptions"
|
||||
readonly itemId: string
|
||||
readonly encryptedContent: string | null
|
||||
},
|
||||
) => {
|
||||
const metadata = {
|
||||
openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
|
||||
}
|
||||
if (options.storedAs === "providerMetadata")
|
||||
return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
|
||||
return Object.assign({ type: "reasoning" as const, text }, { providerOptions: metadata })
|
||||
}
|
||||
|
||||
type SessionAssistantPart = ReturnType<typeof sessionText> | ReturnType<typeof sessionOpenAIReasoning>
|
||||
|
||||
const storedSession = {
|
||||
user: (content: string): ModelMessage => ({ role: "user", content }),
|
||||
assistant: (content: SessionAssistantPart[]): ModelMessage => ({ role: "assistant", content }),
|
||||
text: sessionText,
|
||||
openaiReasoning: sessionOpenAIReasoning,
|
||||
}
|
||||
|
||||
const openAIResponses = {
|
||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
||||
type: "reasoning",
|
||||
id: options.itemId,
|
||||
encrypted_content: options.encryptedContent,
|
||||
summary: [{ type: "summary_text", text }],
|
||||
}),
|
||||
}
|
||||
|
||||
const prepareNativeRequest = (input: NativeRequestInput) => LLMClient.prepare(LLMNative.request(input))
|
||||
|
||||
const expectOpenAIResponsesRequest = (input: {
|
||||
readonly history: NativeRequestInput["messages"]
|
||||
readonly providerOptions?: NativeRequestInput["providerOptions"]
|
||||
readonly maxOutputTokens?: NativeRequestInput["maxOutputTokens"]
|
||||
readonly headers?: NativeRequestInput["headers"]
|
||||
readonly expectedBody: unknown
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* prepareNativeRequest({
|
||||
model: baseModel,
|
||||
apiKey: "test-openai-key",
|
||||
messages: input.history,
|
||||
providerOptions: input.providerOptions,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
headers: input.headers,
|
||||
}),
|
||||
).toMatchObject({
|
||||
route: "openai-responses",
|
||||
protocol: "openai-responses",
|
||||
body: input.expectedBody,
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.llm-native.request", () => {
|
||||
test("maps normalized stream inputs to a native LLM request", () => {
|
||||
const messages: ModelMessage[] = [
|
||||
@@ -426,122 +497,163 @@ describe("session.llm-native.request", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{
|
||||
explode: {
|
||||
description: "always throws",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
it.effect("native tool wrapper converts thrown errors into typed ToolFailure", () =>
|
||||
Effect.gen(function* () {
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{
|
||||
explode: {
|
||||
description: "always throws",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
} satisfies Tool,
|
||||
},
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
|
||||
const failure = await Effect.runPromise(
|
||||
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
|
||||
)
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect((failure as ToolFailure).message).toBe("boom")
|
||||
})
|
||||
const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" }))
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect(failure.message).toBe("boom")
|
||||
}),
|
||||
)
|
||||
|
||||
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
|
||||
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () =>
|
||||
Effect.gen(function* () {
|
||||
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool },
|
||||
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||
)
|
||||
|
||||
const failure = await Effect.runPromise(
|
||||
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
|
||||
)
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect((failure as ToolFailure).message).toContain("incomplete")
|
||||
})
|
||||
const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))
|
||||
expect(failure).toBeInstanceOf(ToolFailure)
|
||||
expect(failure.message).toContain("incomplete")
|
||||
}),
|
||||
)
|
||||
|
||||
test("compiles through the native OpenAI Responses route", async () => {
|
||||
const prepared = await Effect.runPromise(
|
||||
LLMClient.prepare(
|
||||
LLMNative.request({
|
||||
model: baseModel,
|
||||
apiKey: "test-openai-key",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
||||
maxOutputTokens: 512,
|
||||
headers: { "x-request": "request-header" },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(LLMClient.layer),
|
||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
route: "openai-responses",
|
||||
protocol: "openai-responses",
|
||||
body: {
|
||||
it.effect("compiles through the native OpenAI Responses route", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [storedSession.user("hello")],
|
||||
providerOptions: { openai: { store: false, instructions: "You are concise." } },
|
||||
maxOutputTokens: 512,
|
||||
headers: { "x-request": "request-header" },
|
||||
expectedBody: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
input: [openAIResponses.user("hello")],
|
||||
max_output_tokens: 512,
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("uses provider fetch override for native OpenAI OAuth requests", async () => {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
const customFetch = (async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
captures.push({ url: request.url, body: await request.clone().json() })
|
||||
return responsesStream([
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
||||
])
|
||||
}) as typeof fetch
|
||||
|
||||
const events = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
||||
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
llmClient,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: {},
|
||||
providerOptions: { instructions: "You are concise." },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") return []
|
||||
return yield* native.stream.pipe(Stream.runCollect)
|
||||
}).pipe(
|
||||
Effect.provide(LLMClient.layer),
|
||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(captures).toHaveLength(1)
|
||||
expect(captures[0]).toMatchObject({
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
body: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.user("What changed?"),
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerOptions",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: null,
|
||||
}),
|
||||
storedSession.text("The parser changed."),
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
store: false,
|
||||
},
|
||||
})
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
||||
expect.objectContaining({ type: "finish" }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves encrypted OpenAI reasoning state through native request lowering", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.user("What changed?"),
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
storedSession.text("The parser changed."),
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
const customFetch = Object.assign(
|
||||
async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
captures.push({ url: request.url, body: await request.clone().json() })
|
||||
return responsesStream([
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
||||
])
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
) satisfies typeof fetch
|
||||
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
|
||||
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
llmClient,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: {},
|
||||
providerOptions: { instructions: "You are concise." },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") throw new Error(native.reason)
|
||||
const events = Array.from(yield* native.stream.pipe(Stream.runCollect))
|
||||
|
||||
expect(captures).toHaveLength(1)
|
||||
expect(captures[0]).toMatchObject({
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
body: {
|
||||
model: "gpt-5-mini",
|
||||
instructions: "You are concise.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
},
|
||||
})
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text-delta", text: "Hello" }),
|
||||
expect.objectContaining({ type: "finish" }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Git } from "@/git"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
|
||||
@@ -1063,7 +1063,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
const providerID = props.message.model?.providerID
|
||||
const modelID = props.message.model?.modelID
|
||||
if (!providerID || !modelID) return ""
|
||||
const match = data.store.provider?.all?.find((p) => p.id === providerID)
|
||||
const match = data.store.provider?.all?.get(providerID)
|
||||
return match?.models?.[modelID]?.name ?? modelID
|
||||
})
|
||||
const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
|
||||
@@ -1458,7 +1458,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const model = createMemo(() => {
|
||||
if (props.message.role !== "assistant") return ""
|
||||
const message = props.message as AssistantMessage
|
||||
const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
|
||||
const match = data.store.provider?.all?.get(message.providerID)
|
||||
return match?.models?.[message.modelID]?.name ?? message.modelID
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
export type NormalizedProviderListResponse = {
|
||||
all: Map<string, Provider>
|
||||
default: {
|
||||
[key: string]: string
|
||||
}
|
||||
connected: Array<string>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
agent?: {
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
provider?: ProviderListResponse
|
||||
provider?: NormalizedProviderListResponse
|
||||
session: Session[]
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
|
||||
@@ -15,8 +15,11 @@ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
type Issue = {
|
||||
number: number
|
||||
updated_at: string
|
||||
author_association: string
|
||||
}
|
||||
|
||||
const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -63,6 +66,10 @@ async function main() {
|
||||
for (const i of all) {
|
||||
const updated = new Date(i.updated_at)
|
||||
if (updated < cutoff) {
|
||||
if (teamAssociations.has(i.author_association)) {
|
||||
console.log(`Skipping #${i.number}: author association is ${i.author_association}`)
|
||||
continue
|
||||
}
|
||||
stale.push(i.number)
|
||||
} else {
|
||||
console.log(`\nFound fresh issue #${i.number}, stopping`)
|
||||
|
||||
@@ -69,6 +69,7 @@ const maxClose =
|
||||
const sleepMs = requireNonNegativeInteger("sleep-ms", values["sleep-ms"])
|
||||
const printLimit = requireNonNegativeInteger("print-limit", values["print-limit"])
|
||||
const cutoff = subtractMonths(new Date(), ageMonths)
|
||||
const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -82,6 +83,7 @@ type PullRequest = {
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
authorAssociation: string
|
||||
reactionGroups: Array<{
|
||||
content: string
|
||||
users: {
|
||||
@@ -145,19 +147,25 @@ async function main() {
|
||||
console.log(`Threshold: fewer than ${threshold} positive reactions`)
|
||||
|
||||
const prs = await fetchOpenPullRequests()
|
||||
const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length
|
||||
const matching = prs
|
||||
.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) }))
|
||||
.filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold)
|
||||
const scored = prs.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) }))
|
||||
const recentCount = scored.filter((pr) => new Date(pr.createdAt) >= cutoff).length
|
||||
const teamCount = scored.filter((pr) => isTeamMember(pr)).length
|
||||
const matching = scored.filter(
|
||||
(pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold,
|
||||
)
|
||||
const candidates = matching.filter((pr) => !hasPriorCleanup(pr))
|
||||
const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose)
|
||||
|
||||
console.log(`Fetched ${prs.length} open PRs`)
|
||||
console.log(`Matching cleanup criteria: ${matching.length}`)
|
||||
console.log(`Skipped previously cleaned PRs: ${matching.length - candidates.length}`)
|
||||
console.log(`Team member PRs untouched: ${teamCount}`)
|
||||
console.log(`Recent PRs untouched: ${recentCount}`)
|
||||
console.log(
|
||||
`Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - matching.length - recentCount}`,
|
||||
`Older PRs with at least ${threshold} positive reactions untouched: ${
|
||||
scored.filter((pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions >= threshold)
|
||||
.length
|
||||
}`,
|
||||
)
|
||||
|
||||
if (selected.length === 0) return
|
||||
@@ -205,6 +213,7 @@ async function fetchOpenPullRequests() {
|
||||
title
|
||||
url
|
||||
createdAt
|
||||
authorAssociation
|
||||
reactionGroups {
|
||||
content
|
||||
users {
|
||||
@@ -335,6 +344,10 @@ function hasPriorCleanup(pr: PullRequest) {
|
||||
return pr.labels.nodes.some((label) => label.name === cleanupLabel)
|
||||
}
|
||||
|
||||
function isTeamMember(pr: PullRequest) {
|
||||
return teamAssociations.has(pr.authorAssociation)
|
||||
}
|
||||
|
||||
function requireRepo(value: string | undefined) {
|
||||
if (!value) throw new Error("repo is required")
|
||||
const [owner, name] = value.split("/")
|
||||
|
||||
Vendored
+9
-36
@@ -22,22 +22,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Api": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"AuthApi": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"AuthStorage": {
|
||||
"namespaceId": string
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"Bucket": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
}
|
||||
"Api": import("@cloudflare/workers-types").Service
|
||||
"AuthApi": import("@cloudflare/workers-types").Service
|
||||
"AuthStorage": import("@cloudflare/workers-types").KVNamespace
|
||||
"Bucket": import("@cloudflare/workers-types").R2Bucket
|
||||
"CLOUDFLARE_API_TOKEN": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -74,10 +62,7 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"EnterpriseStorage": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
}
|
||||
"EnterpriseStorage": import("@cloudflare/workers-types").R2Bucket
|
||||
"FEISHU_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -114,9 +99,7 @@ declare module "sst" {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"LogProcessor": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
}
|
||||
"LogProcessor": import("@cloudflare/workers-types").Service
|
||||
"R2AccessKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -149,10 +132,7 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Stat": {
|
||||
"type": "sst.cloudflare.Worker"
|
||||
"url": string
|
||||
}
|
||||
"Stat": import("@cloudflare/workers-types").Service
|
||||
"Teams": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
@@ -311,17 +291,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZenData": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
}
|
||||
"ZenDataNew": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
}
|
||||
"ZenData": import("@cloudflare/workers-types").R2Bucket
|
||||
"ZenDataNew": import("@cloudflare/workers-types").R2Bucket
|
||||
}
|
||||
}
|
||||
/// <reference path="sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
Reference in New Issue
Block a user