diff options
| author | David Hill <[email protected]> | 2025-11-10 13:44:12 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-11-10 13:44:12 +0000 |
| commit | c6e830c954418808dc39284a1c073aa63a6d4d21 (patch) | |
| tree | 9c3052e0509115188768a553c0be5a8441ebdd96 /packages | |
| parent | 7088bfabd773e2f076aab1c9d2468c04feff0570 (diff) | |
| parent | fc78c28df64383a9f99382093f61fc28caf6569f (diff) | |
| download | opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.tar.gz opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages')
266 files changed, 8403 insertions, 12811 deletions
diff --git a/packages/console/app/.gitignore b/packages/console/app/.gitignore index 751513ce1..fb29f05d7 100644 --- a/packages/console/app/.gitignore +++ b/packages/console/app/.gitignore @@ -23,6 +23,9 @@ app.config.timestamp_*.js # Temp gitignore +# Generated files +public/sitemap.xml + # System Files .DS_Store Thumbs.db diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 7e06d8248..a00e6baac 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -5,9 +5,9 @@ "typecheck": "tsgo --noEmit", "dev": "vinxi dev --host 0.0.0.0", "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", - "build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json", + "build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json", "start": "vinxi start", - "version": "1.0.23" + "version": "1.0.55" }, "dependencies": { "@ibm/plex": "6.4.1", diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts new file mode 100755 index 000000000..6cbffcb85 --- /dev/null +++ b/packages/console/app/script/generate-sitemap.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +import { readdir, writeFile } from "fs/promises" +import { join, dirname } from "path" +import { fileURLToPath } from "url" +import { config } from "../src/config.js" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const BASE_URL = config.baseUrl +const PUBLIC_DIR = join(__dirname, "../public") +const ROUTES_DIR = join(__dirname, "../src/routes") +const DOCS_DIR = join(__dirname, "../../../web/src/content/docs") + +interface SitemapEntry { + url: string + priority: number + changefreq: string +} + +async function getMainRoutes(): Promise<SitemapEntry[]> { + const routes: SitemapEntry[] = [] + + // Add main static routes + const staticRoutes = [ + { path: "/", priority: 1.0, changefreq: "daily" }, + { path: "/enterprise", priority: 0.8, changefreq: "weekly" }, + { path: "/brand", priority: 0.6, changefreq: "monthly" }, + { path: "/zen", priority: 0.8, changefreq: "weekly" }, + ] + + for (const route of staticRoutes) { + routes.push({ + url: `${BASE_URL}${route.path}`, + priority: route.priority, + changefreq: route.changefreq, + }) + } + + return routes +} + +async function getDocsRoutes(): Promise<SitemapEntry[]> { + const routes: SitemapEntry[] = [] + + try { + const files = await readdir(DOCS_DIR) + + for (const file of files) { + if (!file.endsWith(".mdx")) continue + + const slug = file.replace(".mdx", "") + const path = slug === "index" ? "/docs/" : `/docs/${slug}` + + routes.push({ + url: `${BASE_URL}${path}`, + priority: slug === "index" ? 0.9 : 0.7, + changefreq: "weekly", + }) + } + } catch (error) { + console.error("Error reading docs directory:", error) + } + + return routes +} + +function generateSitemapXML(entries: SitemapEntry[]): string { + const urls = entries + .map( + (entry) => ` <url> + <loc>${entry.url}</loc> + <changefreq>${entry.changefreq}</changefreq> + <priority>${entry.priority}</priority> + </url>`, + ) + .join("\n") + + return `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +${urls} +</urlset>` +} + +async function main() { + console.log("Generating sitemap...") + + const mainRoutes = await getMainRoutes() + const docsRoutes = await getDocsRoutes() + + const allRoutes = [...mainRoutes, ...docsRoutes] + + console.log(`Found ${mainRoutes.length} main routes`) + console.log(`Found ${docsRoutes.length} docs routes`) + console.log(`Total: ${allRoutes.length} routes`) + + const xml = generateSitemapXML(allRoutes) + + const outputPath = join(PUBLIC_DIR, "sitemap.xml") + await writeFile(outputPath, xml, "utf-8") + + console.log(`✓ Sitemap generated at ${outputPath}`) +} + +main() diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index bc3961214..1cf963642 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -12,7 +12,7 @@ export default function App() { root={(props) => ( <MetaProvider> <Title>opencode</Title> - <Meta name="description" content="opencode - The AI coding agent built for the terminal." /> + <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." /> <Suspense>{props.children}</Suspense> </MetaProvider> )} diff --git a/packages/console/app/src/component/dropdown.css b/packages/console/app/src/component/dropdown.css index 982367c6b..242940e6a 100644 --- a/packages/console/app/src/component/dropdown.css +++ b/packages/console/app/src/component/dropdown.css @@ -77,4 +77,4 @@ background-color: var(--color-accent-alpha); } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css index 23b6831c9..1f47f395d 100644 --- a/packages/console/app/src/component/modal.css +++ b/packages/console/app/src/component/modal.css @@ -63,4 +63,4 @@ font-weight: 600; color: var(--color-text); } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 097764b87..40108e968 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -2,6 +2,9 @@ * Application-wide constants and configuration */ export const config = { + // Base URL + baseUrl: "https://opencode.ai", + // GitHub github: { repoUrl: "https://github.com/sst/opencode", diff --git a/packages/console/app/src/lib/github.ts b/packages/console/app/src/lib/github.ts index dab317751..bc49d2e62 100644 --- a/packages/console/app/src/lib/github.ts +++ b/packages/console/app/src/lib/github.ts @@ -7,10 +7,7 @@ export const github = query(async () => { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", } - const apiBaseUrl = config.github.repoUrl.replace( - "https://github.com/", - "https://api.github.com/repos/", - ) + const apiBaseUrl = config.github.repoUrl.replace("https://github.com/", "https://api.github.com/repos/") try { const [meta, releases, contributors] = await Promise.all([ fetch(apiBaseUrl, { headers }).then((res) => res.json()), diff --git a/packages/console/app/src/routes/brand/index.css b/packages/console/app/src/routes/brand/index.css index 5a445f298..d3c0d0523 100644 --- a/packages/console/app/src/routes/brand/index.css +++ b/packages/console/app/src/routes/brand/index.css @@ -264,7 +264,7 @@ [data-component="brand-content"] { padding: 4rem 5rem; - h2 { + h1 { font-size: 1.5rem; font-weight: 500; color: var(--color-text-strong); @@ -299,7 +299,6 @@ transition: all 0.2s ease; text-decoration: none; - &:hover:not(:disabled) { background: var(--color-background-strong-hover); } @@ -385,23 +384,21 @@ 0 1px 2px -1px rgba(19, 16, 16, 0.12); @media (max-width: 40rem) { - box-shadow: - 0 0 0 1px rgba(19, 16, 16, 0.16) + box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.16); } - &:hover { background: var(--color-background); } &:active { transform: scale(0.98); - box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.08), 0 6px 8px -8px rgba(19, 16, 16, 0.50); + box-shadow: + 0 0 0 1px rgba(19, 16, 16, 0.08), + 0 6px 8px -8px rgba(19, 16, 16, 0.5); } } - - @media (max-width: 60rem) { padding: 2rem 1.5rem; } diff --git a/packages/console/app/src/routes/brand/index.tsx b/packages/console/app/src/routes/brand/index.tsx index 4570c73ca..6aac4517a 100644 --- a/packages/console/app/src/routes/brand/index.tsx +++ b/packages/console/app/src/routes/brand/index.tsx @@ -1,6 +1,7 @@ import "./index.css" -import { Title, Meta } from "@solidjs/meta" +import { Title, Meta, Link } from "@solidjs/meta" import { Header } from "~/component/header" +import { config } from "~/config" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png" @@ -53,26 +54,21 @@ export default function Brand() { return ( <main data-page="enterprise"> <Title>OpenCode | Brand</Title> + <Link rel="canonical" href={`${config.baseUrl}/brand`} /> <Meta name="description" content="OpenCode brand guidelines" /> <div data-component="container"> <Header /> <div data-component="content"> <section data-component="brand-content"> - <h2>Brand guidelines</h2> + <h1>Brand guidelines</h1> <p>Resources and assets to help you work with the OpenCode brand.</p> <button data-component="download-button" onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")} > Download all assets - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -88,13 +84,7 @@ export default function Brand() { <div data-component="actions"> <button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -105,13 +95,7 @@ export default function Brand() { </button> <button onClick={() => downloadFile(logoLightSvg, "opencode-logo-light.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -127,13 +111,7 @@ export default function Brand() { <div data-component="actions"> <button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -144,13 +122,7 @@ export default function Brand() { </button> <button onClick={() => downloadFile(logoDarkSvg, "opencode-logo-dark.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -164,17 +136,9 @@ export default function Brand() { <div> <img src={previewWordmarkLight} alt="OpenCode brand guidelines" /> <div data-component="actions"> - <button - onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")} - > + <button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -183,17 +147,9 @@ export default function Brand() { /> </svg> </button> - <button - onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")} - > + <button onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -207,17 +163,9 @@ export default function Brand() { <div> <img src={previewWordmarkDark} alt="OpenCode brand guidelines" /> <div data-component="actions"> - <button - onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")} - > + <button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -226,17 +174,9 @@ export default function Brand() { /> </svg> </button> - <button - onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")} - > + <button onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -250,19 +190,9 @@ export default function Brand() { <div> <img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" /> <div data-component="actions"> - <button - onClick={() => - downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png") - } - > + <button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -271,19 +201,9 @@ export default function Brand() { /> </svg> </button> - <button - onClick={() => - downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg") - } - > + <button onClick={() => downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -297,19 +217,9 @@ export default function Brand() { <div> <img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" /> <div data-component="actions"> - <button - onClick={() => - downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png") - } - > + <button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}> PNG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" @@ -318,19 +228,9 @@ export default function Brand() { /> </svg> </button> - <button - onClick={() => - downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg") - } - > + <button onClick={() => downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")}> SVG - <svg - width="20" - height="20" - viewBox="0 0 20 20" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75" stroke="currentColor" diff --git a/packages/console/app/src/routes/desktop-feedback.ts b/packages/console/app/src/routes/desktop-feedback.ts new file mode 100644 index 000000000..1916cdb4c --- /dev/null +++ b/packages/console/app/src/routes/desktop-feedback.ts @@ -0,0 +1,5 @@ +import { redirect } from "@solidjs/router" + +export async function GET() { + return redirect("https://discord.gg/h5TNnkFVNy") +} diff --git a/packages/console/app/src/routes/enterprise/index.css b/packages/console/app/src/routes/enterprise/index.css index 567c4f99d..0178e40a2 100644 --- a/packages/console/app/src/routes/enterprise/index.css +++ b/packages/console/app/src/routes/enterprise/index.css @@ -287,7 +287,7 @@ } [data-component="enterprise-column-1"] { - h2 { + h1 { font-size: 1.5rem; font-weight: 500; color: var(--color-text-strong); diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx index 4af0ccce8..095ed97a2 100644 --- a/packages/console/app/src/routes/enterprise/index.tsx +++ b/packages/console/app/src/routes/enterprise/index.tsx @@ -1,6 +1,7 @@ import "./index.css" -import { Title, Meta } from "@solidjs/meta" +import { Title, Meta, Link } from "@solidjs/meta" import { createSignal, Show } from "solid-js" +import { config } from "~/config" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" @@ -54,6 +55,7 @@ export default function Enterprise() { return ( <main data-page="enterprise"> <Title>OpenCode | Enterprise solutions for your organisation</Title> + <Link rel="canonical" href={`${config.baseUrl}/enterprise`} /> <Meta name="description" content="Contact OpenCode for enterprise solutions" /> <div data-component="container"> <Header /> @@ -62,41 +64,28 @@ export default function Enterprise() { <section data-component="enterprise-content"> <div data-component="enterprise-columns"> <div data-component="enterprise-column-1"> - <h2>Your code is yours</h2> + <h1>Your code is yours</h1> <p> - OpenCode operates securely inside your organization with no data or context stored - and no licensing restrictions or ownership claims. Start a trial with your team, - then deploy it across your organization by integrating it with your SSO and - internal AI gateway. + OpenCode operates securely inside your organization with no data or context stored and no licensing + restrictions or ownership claims. Start a trial with your team, then deploy it across your + organization by integrating it with your SSO and internal AI gateway. </p> <p>Let us know and how we can help.</p> <Show when={false}> <div data-component="testimonial"> <div data-component="quotation"> - <svg - width="20" - height="17" - viewBox="0 0 20 17" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z" fill="currentColor" /> </svg> </div> - Thanks to OpenCode, we found a way to create software to track all our assets — - even the imaginary ones. + Thanks to OpenCode, we found a way to create software to track all our assets — even the imaginary + ones. <div data-component="testimonial-logo"> - <svg - width="80" - height="79" - viewBox="0 0 80 79" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="80" height="79" viewBox="0 0 80 79" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" @@ -213,11 +202,7 @@ export default function Enterprise() { </button> </form> - {showSuccess() && ( - <div data-component="success-message"> - Message sent, we'll be in touch soon. - </div> - )} + {showSuccess() && <div data-component="success-message">Message sent, we'll be in touch soon.</div>} </div> </div> </div> @@ -230,31 +215,29 @@ export default function Enterprise() { <ul> <li> <Faq question="What is OpenCode Enterprise?"> - OpenCode Enterprise is for organizations that want to ensure that their code and - data never leaves their infrastructure. It can do this by using a centralized - config that integrates with your SSO and internal AI gateway. + OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves + their infrastructure. It can do this by using a centralized config that integrates with your SSO and + internal AI gateway. </Faq> </li> <li> <Faq question="How do I get started with OpenCode Enterprise?"> - Simply start with an internal trial with your team. OpenCode by default does not - store your code or context data, making it easy to get started. Then contact us to - discuss pricing and implementation options. + Simply start with an internal trial with your team. OpenCode by default does not store your code or + context data, making it easy to get started. Then contact us to discuss pricing and implementation + options. </Faq> </li> <li> <Faq question="How does enterprise pricing work?"> - We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not - charge for tokens used. For further details, contact us for a custom quote based - on your organization's needs. + We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens + used. For further details, contact us for a custom quote based on your organization's needs. </Faq> </li> <li> <Faq question="Is my data secure with OpenCode Enterprise?"> - Yes. OpenCode does not store your code or context data. All processing happens - locally or through direct API calls to your AI provider. With central config and - SSO integration, your data remains secure within your organization's - infrastructure. + Yes. OpenCode does not store your code or context data. All processing happens locally or through + direct API calls to your AI provider. With central config and SSO integration, your data remains + secure within your organization's infrastructure. </Faq> </li> </ul> diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css index 496901339..e5101442f 100644 --- a/packages/console/app/src/routes/index.css +++ b/packages/console/app/src/routes/index.css @@ -479,7 +479,7 @@ body { border-bottom: 1px solid var(--color-border-weak); } - strong { + h1 { font-size: 28px; color: var(--color-text-strong); font-weight: 500; diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index bcfb22bc3..8b8f44999 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -42,11 +42,9 @@ export default function Home() { return ( <main data-page="opencode"> - <HttpHeader - name="Cache-Control" - value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" - /> + <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" /> <Title>OpenCode | The AI coding agent built for the terminal</Title> + <Link rel="canonical" href={config.baseUrl} /> <Link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <Meta property="og:image" content="/social-share.png" /> <Meta name="twitter:image" content="/social-share.png" /> @@ -56,27 +54,17 @@ export default function Home() { <div data-component="content"> <section data-component="hero"> <div data-slot="hero-copy"> - <a - data-slot="releases" - href={release()?.url ?? `${config.github.repoUrl}/releases`} - target="_blank" - > + <a data-slot="releases" href={release()?.url ?? `${config.github.repoUrl}/releases`} target="_blank"> What’s new in {release()?.name ?? "the latest release"} </a> - <strong>The AI coding agent built for the terminal</strong> + <h1>The AI coding agent built for the terminal</h1> <p> - OpenCode is fully open source, giving you control and freedom to use any provider, - any model, and any editor. + OpenCode is fully open source, giving you control and freedom to use any provider, any model, and any + editor. </p> <a href="/docs"> <span>Read docs </span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5" stroke="currentColor" @@ -175,10 +163,7 @@ export default function Home() { <section data-component="what"> <div data-slot="section-title"> <h3>What is OpenCode?</h3> - <p> - OpenCode is an open source agent that helps you write and run code directly from the - terminal. - </p> + <p>OpenCode is an open source agent that helps you write and run code directly from the terminal.</p> </div> <ul> <li> @@ -196,8 +181,7 @@ export default function Home() { <li> <span>[*]</span> <div> - <strong>Multi-session</strong> Start multiple agents in parallel on the same - project + <strong>Multi-session</strong> Start multiple agents in parallel on the same project </div> </li> <li> @@ -209,15 +193,13 @@ export default function Home() { <li> <span>[*]</span> <div> - <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max - account + <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account </div> </li> <li> <span>[*]</span> <div> - <strong>Any model</strong> 75+ LLM providers through Models.dev, including local - models + <strong>Any model</strong> 75+ LLM providers through Models.dev, including local models </div> </li> <li> @@ -237,21 +219,15 @@ export default function Home() { <p> With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "} <strong>{config.stats.contributors}</strong> contributors, and almost{" "} - <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by - over <strong>{config.stats.monthlyUsers}</strong> developers every month. + <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "} + <strong>{config.stats.monthlyUsers}</strong> developers every month. </p> </div> <div data-component="growth-stats"> <div data-component="growth-stat"> <div data-component="stat-illustration"> - <svg - width="205" - height="264" - viewBox="0 0 205 264" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg"> <g opacity="0.5" clip-path="url(#clip0_236_15902)"> <mask id="mask0_236_15902" @@ -297,20 +273,13 @@ export default function Home() { </svg> </div> <span> - <figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong>{" "} - GitHub Stars + <figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong> GitHub Stars </span> </div> <div data-component="growth-stat"> <div data-component="stat-illustration"> - <svg - width="205" - height="264" - viewBox="0 0 205 264" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg"> <g opacity="0.5" clip-path="url(#clip0_236_15557)"> <g clip-path="url(#clip1_236_15557)"> <rect opacity="0.81" width="6" height="6" fill="#CFCECD" /> @@ -439,54 +408,12 @@ export default function Home() { <rect opacity="0.32" x="70" y="112" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.52" x="84" y="112" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.02" x="98" y="112" width="6" height="6" fill="#CFCECD" /> - <rect - opacity="0.88" - x="126" - y="112" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.12" - x="140" - y="112" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.93" - x="154" - y="112" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.79" - x="168" - y="112" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.24" - x="182" - y="112" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.64" - x="196" - y="112" - width="6" - height="6" - fill="#CFCECD" - /> + <rect opacity="0.88" x="126" y="112" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.12" x="140" y="112" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.93" x="154" y="112" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.79" x="168" y="112" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.24" x="182" y="112" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.64" x="196" y="112" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.57" y="126" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.6" x="14" y="126" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.05" x="28" y="126" width="6" height="6" fill="#BCBBBB" /> @@ -495,55 +422,13 @@ export default function Home() { <rect opacity="0.93" x="70" y="126" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.63" x="84" y="126" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.58" x="98" y="126" width="6" height="6" fill="#DAD9D9" /> - <rect - opacity="0.64" - x="112" - y="126" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.74" - x="126" - y="126" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.74" - x="140" - y="126" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.64" x="112" y="126" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.74" x="126" y="126" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.74" x="140" y="126" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.1" x="154" y="126" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.93" - x="168" - y="126" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.43" - x="182" - y="126" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.45" - x="196" - y="126" - width="6" - height="6" - fill="#BCBBBB" - /> + <rect opacity="0.93" x="168" y="126" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.43" x="182" y="126" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.45" x="196" y="126" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.77" y="140" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.78" x="14" y="140" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.18" x="28" y="140" width="6" height="6" fill="#DAD9D9" /> @@ -552,55 +437,13 @@ export default function Home() { <rect opacity="0.53" x="70" y="140" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.06" x="84" y="140" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.81" x="98" y="140" width="6" height="6" fill="#DAD9D9" /> - <rect - opacity="0.49" - x="112" - y="140" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.45" - x="126" - y="140" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.37" - x="140" - y="140" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.58" - x="154" - y="140" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.49" x="112" y="140" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.45" x="126" y="140" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.37" x="140" y="140" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.58" x="154" y="140" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.8" x="168" y="140" width="6" height="6" fill="#BCBBBB" /> - <rect - opacity="0.35" - x="182" - y="140" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.73" - x="196" - y="140" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.35" x="182" y="140" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.73" x="196" y="140" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.92" y="154" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.32" x="14" y="154" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.3" x="28" y="154" width="6" height="6" fill="#8E8B8B" /> @@ -609,47 +452,12 @@ export default function Home() { <rect opacity="0.66" x="70" y="154" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.83" x="84" y="154" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.52" x="98" y="154" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.82" - x="112" - y="154" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.95" - x="126" - y="154" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.89" - x="140" - y="154" - width="6" - height="6" - fill="#CFCECD" - /> + <rect opacity="0.82" x="112" y="154" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.95" x="126" y="154" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.89" x="140" y="154" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.2" x="154" y="154" width="6" height="6" fill="#BCBBBB" /> - <rect - opacity="0.61" - x="168" - y="154" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.34" - x="196" - y="154" - width="6" - height="6" - fill="#DAD9D9" - /> + <rect opacity="0.61" x="168" y="154" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.34" x="196" y="154" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.9" y="168" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.99" x="14" y="168" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.49" x="28" y="168" width="6" height="6" fill="#BCBBBB" /> @@ -658,55 +466,13 @@ export default function Home() { <rect opacity="0.92" x="70" y="168" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.79" x="84" y="168" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.8" x="98" y="168" width="6" height="6" fill="#BCBBBB" /> - <rect - opacity="0.74" - x="112" - y="168" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.38" - x="126" - y="168" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.56" - x="140" - y="168" - width="6" - height="6" - fill="#CFCECD" - /> + <rect opacity="0.74" x="112" y="168" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.38" x="126" y="168" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.56" x="140" y="168" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.7" x="154" y="168" width="6" height="6" fill="#DAD9D9" /> - <rect - opacity="0.47" - x="168" - y="168" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.92" - x="182" - y="168" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.19" - x="196" - y="168" - width="6" - height="6" - fill="#BCBBBB" - /> + <rect opacity="0.47" x="168" y="168" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.92" x="182" y="168" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.19" x="196" y="168" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.12" y="182" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.16" x="14" y="182" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.98" x="28" y="182" width="6" height="6" fill="#8E8B8B" /> @@ -715,55 +481,13 @@ export default function Home() { <rect opacity="0.17" x="70" y="182" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.26" x="84" y="182" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.3" x="98" y="182" width="6" height="6" fill="#DAD9D9" /> - <rect - opacity="0.12" - x="112" - y="182" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.31" - x="126" - y="182" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.62" - x="140" - y="182" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.74" - x="154" - y="182" - width="6" - height="6" - fill="#DAD9D9" - /> + <rect opacity="0.12" x="112" y="182" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.31" x="126" y="182" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.62" x="140" y="182" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.74" x="154" y="182" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.8" x="168" y="182" width="6" height="6" fill="#CFCECD" /> - <rect - opacity="0.89" - x="182" - y="182" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.75" - x="196" - y="182" - width="6" - height="6" - fill="#DAD9D9" - /> + <rect opacity="0.89" x="182" y="182" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.75" x="196" y="182" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.1" y="196" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.11" x="14" y="196" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.79" x="28" y="196" width="6" height="6" fill="#BCBBBB" /> @@ -772,62 +496,13 @@ export default function Home() { <rect opacity="0.31" x="70" y="196" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.33" x="84" y="196" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.2" x="98" y="196" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.21" - x="112" - y="196" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.02" - x="126" - y="196" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.82" - x="140" - y="196" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.28" - x="154" - y="196" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.19" - x="168" - y="196" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.97" - x="182" - y="196" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.45" - x="196" - y="196" - width="6" - height="6" - fill="#DAD9D9" - /> + <rect opacity="0.21" x="112" y="196" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.02" x="126" y="196" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.82" x="140" y="196" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.28" x="154" y="196" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.19" x="168" y="196" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.97" x="182" y="196" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.45" x="196" y="196" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.88" y="210" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.58" x="14" y="210" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.53" x="28" y="210" width="6" height="6" fill="#BCBBBB" /> @@ -836,55 +511,13 @@ export default function Home() { <rect opacity="0.73" x="70" y="210" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.87" x="84" y="210" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.35" x="98" y="210" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.61" - x="112" - y="210" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.61" x="112" y="210" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.8" x="126" y="210" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.87" - x="140" - y="210" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.77" - x="154" - y="210" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.94" - x="168" - y="210" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.59" - x="182" - y="210" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.37" - x="196" - y="210" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.87" x="140" y="210" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.77" x="154" y="210" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.94" x="168" y="210" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.59" x="182" y="210" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.37" x="196" y="210" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.7" y="224" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.72" x="14" y="224" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.95" x="28" y="224" width="6" height="6" fill="#CFCECD" /> @@ -894,54 +527,12 @@ export default function Home() { <rect opacity="0.2" x="84" y="224" width="6" height="6" fill="#BCBBBB" /> <rect opacity="0.63" x="98" y="224" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.5" x="112" y="224" width="6" height="6" fill="#8E8B8B" /> - <rect - opacity="0.79" - x="126" - y="224" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.02" - x="140" - y="224" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.17" - x="154" - y="224" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.99" - x="168" - y="224" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.82" - x="182" - y="224" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.28" - x="196" - y="224" - width="6" - height="6" - fill="#DAD9D9" - /> + <rect opacity="0.79" x="126" y="224" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.02" x="140" y="224" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.17" x="154" y="224" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.99" x="168" y="224" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.82" x="182" y="224" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.28" x="196" y="224" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.76" y="238" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.39" x="14" y="238" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.14" x="28" y="238" width="6" height="6" fill="#8E8B8B" /> @@ -950,62 +541,13 @@ export default function Home() { <rect opacity="0.13" x="70" y="238" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.35" x="84" y="238" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.13" x="98" y="238" width="6" height="6" fill="#BCBBBB" /> - <rect - opacity="0.55" - x="112" - y="238" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.83" - x="126" - y="238" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.86" - x="140" - y="238" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.63" - x="154" - y="238" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.38" - x="168" - y="238" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.57" - x="182" - y="238" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.13" - x="196" - y="238" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.55" x="112" y="238" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.83" x="126" y="238" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.86" x="140" y="238" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.63" x="154" y="238" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.38" x="168" y="238" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.57" x="182" y="238" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.13" x="196" y="238" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.9" y="252" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.63" x="14" y="252" width="6" height="6" fill="#CFCECD" /> <rect opacity="0.23" x="28" y="252" width="6" height="6" fill="#8E8B8B" /> @@ -1014,54 +556,12 @@ export default function Home() { <rect opacity="0.19" x="70" y="252" width="6" height="6" fill="#DAD9D9" /> <rect opacity="0.29" x="84" y="252" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.78" x="98" y="252" width="6" height="6" fill="#BCBBBB" /> - <rect - opacity="0.14" - x="112" - y="252" - width="6" - height="6" - fill="#BCBBBB" - /> - <rect - opacity="0.64" - x="126" - y="252" - width="6" - height="6" - fill="#8E8B8B" - /> - <rect - opacity="0.27" - x="140" - y="252" - width="6" - height="6" - fill="#CFCECD" - /> - <rect - opacity="0.85" - x="154" - y="252" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.02" - x="168" - y="252" - width="6" - height="6" - fill="#DAD9D9" - /> - <rect - opacity="0.29" - x="182" - y="252" - width="6" - height="6" - fill="#8E8B8B" - /> + <rect opacity="0.14" x="112" y="252" width="6" height="6" fill="#BCBBBB" /> + <rect opacity="0.64" x="126" y="252" width="6" height="6" fill="#8E8B8B" /> + <rect opacity="0.27" x="140" y="252" width="6" height="6" fill="#CFCECD" /> + <rect opacity="0.85" x="154" y="252" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.02" x="168" y="252" width="6" height="6" fill="#DAD9D9" /> + <rect opacity="0.29" x="182" y="252" width="6" height="6" fill="#8E8B8B" /> <rect opacity="0.4" x="196" y="252" width="6" height="6" fill="#8E8B8B" /> </g> </g> @@ -1070,31 +570,19 @@ export default function Home() { <rect width="205" height="264" fill="white" /> </clipPath> <clipPath id="clip1_236_15557"> - <rect - width="236" - height="264" - fill="white" - transform="translate(-0.164062)" - /> + <rect width="236" height="264" fill="white" transform="translate(-0.164062)" /> </clipPath> </defs> </svg> </div> <span> - <figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong>{" "} - Contributors + <figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong> Contributors </span> </div> <div data-component="growth-stat"> <div data-component="stat-illustration"> - <svg - width="205" - height="264" - viewBox="0 0 205 264" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg"> <g opacity="0.5"> <path d="M205 0H203.985V264H205V0Z" fill="#8E8B8B" /> <path d="M197.896 34H196.881V264H197.896V34Z" fill="#8E8B8B" /> @@ -1130,8 +618,7 @@ export default function Home() { </svg> </div> <span> - <figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly - Devs + <figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly Devs </span> </div> </div> @@ -1145,9 +632,8 @@ export default function Home() { <span>[*]</span> <p> - OpenCode does not store any of your code or context data, so that it can operate - in privacy sensitive environments. Learn more about{" "} - <a href="/docs/enterprise/ ">privacy</a>. + OpenCode does not store any of your code or context data, so that it can operate in privacy sensitive + environments. Learn more about <a href="/docs/enterprise/ ">privacy</a>. </p> </div> </div> @@ -1160,9 +646,9 @@ export default function Home() { <ul> <li> <Faq question="What is OpenCode?"> - OpenCode is an open source agent that helps you write and run code directly from - the terminal. You can pair OpenCode with any AI model, and because it’s - terminal-based you can pair it with your preferred code editor. + OpenCode is an open source agent that helps you write and run code directly from the terminal. You can + pair OpenCode with any AI model, and because it’s terminal-based you can pair it with your preferred + code editor. </Faq> </li> <li> @@ -1172,32 +658,30 @@ export default function Home() { </li> <li> <Faq question="Do I need extra AI subscriptions to use OpenCode?"> - Not necessarily, but probably. You’ll need an AI subscription if you want to - connect OpenCode to a paid provider, although you can work with{" "} + Not necessarily, but probably. You’ll need an AI subscription if you want to connect OpenCode to a + paid provider, although you can work with{" "} <a href="/docs/providers/#lm-studio" target="_blank"> local models </a>{" "} - for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works - with all popular providers such as OpenAI, Anthropic, xAI etc. + for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works with all popular + providers such as OpenAI, Anthropic, xAI etc. </Faq> </li> <li> <Faq question="Can I only use OpenCode in the terminal?"> - Yes, for now. We are actively working on a desktop app. Join the waitlist for - early access. + Yes, for now. We are actively working on a desktop app. Join the waitlist for early access. </Faq> </li> <li> <Faq question="How much does OpenCode cost?"> - OpenCode is 100% free to use. Any additional costs will come from your - subscription to a model provider. While OpenCode works with any model provider, we - recommend using <A href="/zen">Zen</A>. + OpenCode is 100% free to use. Any additional costs will come from your subscription to a model + provider. While OpenCode works with any model provider, we recommend using <A href="/zen">Zen</A>. </Faq> </li> <li> <Faq question="What about data and privacy?"> - Your data and information is only stored when you create sharable links in - OpenCode. Learn more about <a href="/docs/share/#privacy">share pages</a>. + Your data and information is only stored when you create sharable links in OpenCode. Learn more about{" "} + <a href="/docs/share/#privacy">share pages</a>. </Faq> </li> <li> @@ -1210,8 +694,8 @@ export default function Home() { <a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank"> MIT License </a> - , meaning anyone can use, modify, or contribute to its development. Anyone from - the community can file issues, submit pull requests, and extend functionality. + , meaning anyone can use, modify, or contribute to its development. Anyone from the community can file + issues, submit pull requests, and extend functionality. </Faq> </li> </ul> @@ -1221,19 +705,13 @@ export default function Home() { <div data-slot="zen-cta-copy"> <strong>Access reliable optimized models for coding agents</strong> <p> - Zen gives you access to a handpicked set of AI models that OpenCode has tested and - benchmarked specifically for coding agents. No need to worry about inconsistent - performance and quality across providers, use validated models that work. + Zen gives you access to a handpicked set of AI models that OpenCode has tested and benchmarked + specifically for coding agents. No need to worry about inconsistent performance and quality across + providers, use validated models that work. </p> <div data-slot="model-logos"> <div> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <mask id="mask0_79_128586" style="mask-type:luminance" @@ -1254,17 +732,8 @@ export default function Home() { </svg> </div> <div> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z" - fill="currentColor" - /> + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z" fill="currentColor" /> <path d="M6.32538 13.6824L8.52662 8.01177L10.7279 13.6824H6.32538ZM6.68225 3.93164L0.25 20.0677H3.84652L5.16202 16.6791H11.8914L13.2067 20.0677H16.8033L10.371 3.93164H6.68225Z" fill="currentColor" @@ -1272,13 +741,7 @@ export default function Home() { </svg> </div> <div> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M9.16861 16.0529L17.2018 9.85156C17.5957 9.54755 18.1586 9.66612 18.3463 10.1384C19.3339 12.6288 18.8926 15.6217 16.9276 17.6766C14.9626 19.7314 12.2285 20.1821 9.72948 19.1557L6.9995 20.4775C10.9151 23.2763 15.6699 22.5841 18.6411 19.4749C20.9979 17.0103 21.7278 13.6508 21.0453 10.6214L21.0515 10.6278C20.0617 6.17736 21.2948 4.39847 23.8207 0.760904C23.8804 0.674655 23.9402 0.588405 24 0.5L20.6762 3.97585V3.96506L9.16658 16.0551" fill="currentColor" @@ -1290,13 +753,7 @@ export default function Home() { </svg> </div> <div> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" @@ -1306,13 +763,7 @@ export default function Home() { </svg> </div> <div> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M12.6241 11.346L20.3848 3.44816C20.5309 3.29931 20.4487 3 20.2601 3H16.0842C16.0388 3 15.9949 3.01897 15.9594 3.05541L7.59764 11.5629C7.46721 11.6944 7.27446 11.5771 7.27446 11.3666V3.25183C7.27446 3.11242 7.18515 3 7.07594 3H4.19843C4.08932 3 4 3.11242 4 3.25183V20.7482C4 20.8876 4.08932 21 4.19843 21H7.07594C7.18515 21 7.27446 20.8876 7.27446 20.7482V17.1834C7.27446 17.1073 7.30136 17.0344 7.34815 16.987L9.94075 14.3486C10.0031 14.2853 10.0895 14.2757 10.159 14.3232L17.0934 19.5573C18.2289 20.3412 19.4975 20.8226 20.786 20.9652C20.9008 20.9778 21 20.8606 21 20.7133V17.3559C21 17.2276 20.9249 17.1232 20.8243 17.1073C20.0659 16.9853 19.326 16.6845 18.6569 16.222L12.6538 11.764C12.5291 11.6785 12.5135 11.4584 12.6241 11.346Z" fill="currentColor" @@ -1322,13 +773,7 @@ export default function Home() { </div> <A href="/zen"> <span>Learn about Zen </span> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5" stroke="currentColor" diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index cc44f8674..3260f31b2 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -13,146 +13,144 @@ export async function POST(input: APIEvent) { input.request.headers.get("stripe-signature")!, Resource.STRIPE_WEBHOOK_SECRET.value, ) - console.log(body.type, JSON.stringify(body, null, 2)) - if (body.type === "customer.updated") { - // check default payment method changed - const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {} - if (!("default_payment_method" in prevInvoiceSettings)) return - const customerID = body.data.object.id - const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string + return (async () => { + if (body.type === "customer.updated") { + // check default payment method changed + const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {} + if (!("default_payment_method" in prevInvoiceSettings)) return "ignored" - if (!customerID) throw new Error("Customer ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") + const customerID = body.data.object.id + const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) - await Database.use(async (tx) => { - await tx - .update(BillingTable) - .set({ - paymentMethodID, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .where(eq(BillingTable.customerID, customerID)) - }) - } - if (body.type === "checkout.session.completed") { - const workspaceID = body.data.object.metadata?.workspaceID - const customerID = body.data.object.customer as string - const paymentID = body.data.object.payment_intent as string - const invoiceID = body.data.object.invoice as string - const amount = body.data.object.amount_total + if (!customerID) throw new Error("Customer ID not found") + if (!paymentMethodID) throw new Error("Payment method ID not found") - if (!workspaceID) throw new Error("Workspace ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!amount) throw new Error("Amount not found") - if (!paymentID) throw new Error("Payment ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - - await Actor.provide("system", { workspaceID }, async () => { - const customer = await Billing.get() - if (customer?.customerID && customer.customerID !== customerID) - throw new Error("Customer ID mismatch") + const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) + await Database.use(async (tx) => { + await tx + .update(BillingTable) + .set({ + paymentMethodID, + paymentMethodLast4: paymentMethod.card?.last4 ?? null, + paymentMethodType: paymentMethod.type, + }) + .where(eq(BillingTable.customerID, customerID)) + }) + } + if (body.type === "checkout.session.completed") { + const workspaceID = body.data.object.metadata?.workspaceID + const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) + const customerID = body.data.object.customer as string + const paymentID = body.data.object.payment_intent as string + const invoiceID = body.data.object.invoice as string + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!customerID) throw new Error("Customer ID not found") + if (!amountInCents) throw new Error("Amount not found") + if (!paymentID) throw new Error("Payment ID not found") + if (!invoiceID) throw new Error("Invoice ID not found") + + await Actor.provide("system", { workspaceID }, async () => { + const customer = await Billing.get() + if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") + + // set customer metadata + if (!customer?.customerID) { + await Billing.stripe().customers.update(customerID, { + metadata: { + workspaceID, + }, + }) + } - // set customer metadata - if (!customer?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { + // get payment method for the payment intent + const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { + expand: ["payment_method"], + }) + const paymentMethod = paymentIntent.payment_method + if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded") + + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, + customerID, + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card?.last4 ?? null, + paymentMethodType: paymentMethod.type, + // enable reload if first time enabling billing + ...(customer?.customerID + ? {} + : { + reload: true, + reloadError: null, + timeReloadError: null, + }), + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + await tx.insert(PaymentTable).values({ workspaceID, - }, + id: Identifier.create("payment"), + amount: centsToMicroCents(amountInCents), + paymentID, + invoiceID, + customerID, + }) }) - } - - // get payment method for the payment intent - const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { - expand: ["payment_method"], }) - const paymentMethod = paymentIntent.payment_method - if (!paymentMethod || typeof paymentMethod === "string") - throw new Error("Payment method not expanded") - - const oldBillingInfo = await Database.use((tx) => + } + if (body.type === "charge.refunded") { + const customerID = body.data.object.customer as string + const paymentIntentID = body.data.object.payment_intent as string + if (!customerID) throw new Error("Customer ID not found") + if (!paymentIntentID) throw new Error("Payment ID not found") + + const workspaceID = await Database.use((tx) => tx .select({ - customerID: BillingTable.customerID, + workspaceID: BillingTable.workspaceID, }) .from(BillingTable) - .where(eq(BillingTable.workspaceID, workspaceID)) - .then((rows) => rows[0]), + .where(eq(BillingTable.customerID, customerID)) + .then((rows) => rows[0]?.workspaceID), ) + if (!workspaceID) throw new Error("Workspace ID not found") + + const amount = await Database.use((tx) => + tx + .select({ + amount: PaymentTable.amount, + }) + .from(PaymentTable) + .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) + .then((rows) => rows[0]?.amount), + ) + if (!amount) throw new Error("Payment not found") await Database.transaction(async (tx) => { await tx + .update(PaymentTable) + .set({ + timeRefunded: new Date(body.created * 1000), + }) + .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) + + await tx .update(BillingTable) .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`, - customerID, - paymentMethodID: paymentMethod.id, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - // enable reload if first time enabling billing - ...(oldBillingInfo?.customerID - ? {} - : { - reload: true, - reloadError: null, - timeReloadError: null, - }), + balance: sql`${BillingTable.balance} - ${amount}`, }) .where(eq(BillingTable.workspaceID, workspaceID)) - await tx.insert(PaymentTable).values({ - workspaceID, - id: Identifier.create("payment"), - amount: centsToMicroCents(Billing.CHARGE_AMOUNT), - paymentID, - invoiceID, - customerID, - }) }) + } + })() + .then((message) => { + return Response.json({ message: message ?? "done" }, { status: 200 }) }) - } - if (body.type === "charge.refunded") { - const customerID = body.data.object.customer as string - const paymentIntentID = body.data.object.payment_intent as string - if (!customerID) throw new Error("Customer ID not found") - if (!paymentIntentID) throw new Error("Payment ID not found") - - const workspaceID = await Database.use((tx) => - tx - .select({ - workspaceID: BillingTable.workspaceID, - }) - .from(BillingTable) - .where(eq(BillingTable.customerID, customerID)) - .then((rows) => rows[0]?.workspaceID), - ) - if (!workspaceID) throw new Error("Workspace ID not found") - - await Database.transaction(async (tx) => { - await tx - .update(PaymentTable) - .set({ - timeRefunded: new Date(body.created * 1000), - }) - .where( - and( - eq(PaymentTable.paymentID, paymentIntentID), - eq(PaymentTable.workspaceID, workspaceID), - ), - ) - - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) + .catch((error: any) => { + return Response.json({ message: error.message }, { status: 500 }) }) - } - - console.log("finished handling") - - return Response.json("ok", { status: 200 }) } diff --git a/packages/console/app/src/routes/temp.tsx b/packages/console/app/src/routes/temp.tsx index 59987e4d0..b0aef00e7 100644 --- a/packages/console/app/src/routes/temp.tsx +++ b/packages/console/app/src/routes/temp.tsx @@ -79,19 +79,17 @@ export default function Home() { <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM </li> <li> - <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a>{" "} - provided by opencode <label>New</label> + <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "} + <label>New</label> </li> <li> <strong>Multi-session</strong> Start multiple agents in parallel on the same project </li> <li> - <strong>Shareable links</strong> Share a link to any sessions for reference or to - debug + <strong>Shareable links</strong> Share a link to any sessions for reference or to debug </li> <li> - <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max - account + <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account </li> <li> <strong>Use any model</strong> Supports 75+ LLM providers through{" "} diff --git a/packages/console/app/src/routes/user-menu.css b/packages/console/app/src/routes/user-menu.css index 15700579a..21008e98d 100644 --- a/packages/console/app/src/routes/user-menu.css +++ b/packages/console/app/src/routes/user-menu.css @@ -14,4 +14,4 @@ color: var(--color-danger); } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css index ab7f5be66..dd0aafaf5 100644 --- a/packages/console/app/src/routes/workspace-picker.css +++ b/packages/console/app/src/routes/workspace-picker.css @@ -71,4 +71,4 @@ color: var(--color-text-muted); } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css index e8f12796e..ddb13ab74 100644 --- a/packages/console/app/src/routes/workspace.css +++ b/packages/console/app/src/routes/workspace.css @@ -104,4 +104,4 @@ } } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css index e0a80ef74..24b405391 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css @@ -71,6 +71,57 @@ flex: 1; } + [data-slot="add-balance-form-container"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="add-balance-form"] { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--space-3); + + label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-muted); + white-space: nowrap; + } + + input[data-component="input"] { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; + + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + } + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + [data-slot="credit-card"] { padding: var(--space-2) var(--space-4); background-color: var(--color-bg-surface); @@ -131,4 +182,4 @@ padding: var(--space-4); min-width: 150px; } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index c0723136b..fe4e08b7c 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -1,24 +1,86 @@ -import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" -import { createMemo, Match, Show, Switch } from "solid-js" +import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router" +import { createMemo, Match, Show, Switch, createEffect } from "solid-js" +import { createStore } from "solid-js/store" import { Billing } from "@opencode-ai/console-core/billing.js" import { withActor } from "~/context/auth.withActor" import { IconCreditCard, IconStripe } from "~/component/icon" import styles from "./billing-section.module.css" -import { createCheckoutUrl, queryBillingInfo } from "../../common" +import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { "use server" - return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID) + return json( + await withActor( + () => + Billing.generateSessionUrl({ returnUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + { revalidate: queryBillingInfo.key }, + ) }, "sessionUrl") export function BillingSection() { const params = useParams() // ORIGINAL CODE - COMMENTED OUT FOR TESTING - const balanceInfo = createAsync(() => queryBillingInfo(params.id)) - const createCheckoutUrlAction = useAction(createCheckoutUrl) - const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) - const createSessionUrlAction = useAction(createSessionUrl) - const createSessionUrlSubmission = useSubmission(createSessionUrl) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) + const checkoutAction = useAction(createCheckoutUrl) + const checkoutSubmission = useSubmission(createCheckoutUrl) + const sessionAction = useAction(createSessionUrl) + const sessionSubmission = useSubmission(createSessionUrl) + const [store, setStore] = createStore({ + showAddBalanceForm: false, + addBalanceAmount: billingInfo()?.reloadAmount.toString() ?? "", + checkoutRedirecting: false, + sessionRedirecting: false, + }) + + createEffect(() => { + const info = billingInfo() + if (info) { + setStore("addBalanceAmount", info.reloadAmount.toString()) + } + }) + const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0)) + + async function onClickCheckout() { + const amount = parseInt(store.addBalanceAmount) + const baseUrl = window.location.href + + const checkout = await checkoutAction(params.id, amount, baseUrl, baseUrl) + if (checkout && checkout.data) { + setStore("checkoutRedirecting", true) + window.location.href = checkout.data + } + } + + async function onClickSession() { + const baseUrl = window.location.href + const sessionUrl = await sessionAction(params.id, baseUrl) + if (sessionUrl && sessionUrl.data) { + setStore("sessionRedirecting", true) + window.location.href = sessionUrl.data + } + } + + function showAddBalanceForm() { + while (true) { + checkoutSubmission.clear() + if (!checkoutSubmission.result) break + } + setStore({ + showAddBalanceForm: true, + }) + } + + function hideAddBalanceForm() { + setStore("showAddBalanceForm", false) + checkoutSubmission.clear() + } // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW @@ -72,97 +134,104 @@ export function BillingSection() { // timeReloadError: null as Date | null // }) - const balanceAmount = createMemo(() => { - return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2) - }) - return ( <section class={styles.root}> <div data-slot="section-title"> <h2>Billing</h2> <p> - Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any - questions. + Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions. </p> </div> <div data-slot="section-content"> <div data-slot="balance-display"> <div data-slot="balance-amount"> - <span data-slot="balance-value"> - ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} - </span> + <span data-slot="balance-value">${balance()}</span> <span data-slot="balance-label">Current Balance</span> </div> - <Show when={balanceInfo()?.customerID}> + <Show when={billingInfo()?.customerID}> <div data-slot="balance-right-section"> - <button - data-color="primary" - disabled={createCheckoutUrlSubmission.pending} - onClick={async () => { - const baseUrl = window.location.href - const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) - if (checkoutUrl) { - window.location.href = checkoutUrl - } - }} + <Show + when={!store.showAddBalanceForm} + fallback={ + <div data-slot="add-balance-form-container"> + <div data-slot="add-balance-form"> + <label>Add $</label> + <input + data-component="input" + type="number" + min={billingInfo()?.reloadAmountMin.toString()} + step="1" + value={store.addBalanceAmount} + onInput={(e) => { + setStore("addBalanceAmount", e.currentTarget.value) + checkoutSubmission.clear() + }} + placeholder="Enter amount" + /> + <div data-slot="form-actions"> + <button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}> + Cancel + </button> + <button + data-color="primary" + type="button" + disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting} + onClick={onClickCheckout} + > + {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"} + </button> + </div> + </div> + <Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}> + {(err: any) => <div data-slot="form-error">{err()}</div>} + </Show> + </div> + } > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"} - </button> + <button data-color="primary" onClick={() => showAddBalanceForm()}> + Add Balance + </button> + </Show> <div data-slot="credit-card"> <div data-slot="card-icon"> <Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}> - <Match when={balanceInfo()?.paymentMethodType === "link"}> + <Match when={billingInfo()?.paymentMethodType === "link"}> <IconStripe style={{ width: "24px", height: "24px" }} /> </Match> </Switch> </div> <div data-slot="card-details"> <Switch> - <Match when={balanceInfo()?.paymentMethodType === "card"}> - <Show - when={balanceInfo()?.paymentMethodLast4} - fallback={<span data-slot="number">----</span>} - > + <Match when={billingInfo()?.paymentMethodType === "card"}> + <Show when={billingInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}> <span data-slot="secret">••••</span> - <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span> + <span data-slot="number">{billingInfo()?.paymentMethodLast4}</span> </Show> </Match> - <Match when={balanceInfo()?.paymentMethodType === "link"}> + <Match when={billingInfo()?.paymentMethodType === "link"}> <span data-slot="type">Linked to Stripe</span> </Match> </Switch> </div> <button data-color="ghost" - disabled={createSessionUrlSubmission.pending} - onClick={async () => { - const baseUrl = window.location.href - const sessionUrl = await createSessionUrlAction(params.id, baseUrl) - if (sessionUrl) { - window.location.href = sessionUrl - } - }} + disabled={sessionSubmission.pending || store.sessionRedirecting} + onClick={onClickSession} > - {createSessionUrlSubmission.pending ? "Loading..." : "Manage"} + {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"} </button> </div> </div> </Show> </div> - <Show when={!balanceInfo()?.customerID}> + <Show when={!billingInfo()?.customerID}> <button data-slot="enable-billing-button" data-color="primary" - disabled={createCheckoutUrlSubmission.pending} - onClick={async () => { - const baseUrl = window.location.href - const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) - if (checkoutUrl) { - window.location.href = checkoutUrl - } - }} + disabled={checkoutSubmission.pending || store.checkoutRedirecting} + onClick={onClickCheckout} > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} + {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"} </button> </Show> </div> diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css index 4f0f8b2e6..a45bbf1a6 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css @@ -93,4 +93,4 @@ margin: 0; line-height: 1.4; } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index dbeda115c..77c017964 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx @@ -1,16 +1,10 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" import { createEffect, Show } from "solid-js" import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import styles from "./monthly-limit-section.module.css" - -const getBillingInfo = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return await Billing.get() - }, workspaceID) -}, "billing.get") +import { queryBillingInfo } from "../../common" const setMonthlyLimit = action(async (form: FormData) => { "use server" @@ -28,7 +22,7 @@ const setMonthlyLimit = action(async (form: FormData) => { .catch((e) => ({ error: e.message as string })), workspaceID, ), - { revalidate: getBillingInfo.key }, + { revalidate: queryBillingInfo.key }, ) }, "billing.setMonthlyLimit") @@ -36,7 +30,7 @@ export function MonthlyLimitSection() { const params = useParams() const submission = useSubmission(setMonthlyLimit) const [store, setStore] = createStore({ show: false }) - const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) let input: HTMLInputElement @@ -68,13 +62,13 @@ export function MonthlyLimitSection() { <section class={styles.root}> <div data-slot="section-title"> <h2>Monthly Limit</h2> - <p>Set a monthly spending limit for your account.</p> + <p>Set a monthly usage limit for your account.</p> </div> <div data-slot="section-content"> <div data-slot="balance"> <div data-slot="amount"> - {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null} - <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span> + {billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null} + <span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span> </div> <Show when={!store.show} @@ -106,15 +100,15 @@ export function MonthlyLimitSection() { } > <button data-color="primary" onClick={() => show()}> - {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"} + {billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"} </button> </Show> </div> - <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}> + <Show when={billingInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No usage limit set.</p>}> <p data-slot="usage-status"> Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $ {(() => { - const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated + const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated if (!dateLastUsed) return "0" const current = new Date().toLocaleDateString("en-US", { @@ -128,7 +122,7 @@ export function MonthlyLimitSection() { timeZone: "UTC", }) if (current !== lastUsed) return "0" - return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) + return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) })()} . </p> diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css index 08fb8524b..e37bad696 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css @@ -34,6 +34,206 @@ } } + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + margin-top: var(--space-4); + + [data-slot="form-field"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + + label { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="field-label"] { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-muted); + } + + [data-slot="toggle-container"] { + display: flex; + align-items: center; + } + + input[data-component="input"] { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + } + + [data-slot="input-row"] { + display: flex; + flex-direction: row; + gap: var(--space-3); + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-2); + } + } + + [data-slot="input-field"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1; + + p { + line-height: 1.2; + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + + input[data-component="input"] { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; + min-width: 0; + + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); + } + + &::placeholder { + color: var(--color-text-disabled); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--color-bg-surface); + } + } + + [data-slot="field-with-connector"] { + display: flex; + align-items: center; + gap: var(--space-2); + + [data-slot="field-connector"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + white-space: nowrap; + } + + input[data-component="input"] { + flex: 1; + min-width: 80px; + } + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + margin-top: var(--space-1); + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + margin-top: calc(var(--space-1) * -1); + } + + [data-slot="model-toggle-label"] { + position: relative; + display: inline-block; + width: 2.5rem; + height: 1.5rem; + cursor: pointer; + + input { + opacity: 0; + width: 0; + height: 0; + } + + span { + position: absolute; + inset: 0; + background-color: #ccc; + border: 1px solid #bbb; + border-radius: 1.5rem; + transition: all 0.3s ease; + cursor: pointer; + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0.125rem; + width: 1.25rem; + height: 1.25rem; + background-color: white; + border: 1px solid #ddd; + border-radius: 50%; + transform: translateY(-50%); + transition: all 0.3s ease; + } + } + + input:checked + span { + background-color: #21ad0e; + border-color: #148605; + + &::before { + transform: translateX(1rem) translateY(-50%); + } + } + + &:hover span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + } + + input:checked:hover + span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3); + } + + &:has(input:disabled) { + cursor: not-allowed; + } + + input:disabled + span { + opacity: 0.5; + cursor: not-allowed; + } + } + } + [data-slot="reload-error"] { display: flex; align-items: center; @@ -54,7 +254,8 @@ gap: var(--space-2); margin: 0; flex-shrink: 0; + padding: 0; + border: none; } } } - diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index 6be6ddf31..8dcc4da92 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -1,17 +1,19 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { Show } from "solid-js" +import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" import styles from "./reload-section.module.css" +import { queryBillingInfo } from "../../common" const reload = action(async (form: FormData) => { "use server" const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } return json(await withActor(() => Billing.reload(), workspaceID), { - revalidate: getBillingInfo.key, + revalidate: queryBillingInfo.key, }) }, "billing.reload") @@ -20,12 +22,27 @@ const setReload = action(async (form: FormData) => { const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } const reloadValue = form.get("reload")?.toString() === "true" + const amountStr = form.get("reloadAmount")?.toString() + const triggerStr = form.get("reloadTrigger")?.toString() + + const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null + const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null + + if (reloadValue) { + if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN) + return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` } + if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN) + return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` } + } + return json( await Database.use((tx) => tx .update(BillingTable) .set({ reload: reloadValue, + ...(reloadAmount !== null ? { reloadAmount } : {}), + ...(reloadTrigger !== null ? { reloadTrigger } : {}), ...(reloadValue ? { reloadError: null, @@ -35,22 +52,43 @@ const setReload = action(async (form: FormData) => { }) .where(eq(BillingTable.workspaceID, workspaceID)), ), - { revalidate: getBillingInfo.key }, + { revalidate: queryBillingInfo.key }, ) }, "billing.setReload") -const getBillingInfo = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return await Billing.get() - }, workspaceID) -}, "billing.get") - export function ReloadSection() { const params = useParams() - const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) const setReloadSubmission = useSubmission(setReload) const reloadSubmission = useSubmission(reload) + const [store, setStore] = createStore({ + show: false, + reload: false, + reloadAmount: "", + reloadTrigger: "", + }) + + createEffect(() => { + if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) { + setStore("show", false) + } + }) + + function show() { + while (true) { + setReloadSubmission.clear() + if (!setReloadSubmission.result) break + } + const info = billingInfo()! + setStore("show", true) + setStore("reload", info.reload ? true : true) + setStore("reloadAmount", info.reloadAmount.toString()) + setStore("reloadTrigger", info.reloadTrigger.toString()) + } + + function hide() { + setStore("show", false) + } return ( <section class={styles.root}> @@ -58,44 +96,102 @@ export function ReloadSection() { <h2>Auto Reload</h2> <div data-slot="title-row"> <Show - when={balanceInfo()?.reload} + when={billingInfo()?.reload} fallback={ - <p>Auto reload is disabled. Enable to automatically reload when balance is low.</p> + <p> + Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low. + </p> } > <p> - We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "} - <b>$5</b>. + Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee) + when balance reaches <b>${billingInfo()?.reloadTrigger}</b>. </p> </Show> - <form action={setReload} method="post" data-slot="create-form"> - <input type="hidden" name="workspaceID" value={params.id} /> - <input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} /> - <button data-color="primary" type="submit" disabled={setReloadSubmission.pending}> - <Show - when={balanceInfo()?.reload} - fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"} - > - {setReloadSubmission.pending ? "Disabling..." : "Disable"} - </Show> - </button> - </form> + <button data-color="primary" type="button" onClick={() => show()}> + {billingInfo()?.reload ? "Edit" : "Enable"} + </button> </div> </div> - <div data-slot="section-content"> - <Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}> + <Show when={store.show}> + <form action={setReload} method="post" data-slot="create-form"> + <div data-slot="form-field"> + <label> + <span data-slot="field-label">Enable Auto Reload</span> + <div data-slot="toggle-container"> + <label data-slot="model-toggle-label"> + <input + type="checkbox" + name="reload" + value="true" + checked={store.reload} + onChange={(e) => setStore("reload", e.currentTarget.checked)} + /> + <span></span> + </label> + </div> + </label> + </div> + + <div data-slot="input-row"> + <div data-slot="input-field"> + <p>Reload $</p> + <input + data-component="input" + name="reloadAmount" + type="number" + min={billingInfo()?.reloadAmountMin.toString()} + step="1" + value={store.reloadAmount} + onInput={(e) => setStore("reloadAmount", e.currentTarget.value)} + placeholder={billingInfo()?.reloadAmount.toString()} + disabled={!store.reload} + /> + </div> + <div data-slot="input-field"> + <p>When balance reaches $</p> + <input + data-component="input" + name="reloadTrigger" + type="number" + min={billingInfo()?.reloadTriggerMin.toString()} + step="1" + value={store.reloadTrigger} + onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)} + placeholder={billingInfo()?.reloadTrigger.toString()} + disabled={!store.reload} + /> + </div> + </div> + + <Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}> + {(err: any) => <div data-slot="form-error">{err()}</div>} + </Show> + <input type="hidden" name="workspaceID" value={params.id} /> + <div data-slot="form-actions"> + <button type="button" data-color="ghost" onClick={() => hide()}> + Cancel + </button> + <button type="submit" data-color="primary" disabled={setReloadSubmission.pending}> + {setReloadSubmission.pending ? "Saving..." : "Save"} + </button> + </div> + </form> + </Show> + <Show when={billingInfo()?.reload && billingInfo()?.reloadError}> + <div data-slot="section-content"> <div data-slot="reload-error"> <p> Reload failed at{" "} - {balanceInfo()?.timeReloadError!.toLocaleString("en-US", { + {billingInfo()?.timeReloadError!.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", })} - . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment - method and try again. + . Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try + again. </p> <form action={reload} method="post" data-slot="create-form"> <input type="hidden" name="workspaceID" value={params.id} /> @@ -104,8 +200,8 @@ export function ReloadSection() { </button> </form> </div> - </Show> - </div> + </div> + </Show> </section> ) } diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx index 8f7678f21..45f67ca38 100644 --- a/packages/console/app/src/routes/workspace/[id]/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -1,22 +1,32 @@ +import { Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router" import { NewUserSection } from "./new-user-section" import { UsageSection } from "./usage-section" import { ModelSection } from "./model-section" import { ProviderSection } from "./provider-section" import { IconLogo } from "~/component/icon" -import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router" -import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common" -import { Show, createMemo } from "solid-js" +import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common" export default function () { const params = useParams() const userInfo = createAsync(() => querySessionInfo(params.id)) const billingInfo = createAsync(() => queryBillingInfo(params.id)) - const createCheckoutUrlAction = useAction(createCheckoutUrl) - const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) - - const balanceAmount = createMemo(() => { - return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2) + const checkoutAction = useAction(createCheckoutUrl) + const checkoutSubmission = useSubmission(createCheckoutUrl) + const [store, setStore] = createStore({ + checkoutRedirecting: false, }) + const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0)) + + async function onClickCheckout() { + const baseUrl = window.location.href + const checkout = await checkoutAction(params.id, billingInfo()!.reloadAmount, baseUrl, baseUrl) + if (checkout && checkout.data) { + setStore("checkoutRedirecting", true) + window.location.href = checkout.data + } + } return ( <div data-page="workspace-[id]"> @@ -38,21 +48,15 @@ export default function () { <button data-color="primary" data-size="sm" - disabled={createCheckoutUrlSubmission.pending} - onClick={async () => { - const baseUrl = window.location.href - const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) - if (checkoutUrl) { - window.location.href = checkoutUrl - } - }} + disabled={checkoutSubmission.pending || store.checkoutRedirecting} + onClick={onClickCheckout} > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"} + {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"} </button> } > <span data-slot="balance"> - Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> + Current balance <b>${balance()}</b> </span> </Show> </span> diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css index 1066b7f09..5705549e3 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css @@ -171,7 +171,6 @@ } @media (max-width: 40rem) { - th, td { padding: var(--space-2) var(--space-3); @@ -181,8 +180,7 @@ th { &:nth-child(3) - /* Date */ - { + /* Date */ { display: none; } } @@ -190,11 +188,10 @@ td { &:nth-child(3) - /* Date */ - { + /* Date */ { display: none; } } } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css index 29f55a977..7a64fd9c7 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css +++ b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css @@ -69,4 +69,4 @@ } } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index 964f7dacb..7a1980ebe 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -5,15 +5,7 @@ import { withActor } from "~/context/auth.withActor" import { ZenData } from "@opencode-ai/console-core/model.js" import styles from "./model-section.module.css" import { querySessionInfo } from "../common" -import { - IconAlibaba, - IconAnthropic, - IconMoonshotAI, - IconOpenAI, - IconStealth, - IconXai, - IconZai, -} from "~/component/icon" +import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon" const getModelLab = (modelId: string) => { if (modelId.startsWith("claude")) return "Anthropic" @@ -31,6 +23,7 @@ const getModelsInfo = query(async (workspaceID: string) => { return { all: Object.entries(ZenData.list().models) .filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id)) + .filter(([id, _model]) => !id.startsWith("an-")) .sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name)) .map(([id, model]) => ({ id, name: model.name })), disabled: await Model.listDisabled(), @@ -75,8 +68,7 @@ export function ModelSection() { <div data-slot="section-title"> <h2>Models</h2> <p> - Manage which models workspace members can access.{" "} - <a href="/docs/zen#pricing ">Learn more</a>. + Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>. </p> </div> <div data-slot="models-list"> diff --git a/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css index aaad823ab..bb58df79b 100644 --- a/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css @@ -140,4 +140,4 @@ } } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.module.css b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css index 1a450d3dc..1dc7085b7 100644 --- a/packages/console/app/src/routes/workspace/[id]/provider-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css @@ -128,7 +128,6 @@ } @media (max-width: 40rem) { - th, td { padding: var(--space-2) var(--space-3); @@ -136,4 +135,4 @@ } } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx index 6ec8477b4..5419ed7fb 100644 --- a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx @@ -22,7 +22,9 @@ const removeProvider = action(async (form: FormData) => { if (!provider) return { error: "Provider is required" } const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } - return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key }) + return json(await withActor(() => Provider.remove({ provider }), workspaceID), { + revalidate: listProviders.key, + }) }, "provider.remove") const saveProvider = action(async (form: FormData) => { diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css index 058fbe301..6764a0534 100644 --- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css @@ -31,7 +31,7 @@ margin: 0; } - >button { + > button { align-self: flex-start; } } @@ -80,7 +80,7 @@ } } - >button[type="reset"] { + > button[type="reset"] { align-self: flex-start; } @@ -91,4 +91,4 @@ margin-top: calc(var(--space-1) * -1); } } -}
\ No newline at end of file +} diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index 69bfebe97..a6eaaeb1e 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -1,6 +1,6 @@ import { Resource } from "@opencode-ai/console-resource" import { Actor } from "@opencode-ai/console-core/actor.js" -import { action, query } from "@solidjs/router" +import { action, json, query } from "@solidjs/router" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import { User } from "@opencode-ai/console-core/user.js" @@ -34,6 +34,11 @@ export function formatDateUTC(date: Date) { return date.toLocaleDateString("en-US", options) } +export function formatBalance(amount: number) { + const balance = ((amount ?? 0) / 100000000).toFixed(2) + return balance === "-0.00" ? "0.00" : balance +} + export async function getLastSeenWorkspaceID() { "use server" return withActor(async () => { @@ -62,23 +67,40 @@ export const querySessionInfo = query(async (workspaceID: string) => { return withActor(() => { return { isAdmin: Actor.userRole() === "admin", - isBeta: - Resource.App.stage === "production" - ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" - : true, + isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true, } }, workspaceID) }, "session.get") export const createCheckoutUrl = action( - async (workspaceID: string, successUrl: string, cancelUrl: string) => { + async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => { "use server" - return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID) + return json( + await withActor( + () => + Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + ) }, "checkoutUrl", ) export const queryBillingInfo = query(async (workspaceID: string) => { "use server" - return withActor(() => Billing.get(), workspaceID) + return withActor(async () => { + const billing = await Billing.get() + return { + ...billing, + reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT, + reloadAmountMin: Billing.RELOAD_AMOUNT_MIN, + reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER, + reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN, + } + }, workspaceID) }, "billing.get") diff --git a/packages/console/app/src/routes/zen/index.css b/packages/console/app/src/routes/zen/index.css index 661517c43..fbdd15306 100644 --- a/packages/console/app/src/routes/zen/index.css +++ b/packages/console/app/src/routes/zen/index.css @@ -277,7 +277,7 @@ body { margin-bottom: 24px; } - strong { + h1 { font-size: 28px; color: var(--color-text-strong); font-weight: 500; diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 08e58e160..4eab4dcb9 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -3,6 +3,7 @@ import { createAsync, query, redirect } from "@solidjs/router" import { Title, Meta, Link } from "@solidjs/meta" import { HttpHeader } from "@solidjs/start" import zenLogoLight from "../../asset/zen-ornate-light.svg" +import { config } from "~/config" import zenLogoDark from "../../asset/zen-ornate-dark.svg" import compareVideo from "../../asset/lander/opencode-comparison-min.mp4" import compareVideoPoster from "../../asset/lander/opencode-comparison-poster.png" @@ -30,6 +31,7 @@ export default function Home() { <main data-page="zen"> <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" /> <Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title> + <Link rel="canonical" href={`${config.baseUrl}/zen`} /> <Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" /> <Meta property="og:image" content="/social-share-zen.png" /> <Meta name="twitter:image" content="/social-share-zen.png" /> @@ -42,7 +44,7 @@ export default function Home() { <div data-slot="hero-copy"> <img data-slot="zen logo light" src={zenLogoLight} alt="zen logo light" /> <img data-slot="zen logo dark" src={zenLogoDark} alt="zen logo dark" /> - <strong>Reliable optimized models for coding agents</strong> + <h1>Reliable optimized models for coding agents</h1> <p> Zen gives you access to a curated set of AI models that OpenCode has tested and benchmarked specifically for coding agents. No need to worry about inconsistent performance and quality, use validated models diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index dfc7e9fcd..f1e131468 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -3,3 +3,4 @@ export class CreditsError extends Error {} export class MonthlyLimitError extends Error {} export class UserLimitError extends Error {} export class ModelError extends Error {} +export class RateLimitError extends Error {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 0d46e8580..edaac3a7b 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -12,18 +12,14 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" import { logger } from "./logger" -import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error" -import { - createBodyConverter, - createStreamPartConverter, - createResponseConverter, -} from "./provider/provider" +import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error" +import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider" import { anthropicHelper } from "./provider/anthropic" import { openaiHelper } from "./provider/openai" import { oaCompatHelper } from "./provider/openai-compatible" +import { createRateLimiter } from "./rateLimiter" type ZenData = Awaited<ReturnType<typeof ZenData.list>> -type Model = ZenData["models"][string] export async function handler( input: APIEvent, @@ -32,6 +28,10 @@ export async function handler( parseApiKey: (headers: Headers) => string | undefined }, ) { + type AuthInfo = Awaited<ReturnType<typeof authenticate>> + type ModelInfo = Awaited<ReturnType<typeof validateModel>> + type ProviderInfo = Awaited<ReturnType<typeof selectProvider>> + const FREE_WORKSPACES = [ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench @@ -39,6 +39,7 @@ export async function handler( try { const body = await input.request.json() + const ip = input.request.headers.get("x-real-ip") ?? "" logger.metric({ is_tream: !!body.stream, session: input.request.headers.get("x-opencode-session"), @@ -46,13 +47,11 @@ export async function handler( }) const zenData = ZenData.list() const modelInfo = validateModel(zenData, body.model) - const providerInfo = selectProvider( - zenData, - modelInfo, - input.request.headers.get("x-real-ip") ?? "", - ) + const providerInfo = selectProvider(zenData, modelInfo, ip) const authInfo = await authenticate(modelInfo, providerInfo) - validateBilling(modelInfo, authInfo) + const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) + await rateLimiter?.check() + validateBilling(authInfo, modelInfo) validateModelSettings(authInfo) updateProviderKey(authInfo, providerInfo) logger.metric({ provider: providerInfo.id }) @@ -67,7 +66,7 @@ export async function handler( }), ) logger.debug("REQUEST URL: " + reqUrl) - logger.debug("REQUEST: " + reqBody) + logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...") const res = await fetch(reqUrl, { method: "POST", headers: (() => { @@ -92,9 +91,6 @@ export async function handler( } } logger.debug("STATUS: " + res.status + " " + res.statusText) - if (res.status === 400 || res.status === 503) { - logger.debug("RESPONSE: " + (await res.text())) - } // Handle non-streaming response if (!body.stream) { @@ -103,6 +99,7 @@ export async function handler( const body = JSON.stringify(responseConverter(json)) logger.metric({ response_length: body.length }) logger.debug("RESPONSE: " + body) + await rateLimiter?.track() await trackUsage(authInfo, modelInfo, providerInfo, json.usage) await reload(authInfo) return new Response(body, { @@ -131,6 +128,7 @@ export async function handler( response_length: responseLength, "timestamp.last_byte": Date.now(), }) + await rateLimiter?.track() const usage = usageParser.retrieve() if (usage) { await trackUsage(authInfo, modelInfo, providerInfo, usage) @@ -205,6 +203,15 @@ export async function handler( { status: 401 }, ) + if (error instanceof RateLimitError) + return new Response( + JSON.stringify({ + type: "error", + error: { type: error.constructor.name, message: error.message }, + }), + { status: 429 }, + ) + return new Response( JSON.stringify({ type: "error", @@ -229,12 +236,8 @@ export async function handler( return { id: modelId, ...modelData } } - function selectProvider( - zenData: ZenData, - model: Awaited<ReturnType<typeof validateModel>>, - ip: string, - ) { - const providers = model.providers + function selectProvider(zenData: ZenData, modelInfo: ModelInfo, ip: string) { + const providers = modelInfo.providers .filter((provider) => !provider.disabled) .flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider)) @@ -247,26 +250,22 @@ export async function handler( throw new ModelError(`Provider ${provider.id} not supported`) } - const format = zenData.providers[provider.id].format - return { ...provider, ...zenData.providers[provider.id], - ...(format === "anthropic" - ? anthropicHelper - : format === "openai" - ? openaiHelper - : oaCompatHelper), + ...(() => { + const format = zenData.providers[provider.id].format + if (format === "anthropic") return anthropicHelper + if (format === "openai") return openaiHelper + return oaCompatHelper + })(), } } - async function authenticate( - model: Awaited<ReturnType<typeof validateModel>>, - providerInfo: Awaited<ReturnType<typeof selectProvider>>, - ) { + async function authenticate(modelInfo: ModelInfo, providerInfo: ProviderInfo) { const apiKey = opts.parseApiKey(input.request.headers) if (!apiKey) { - if (model.allowAnonymous) return + if (modelInfo.allowAnonymous) return throw new AuthError("Missing API key.") } @@ -281,6 +280,7 @@ export async function handler( monthlyLimit: BillingTable.monthlyLimit, monthlyUsage: BillingTable.monthlyUsage, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, + reloadTrigger: BillingTable.reloadTrigger, }, user: { id: UserTable.id, @@ -296,20 +296,11 @@ export async function handler( .from(KeyTable) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) .innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID)) - .innerJoin( - UserTable, - and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)), - ) - .leftJoin( - ModelTable, - and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)), - ) + .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID))) + .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id))) .leftJoin( ProviderTable, - and( - eq(ProviderTable.workspaceID, KeyTable.workspaceID), - eq(ProviderTable.provider, providerInfo.id), - ), + and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)), ) .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) .then((rows) => rows[0]), @@ -332,11 +323,11 @@ export async function handler( } } - function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) { + function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) { if (!authInfo) return if (authInfo.provider?.credentials) return if (authInfo.isFree) return - if (model.allowAnonymous) return + if (modelInfo.allowAnonymous) return const billing = authInfo.billing if (!billing.paymentMethodID) @@ -380,39 +371,24 @@ export async function handler( } } - function validateModelSettings(authInfo: Awaited<ReturnType<typeof authenticate>>) { + function validateModelSettings(authInfo: AuthInfo) { if (!authInfo) return if (authInfo.isDisabled) throw new ModelError("Model is disabled") } - function updateProviderKey( - authInfo: Awaited<ReturnType<typeof authenticate>>, - providerInfo: Awaited<ReturnType<typeof selectProvider>>, - ) { + function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) { if (!authInfo) return if (!authInfo.provider?.credentials) return providerInfo.apiKey = authInfo.provider.credentials } - async function trackUsage( - authInfo: Awaited<ReturnType<typeof authenticate>>, - modelInfo: ReturnType<typeof validateModel>, - providerInfo: Awaited<ReturnType<typeof selectProvider>>, - usage: any, - ) { - const { - inputTokens, - outputTokens, - reasoningTokens, - cacheReadTokens, - cacheWrite5mTokens, - cacheWrite1hTokens, - } = providerInfo.normalizeUsage(usage) + async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) { + const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = + providerInfo.normalizeUsage(usage) const modelCost = modelInfo.cost200K && - inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > - 200_000 + inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000 ? modelInfo.cost200K : modelInfo.cost @@ -463,8 +439,7 @@ export async function handler( if (!authInfo) return - const cost = - authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) + const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) await Database.transaction(async (tx) => { await tx.insert(UsageTable).values({ workspaceID: authInfo.workspaceID, @@ -504,9 +479,7 @@ export async function handler( `, timeMonthlyUsageUpdated: sql`now()`, }) - .where( - and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)), - ) + .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))) }) await Database.use((tx) => @@ -517,7 +490,7 @@ export async function handler( ) } - async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) { + async function reload(authInfo: AuthInfo) { if (!authInfo) return if (authInfo.isFree) return if (authInfo.provider?.credentials) return @@ -532,11 +505,11 @@ export async function handler( and( eq(BillingTable.workspaceID, authInfo.workspaceID), eq(BillingTable.reload, true), - lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)), - or( - isNull(BillingTable.timeReloadLockedTill), - lt(BillingTable.timeReloadLockedTill, sql`now()`), + lt( + BillingTable.balance, + centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100), ), + or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), ), ), ) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 603d8917b..d8d1cd741 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -98,7 +98,10 @@ export function fromAnthropicRequest(body: any): CommonRequest { typeof (src as any).media_type === "string" && typeof (src as any).data === "string" ) - return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } } + return { + type: "image_url", + image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` }, + } return undefined } @@ -165,7 +168,11 @@ export function fromAnthropicRequest(body: any): CommonRequest { .filter((t: any) => t && typeof t === "object" && "input_schema" in t) .map((t: any) => ({ type: "function", - function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema }, + function: { + name: (t as any).name, + description: (t as any).description, + parameters: (t as any).input_schema, + }, })) : undefined @@ -452,7 +459,12 @@ export function toAnthropicResponse(resp: CommonResponse) { } catch { input = (tc as any).function.arguments } - content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input }) + content.push({ + type: "tool_use", + id: (tc as any).id, + name: (tc as any).function.name, + input, + }) } } } @@ -511,13 +523,22 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string { if (json.type === "content_block_start") { const cb = json.content_block if (cb?.type === "text") { - out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null }) + out.choices.push({ + index: json.index ?? 0, + delta: { role: "assistant", content: "" }, + finish_reason: null, + }) } else if (cb?.type === "tool_use") { out.choices.push({ index: json.index ?? 0, delta: { tool_calls: [ - { index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } }, + { + index: json.index ?? 0, + id: cb.id, + type: "function", + function: { name: cb.name, arguments: "" }, + }, ], }, finish_reason: null, @@ -532,7 +553,9 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string { } else if (d?.type === "input_json_delta") { out.choices.push({ index: json.index ?? 0, - delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] }, + delta: { + tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }], + }, finish_reason: null, }) } diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index daf650275..8a9170ef1 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -532,7 +532,9 @@ export function toOaCompatibleChunk(chunk: CommonChunk): string { total_tokens: chunk.usage.total_tokens, ...(chunk.usage.prompt_tokens_details?.cached_tokens ? { - prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens }, + prompt_tokens_details: { + cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens, + }, } : {}), } diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index d17300991..e79e83579 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -77,7 +77,10 @@ export function fromOpenaiRequest(body: any): CommonRequest { typeof (s as any).media_type === "string" && typeof (s as any).data === "string" ) - return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } } + return { + type: "image_url", + image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` }, + } return undefined } @@ -153,7 +156,11 @@ export function fromOpenaiRequest(body: any): CommonRequest { } if ((m as any).role === "tool") { - msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content }) + msgs.push({ + role: "tool", + tool_call_id: (m as any).tool_call_id, + content: (m as any).content, + }) continue } } @@ -210,7 +217,10 @@ export function toOpenaiRequest(body: CommonRequest) { typeof (s as any).media_type === "string" && typeof (s as any).data === "string" ) - return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } } + return { + type: "input_image", + image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` }, + } return undefined } @@ -498,7 +508,9 @@ export function fromOpenaiChunk(chunk: string): CommonChunk | string { if (typeof name === "string" && name.length > 0) { out.choices.push({ index: 0, - delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] }, + delta: { + tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }], + }, finish_reason: null, }) } @@ -555,7 +567,12 @@ export function toOpenaiChunk(chunk: CommonChunk): string { const model = chunk.model if (d.content) { - const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } } + const data = { + id, + type: "response.output_text.delta", + delta: d.content, + response: { id, model }, + } return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}` } @@ -565,7 +582,13 @@ export function toOpenaiChunk(chunk: CommonChunk): string { const data = { type: "response.output_item.added", output_index: 0, - item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" }, + item: { + id: tc.id, + type: "function_call", + name: tc.function.name, + call_id: tc.id, + arguments: "", + }, } return `event: response.output_item.added\ndata: ${JSON.stringify(data)}` } @@ -593,7 +616,11 @@ export function toOpenaiChunk(chunk: CommonChunk): string { } : undefined - const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } } + const data: any = { + id, + type: "response.completed", + response: { id, model, ...(usage ? { usage } : {}) }, + } return `event: response.completed\ndata: ${JSON.stringify(data)}` } diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts new file mode 100644 index 000000000..b3c036815 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -0,0 +1,35 @@ +import { Resource } from "@opencode-ai/console-resource" +import { RateLimitError } from "./error" +import { logger } from "./logger" + +export function createRateLimiter(model: string, limit: number | undefined, ip: string) { + if (!limit) return + + const now = Date.now() + const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}` + const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}` + let currRate: number + let prevRate: number + + return { + track: async () => { + await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 }) + }, + check: async () => { + const values = await Resource.GatewayKv.get([currKey, prevKey]) + const prevValue = values?.get(prevKey) + const currValue = values?.get(currKey) + prevRate = prevValue ? parseInt(prevValue) : 0 + currRate = currValue ? parseInt(currValue) : 0 + logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`) + if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + }, + } +} + +function buildYYYYMMDDHH(timestamp: number) { + return new Date(timestamp) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 10) +} diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index 3d0c31470..ee2b3ab54 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -50,10 +50,7 @@ export async function GET(input: APIEvent) { }) .from(KeyTable) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) - .leftJoin( - ModelTable, - and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)), - ) + .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted))) .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) .then((rows) => rows.map((row) => row.model)), ) diff --git a/packages/console/app/sst-env.d.ts b/packages/console/app/sst-env.d.ts index 9b9de7327..bd5588217 100644 --- a/packages/console/app/sst-env.d.ts +++ b/packages/console/app/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/console/core/migrations/meta/0018_snapshot.json b/packages/console/core/migrations/meta/0018_snapshot.json index 398ba2619..3e3c64c73 100644 --- a/packages/console/core/migrations/meta/0018_snapshot.json +++ b/packages/console/core/migrations/meta/0018_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -599,10 +578,7 @@ "indexes": { "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true } }, @@ -610,10 +586,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -670,9 +643,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -680,9 +651,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -699,4 +668,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0019_snapshot.json b/packages/console/core/migrations/meta/0019_snapshot.json index 8f1afeb51..9a0d4d243 100644 --- a/packages/console/core/migrations/meta/0019_snapshot.json +++ b/packages/console/core/migrations/meta/0019_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -599,10 +578,7 @@ "indexes": { "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true } }, @@ -610,10 +586,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -670,9 +643,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -680,9 +651,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -699,4 +668,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0020_snapshot.json b/packages/console/core/migrations/meta/0020_snapshot.json index 662093f55..9defceb5a 100644 --- a/packages/console/core/migrations/meta/0020_snapshot.json +++ b/packages/console/core/migrations/meta/0020_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -592,10 +571,7 @@ "indexes": { "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true } }, @@ -603,10 +579,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -663,9 +636,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -673,9 +644,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -692,4 +661,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0021_snapshot.json b/packages/console/core/migrations/meta/0021_snapshot.json index b285e34fa..64d3e9d24 100644 --- a/packages/console/core/migrations/meta/0021_snapshot.json +++ b/packages/console/core/migrations/meta/0021_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -599,10 +578,7 @@ "indexes": { "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true } }, @@ -610,10 +586,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -670,9 +643,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -680,9 +651,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -699,4 +668,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0022_snapshot.json b/packages/console/core/migrations/meta/0022_snapshot.json index 9486ee345..8a1c4e7d8 100644 --- a/packages/console/core/migrations/meta/0022_snapshot.json +++ b/packages/console/core/migrations/meta/0022_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -613,18 +592,12 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true } }, @@ -632,10 +605,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -692,9 +662,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -702,9 +670,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -721,4 +687,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0023_snapshot.json b/packages/console/core/migrations/meta/0023_snapshot.json index 8a40e42a4..4f6f66283 100644 --- a/packages/console/core/migrations/meta/0023_snapshot.json +++ b/packages/console/core/migrations/meta/0023_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -613,32 +592,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -646,10 +615,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -706,9 +672,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -716,9 +680,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -735,4 +697,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0024_snapshot.json b/packages/console/core/migrations/meta/0024_snapshot.json index 4f50945d6..1ef25970a 100644 --- a/packages/console/core/migrations/meta/0024_snapshot.json +++ b/packages/console/core/migrations/meta/0024_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,17 +473,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -504,10 +486,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -599,32 +578,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -632,10 +601,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -692,9 +658,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -702,9 +666,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -721,4 +683,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0025_snapshot.json b/packages/console/core/migrations/meta/0025_snapshot.json index 4b0cef0c0..6746a6e8c 100644 --- a/packages/console/core/migrations/meta/0025_snapshot.json +++ b/packages/console/core/migrations/meta/0025_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -493,17 +480,12 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true }, "name": { "name": "name", - "columns": [ - "workspace_id", - "name" - ], + "columns": ["workspace_id", "name"], "isUnique": true } }, @@ -511,10 +493,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -606,32 +585,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -639,10 +608,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -699,9 +665,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -709,9 +673,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -728,4 +690,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0026_snapshot.json b/packages/console/core/migrations/meta/0026_snapshot.json index 543ab44c3..d3c7dc496 100644 --- a/packages/console/core/migrations/meta/0026_snapshot.json +++ b/packages/console/core/migrations/meta/0026_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -493,9 +480,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -503,10 +488,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -598,32 +580,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -631,10 +603,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -691,9 +660,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -701,9 +668,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -720,4 +685,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0027_snapshot.json b/packages/console/core/migrations/meta/0027_snapshot.json index 9b6910223..408766f71 100644 --- a/packages/console/core/migrations/meta/0027_snapshot.json +++ b/packages/console/core/migrations/meta/0027_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -479,9 +466,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -489,10 +474,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -584,32 +566,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -617,10 +589,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -677,9 +646,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -687,9 +654,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -706,4 +671,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0028_snapshot.json b/packages/console/core/migrations/meta/0028_snapshot.json index 8242ae52d..827cb53c5 100644 --- a/packages/console/core/migrations/meta/0028_snapshot.json +++ b/packages/console/core/migrations/meta/0028_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -479,9 +466,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -489,10 +474,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -584,32 +566,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -617,10 +589,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -677,9 +646,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -687,9 +654,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -706,4 +671,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0029_snapshot.json b/packages/console/core/migrations/meta/0029_snapshot.json index 959004f33..d235697cc 100644 --- a/packages/console/core/migrations/meta/0029_snapshot.json +++ b/packages/console/core/migrations/meta/0029_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -479,9 +466,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -489,10 +474,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -605,32 +587,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -638,10 +610,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -698,9 +667,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -708,9 +675,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -727,4 +692,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0030_snapshot.json b/packages/console/core/migrations/meta/0030_snapshot.json index 6a6eb38cb..66978dfa5 100644 --- a/packages/console/core/migrations/meta/0030_snapshot.json +++ b/packages/console/core/migrations/meta/0030_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -479,9 +466,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -489,10 +474,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -549,10 +531,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -560,10 +539,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -676,32 +652,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -709,10 +675,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -769,9 +732,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -779,9 +740,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -798,4 +757,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0031_snapshot.json b/packages/console/core/migrations/meta/0031_snapshot.json index ba964881d..c47165925 100644 --- a/packages/console/core/migrations/meta/0031_snapshot.json +++ b/packages/console/core/migrations/meta/0031_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -398,10 +388,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -479,9 +466,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -489,10 +474,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -549,10 +531,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -560,10 +539,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -627,10 +603,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -638,10 +611,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -754,32 +724,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -787,10 +747,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -847,9 +804,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -857,9 +812,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -876,4 +829,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0032_snapshot.json b/packages/console/core/migrations/meta/0032_snapshot.json index 344fde6fd..51e84a1d3 100644 --- a/packages/console/core/migrations/meta/0032_snapshot.json +++ b/packages/console/core/migrations/meta/0032_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -180,9 +178,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -190,10 +186,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -280,10 +273,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -405,10 +395,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -486,9 +473,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -496,10 +481,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -556,10 +538,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -567,10 +546,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -634,10 +610,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -645,10 +618,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -761,32 +731,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -794,10 +754,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -854,9 +811,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -864,9 +819,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -883,4 +836,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0033_snapshot.json b/packages/console/core/migrations/meta/0033_snapshot.json index eb682adca..76d4720e8 100644 --- a/packages/console/core/migrations/meta/0033_snapshot.json +++ b/packages/console/core/migrations/meta/0033_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -187,9 +185,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -197,10 +193,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -287,10 +280,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -412,10 +402,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -493,9 +480,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -503,10 +488,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -563,10 +545,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -574,10 +553,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -641,10 +617,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -652,10 +625,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -768,32 +738,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -801,10 +761,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -861,9 +818,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -871,9 +826,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -890,4 +843,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0034_snapshot.json b/packages/console/core/migrations/meta/0034_snapshot.json index 36acbdef4..e9c999be7 100644 --- a/packages/console/core/migrations/meta/0034_snapshot.json +++ b/packages/console/core/migrations/meta/0034_snapshot.json @@ -48,9 +48,7 @@ "indexes": { "email": { "name": "email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -117,10 +115,7 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true } }, @@ -257,9 +252,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -267,10 +260,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -357,10 +347,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -482,10 +469,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -563,9 +547,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -573,10 +555,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -633,10 +612,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -644,10 +620,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -711,10 +684,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -722,10 +692,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -838,32 +805,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -871,10 +828,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -931,9 +885,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -941,9 +893,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -960,4 +910,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0035_snapshot.json b/packages/console/core/migrations/meta/0035_snapshot.json index 7478337b5..815d120ea 100644 --- a/packages/console/core/migrations/meta/0035_snapshot.json +++ b/packages/console/core/migrations/meta/0035_snapshot.json @@ -102,17 +102,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -249,9 +244,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -259,10 +252,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -349,10 +339,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -474,10 +461,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -555,9 +539,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -565,10 +547,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -625,10 +604,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -636,10 +612,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -703,10 +676,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -714,10 +684,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -830,32 +797,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -863,10 +820,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -923,9 +877,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -933,9 +885,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -952,4 +902,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0036_snapshot.json b/packages/console/core/migrations/meta/0036_snapshot.json index b030e30ea..926b143eb 100644 --- a/packages/console/core/migrations/meta/0036_snapshot.json +++ b/packages/console/core/migrations/meta/0036_snapshot.json @@ -43,9 +43,7 @@ "compositePrimaryKeys": { "account_id_pk": { "name": "account_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -109,17 +107,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -127,9 +120,7 @@ "compositePrimaryKeys": { "auth_id_pk": { "name": "auth_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -263,9 +254,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -273,10 +262,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -363,10 +349,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -488,10 +471,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -569,9 +549,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -579,10 +557,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -639,10 +614,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -650,10 +622,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -717,10 +686,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -728,10 +694,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -844,32 +807,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -877,10 +830,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -937,9 +887,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -947,9 +895,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -966,4 +912,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0037_snapshot.json b/packages/console/core/migrations/meta/0037_snapshot.json index 690bae87a..8a80ea522 100644 --- a/packages/console/core/migrations/meta/0037_snapshot.json +++ b/packages/console/core/migrations/meta/0037_snapshot.json @@ -43,9 +43,7 @@ "compositePrimaryKeys": { "account_id_pk": { "name": "account_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -109,17 +107,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -127,9 +120,7 @@ "compositePrimaryKeys": { "auth_id_pk": { "name": "auth_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -277,9 +268,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -287,10 +276,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -377,10 +363,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -502,10 +485,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -583,9 +563,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -593,10 +571,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -653,10 +628,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -664,10 +636,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -731,10 +700,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -742,10 +708,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -858,32 +821,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -891,10 +844,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -951,9 +901,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -961,9 +909,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -980,4 +926,4 @@ "tables": {}, "indexes": {} } -}
\ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index f2c6c6fc5..250fe59b3 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -269,4 +269,4 @@ "breakpoints": true } ] -}
\ No newline at end of file +} diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c1ab23476..04421bbc8 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.23", + "version": "1.0.55", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index af9bcc3a1..1ae18c4dd 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -8,22 +8,15 @@ if (!email) { process.exit(1) } -const authData = await printTable("Auth", (tx) => - tx.select().from(AuthTable).where(eq(AuthTable.subject, email)), -) +const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email))) if (authData.length === 0) { console.error("User not found") process.exit(1) } -await printTable("Auth", (tx) => - tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)), -) +await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID))) -function printTable( - title: string, - callback: (tx: Database.TxOrDb) => Promise<any[]>, -): Promise<any[]> { +function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any[]>): Promise<any[]> { return Database.use(async (tx) => { const data = await callback(tx) console.log(`== ${title} ==`) diff --git a/packages/console/core/script/reset-db.ts b/packages/console/core/script/reset-db.ts index bd00e1962..02d498901 100644 --- a/packages/console/core/script/reset-db.ts +++ b/packages/console/core/script/reset-db.ts @@ -8,14 +8,6 @@ import { KeyTable } from "../src/schema/key.sql.js" if (Resource.App.stage !== "frank") throw new Error("This script is only for frank") -for (const table of [ - AccountTable, - BillingTable, - KeyTable, - PaymentTable, - UsageTable, - UserTable, - WorkspaceTable, -]) { +for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) { await Database.use((tx) => tx.delete(table)) } diff --git a/packages/console/core/script/update-models.ts b/packages/console/core/script/update-models.ts index e7a245515..807d57826 100755 --- a/packages/console/core/script/update-models.ts +++ b/packages/console/core/script/update-models.ts @@ -7,7 +7,6 @@ import { ZenData } from "../src/model" const root = path.resolve(process.cwd(), "..", "..", "..") const models = await $`bun sst secret list`.cwd(root).text() -console.log("models", models) // read the line starting with "ZEN_MODELS" const oldValue1 = models diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 70bf1bc36..348718146 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -10,13 +10,12 @@ import { centsToMicroCents } from "./util/price" import { User } from "./user" export namespace Billing { - export const CHARGE_NAME = "opencode credits" - export const CHARGE_FEE_NAME = "processing fee" - export const CHARGE_AMOUNT = 2000 // $20 - export const CHARGE_AMOUNT_DOLLAR = 20 - export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30 - export const CHARGE_THRESHOLD_DOLLAR = 5 - export const CHARGE_THRESHOLD = 500 // $5 + export const ITEM_CREDIT_NAME = "opencode credits" + export const ITEM_FEE_NAME = "processing fee" + export const RELOAD_AMOUNT = 20 + export const RELOAD_AMOUNT_MIN = 10 + export const RELOAD_TRIGGER = 5 + export const RELOAD_TRIGGER_MIN = 5 export const stripe = () => new Stripe(Resource.STRIPE_SECRET_KEY.value, { apiVersion: "2025-03-31.basil", @@ -33,6 +32,8 @@ export namespace Billing { paymentMethodLast4: BillingTable.paymentMethodLast4, balance: BillingTable.balance, reload: BillingTable.reload, + reloadAmount: BillingTable.reloadAmount, + reloadTrigger: BillingTable.reloadTrigger, monthlyLimit: BillingTable.monthlyLimit, monthlyUsage: BillingTable.monthlyUsage, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, @@ -67,17 +68,28 @@ export namespace Billing { ) } + export const calculateFeeInCents = (x: number) => { + // math: x = total - (total * 0.044 + 0.30) + // math: x = total * (1-0.044) - 0.30 + // math: (x + 0.30) / 0.956 = total + return Math.round(((x + 30) / 0.956) * 0.044 + 30) + } + export const reload = async () => { - const { customerID, paymentMethodID } = await Database.use((tx) => + const billing = await Database.use((tx) => tx .select({ customerID: BillingTable.customerID, paymentMethodID: BillingTable.paymentMethodID, + reloadAmount: BillingTable.reloadAmount, }) .from(BillingTable) .where(eq(BillingTable.workspaceID, Actor.workspace())) .then((rows) => rows[0]), ) + const customerID = billing.customerID + const paymentMethodID = billing.paymentMethodID + const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100 const paymentID = Identifier.create("payment") let invoice try { @@ -89,18 +101,18 @@ export namespace Billing { currency: "usd", }) await Billing.stripe().invoiceItems.create({ - amount: Billing.CHARGE_AMOUNT, + amount: amountInCents, currency: "usd", customer: customerID!, - description: CHARGE_NAME, invoice: draft.id!, + description: ITEM_CREDIT_NAME, }) await Billing.stripe().invoiceItems.create({ - amount: Billing.CHARGE_FEE, + amount: calculateFeeInCents(amountInCents), currency: "usd", customer: customerID!, - description: CHARGE_FEE_NAME, invoice: draft.id!, + description: ITEM_FEE_NAME, }) await Billing.stripe().invoices.finalizeInvoice(draft.id!) invoice = await Billing.stripe().invoices.pay(draft.id!, { @@ -128,7 +140,7 @@ export namespace Billing { await tx .update(BillingTable) .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`, + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, reloadError: null, timeReloadError: null, }) @@ -136,7 +148,7 @@ export namespace Billing { await tx.insert(PaymentTable).values({ workspaceID: Actor.workspace(), id: paymentID, - amount: centsToMicroCents(CHARGE_AMOUNT), + amount: centsToMicroCents(amountInCents), invoiceID: invoice.id!, paymentID: invoice.payments?.data[0].payment.payment_intent as string, customerID, @@ -159,13 +171,19 @@ export namespace Billing { z.object({ successUrl: z.string(), cancelUrl: z.string(), + amount: z.number().optional(), }), async (input) => { const user = Actor.assert("user") - const { successUrl, cancelUrl } = input + const { successUrl, cancelUrl, amount } = input + + if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) { + throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`) + } const email = await User.getAuthEmail(user.properties.userID) const customer = await Billing.get() + const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100 const session = await Billing.stripe().checkout.sessions.create({ mode: "payment", billing_address_collection: "required", @@ -173,20 +191,16 @@ export namespace Billing { { price_data: { currency: "usd", - product_data: { - name: CHARGE_NAME, - }, - unit_amount: CHARGE_AMOUNT, + product_data: { name: ITEM_CREDIT_NAME }, + unit_amount: amountInCents, }, quantity: 1, }, { price_data: { currency: "usd", - product_data: { - name: CHARGE_FEE_NAME, - }, - unit_amount: CHARGE_FEE, + product_data: { name: ITEM_FEE_NAME }, + unit_amount: calculateFeeInCents(amountInCents), }, quantity: 1, }, @@ -218,6 +232,7 @@ export namespace Billing { }, metadata: { workspaceID: Actor.workspace(), + amount: amountInCents.toString(), }, success_url: successUrl, cancel_url: cancelUrl, diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 30cc15e45..46b2aa557 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -24,6 +24,7 @@ export namespace ZenData { cost: ModelCostSchema, cost200K: ModelCostSchema.optional(), allowAnonymous: z.boolean().optional(), + rateLimit: z.number().optional(), providers: z.array( z.object({ id: z.string(), @@ -60,9 +61,7 @@ export namespace Model { export const enable = fn(z.object({ model: z.string() }), ({ model }) => { Actor.assertAdmin() return Database.use((db) => - db - .delete(ModelTable) - .where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))), + db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))), ) }) diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 01407434e..bcd7c2650 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -6,99 +6,108 @@ 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 - } - "Console": { - "type": "sst.cloudflare.SolidStart" - "url": string - } - "Database": { - "database": string - "host": string - "password": string - "port": number - "type": "sst.sst.Linkable" - "username": string - } - "Desktop": { - "type": "sst.cloudflare.StaticSite" - "url": string - } - "EMAILOCTOPUS_API_KEY": { - "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 - } - "STRIPE_SECRET_KEY": { - "type": "sst.sst.Secret" - "value": string - } - "STRIPE_WEBHOOK_SECRET": { - "type": "sst.sst.Linkable" - "value": string - } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string - } - "ZEN_MODELS1": { - "type": "sst.sst.Secret" - "value": string - } - "ZEN_MODELS2": { - "type": "sst.sst.Secret" - "value": string + 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 + } + Database: { + database: string + host: string + password: string + port: number + type: "sst.sst.Linkable" + username: string + } + Desktop: { + type: "sst.cloudflare.StaticSite" + url: string + } + EMAILOCTOPUS_API_KEY: { + 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 + } + STRIPE_SECRET_KEY: { + type: "sst.sst.Secret" + value: string + } + STRIPE_WEBHOOK_SECRET: { + type: "sst.sst.Linkable" + value: string + } + Web: { + type: "sst.cloudflare.Astro" + url: string + } + ZEN_MODELS1: { + type: "sst.sst.Secret" + value: string + } + ZEN_MODELS2: { + type: "sst.sst.Secret" + value: string } } } -// cloudflare -import * as cloudflare from "@cloudflare/workers-types"; +// 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 - "LogProcessor": cloudflare.Service + Api: cloudflare.Service + AuthApi: cloudflare.Service + AuthStorage: cloudflare.KVNamespace + Bucket: cloudflare.R2Bucket + GatewayKv: cloudflare.KVNamespace + LogProcessor: cloudflare.Service } } import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0c6e02886..26af4c7c2 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.23", + "version": "1.0.55", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 01407434e..bcd7c2650 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -6,99 +6,108 @@ 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 - } - "Console": { - "type": "sst.cloudflare.SolidStart" - "url": string - } - "Database": { - "database": string - "host": string - "password": string - "port": number - "type": "sst.sst.Linkable" - "username": string - } - "Desktop": { - "type": "sst.cloudflare.StaticSite" - "url": string - } - "EMAILOCTOPUS_API_KEY": { - "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 - } - "STRIPE_SECRET_KEY": { - "type": "sst.sst.Secret" - "value": string - } - "STRIPE_WEBHOOK_SECRET": { - "type": "sst.sst.Linkable" - "value": string - } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string - } - "ZEN_MODELS1": { - "type": "sst.sst.Secret" - "value": string - } - "ZEN_MODELS2": { - "type": "sst.sst.Secret" - "value": string + 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 + } + Database: { + database: string + host: string + password: string + port: number + type: "sst.sst.Linkable" + username: string + } + Desktop: { + type: "sst.cloudflare.StaticSite" + url: string + } + EMAILOCTOPUS_API_KEY: { + 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 + } + STRIPE_SECRET_KEY: { + type: "sst.sst.Secret" + value: string + } + STRIPE_WEBHOOK_SECRET: { + type: "sst.sst.Linkable" + value: string + } + Web: { + type: "sst.cloudflare.Astro" + url: string + } + ZEN_MODELS1: { + type: "sst.sst.Secret" + value: string + } + ZEN_MODELS2: { + type: "sst.sst.Secret" + value: string } } } -// cloudflare -import * as cloudflare from "@cloudflare/workers-types"; +// 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 - "LogProcessor": cloudflare.Service + Api: cloudflare.Service + AuthApi: cloudflare.Service + AuthStorage: cloudflare.KVNamespace + Bucket: cloudflare.R2Bucket + GatewayKv: cloudflare.KVNamespace + LogProcessor: cloudflare.Service } } import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 30e772b6b..ef70beb69 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.23", + "version": "1.0.55", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/mail/sst-env.d.ts b/packages/console/mail/sst-env.d.ts index 9b9de7327..bd5588217 100644 --- a/packages/console/mail/sst-env.d.ts +++ b/packages/console/mail/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/console/resource/package.json b/packages/console/resource/package.json index 6553feed1..f110f6c2a 100644 --- a/packages/console/resource/package.json +++ b/packages/console/resource/package.json @@ -13,6 +13,9 @@ } }, "devDependencies": { - "@tsconfig/node22": "22.0.2" + "@cloudflare/workers-types": "catalog:", + "@tsconfig/node22": "22.0.2", + "@types/node": "catalog:", + "cloudflare": "5.2.0" } } diff --git a/packages/console/resource/resource.node.ts b/packages/console/resource/resource.node.ts index d7dbb6c6d..f63d7bada 100644 --- a/packages/console/resource/resource.node.ts +++ b/packages/console/resource/resource.node.ts @@ -1 +1,58 @@ -export { Resource } from "sst" +import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptions } from "@cloudflare/workers-types" +import { Resource as ResourceBase } from "sst" +import Cloudflare from "cloudflare" + +export const Resource = new Proxy( + {}, + { + get(_target, prop: keyof typeof ResourceBase) { + const value = ResourceBase[prop] + // @ts-ignore + if ("type" in value && value.type === "sst.cloudflare.Kv") { + const client = new Cloudflare({ + apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value, + }) + // @ts-ignore + const namespaceId = value.namespaceId + const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value + return { + get: (k: string | string[]) => { + const isMulti = Array.isArray(k) + return client.kv.namespaces + .bulkGet(namespaceId, { + keys: Array.isArray(k) ? k : [k], + account_id: accountId, + }) + .then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k])) + }, + put: (k: string, v: string, opts?: KVNamespacePutOptions) => + client.kv.namespaces.values.update(namespaceId, k, { + account_id: accountId, + value: v, + expiration: opts?.expiration, + expiration_ttl: opts?.expirationTtl, + metadata: opts?.metadata, + }), + delete: (k: string) => + client.kv.namespaces.values.delete(namespaceId, k, { + account_id: accountId, + }), + list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> => + client.kv.namespaces.keys + .list(namespaceId, { + account_id: accountId, + prefix: opts?.prefix ?? undefined, + }) + .then((result) => { + return { + keys: result.result, + list_complete: true, + cacheStatus: null, + } + }), + } + } + return value + }, + }, +) as Record<string, any> diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 01407434e..bcd7c2650 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -6,99 +6,108 @@ 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 - } - "Console": { - "type": "sst.cloudflare.SolidStart" - "url": string - } - "Database": { - "database": string - "host": string - "password": string - "port": number - "type": "sst.sst.Linkable" - "username": string - } - "Desktop": { - "type": "sst.cloudflare.StaticSite" - "url": string - } - "EMAILOCTOPUS_API_KEY": { - "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 - } - "STRIPE_SECRET_KEY": { - "type": "sst.sst.Secret" - "value": string - } - "STRIPE_WEBHOOK_SECRET": { - "type": "sst.sst.Linkable" - "value": string - } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string - } - "ZEN_MODELS1": { - "type": "sst.sst.Secret" - "value": string - } - "ZEN_MODELS2": { - "type": "sst.sst.Secret" - "value": string + 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 + } + Database: { + database: string + host: string + password: string + port: number + type: "sst.sst.Linkable" + username: string + } + Desktop: { + type: "sst.cloudflare.StaticSite" + url: string + } + EMAILOCTOPUS_API_KEY: { + 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 + } + STRIPE_SECRET_KEY: { + type: "sst.sst.Secret" + value: string + } + STRIPE_WEBHOOK_SECRET: { + type: "sst.sst.Linkable" + value: string + } + Web: { + type: "sst.cloudflare.Astro" + url: string + } + ZEN_MODELS1: { + type: "sst.sst.Secret" + value: string + } + ZEN_MODELS2: { + type: "sst.sst.Secret" + value: string } } } -// cloudflare -import * as cloudflare from "@cloudflare/workers-types"; +// 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 - "LogProcessor": cloudflare.Service + Api: cloudflare.Service + AuthApi: cloudflare.Service + AuthStorage: cloudflare.KVNamespace + Bucket: cloudflare.R2Bucket + GatewayKv: cloudflare.KVNamespace + LogProcessor: cloudflare.Service } } import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 341884453..25b278c48 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.23", + "version": "1.0.55", "description": "", "type": "module", "scripts": { @@ -31,6 +31,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", + "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.3", "@thisbeyond/solid-dnd": "0.7.5", @@ -45,9 +46,5 @@ "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:" - }, - "prettier": { - "semi": false, - "printWidth": 120 } } diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx deleted file mode 100644 index 325f7b635..000000000 --- a/packages/desktop/src/components/code.tsx +++ /dev/null @@ -1,846 +0,0 @@ -import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki" -import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" -import { useLocal, type TextSelection } from "@/context/local" -import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" -import { useShiki } from "@opencode-ai/ui" - -type DefinedSelection = Exclude<TextSelection, undefined> - -interface Props extends ComponentProps<"div"> { - code: string - path: string -} - -export function Code(props: Props) { - const ctx = useLocal() - const highlighter = useShiki() - const [local, others] = splitProps(props, ["class", "classList", "code", "path"]) - const lang = createMemo(() => { - const ext = getFileExtension(local.path) - if (ext in bundledLanguages) return ext - return "text" - }) - - let container: HTMLDivElement | undefined - let isProgrammaticSelection = false - - const ranges = createMemo<DefinedSelection[]>(() => { - const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }> - const result: DefinedSelection[] = [] - for (const item of items) { - if (item.path !== local.path) continue - const selection = item.selection - if (!selection) continue - result.push(selection) - } - return result - }) - - const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => { - const highlighted = new Set<number>() - for (const selection of selections) { - const startLine = selection.startLine - const endLine = selection.endLine - const start = Math.max(1, Math.min(startLine, endLine)) - const end = Math.max(start, Math.max(startLine, endLine)) - const count = end - start + 1 - if (count <= 0) continue - const values = Array.from({ length: count }, (_, index) => start + index) - for (const value of values) highlighted.add(value) - } - return { - name: "line-number-highlight", - line(node, index) { - if (!highlighted.has(index)) return - this.addClassToHast(node, "line-number-highlight") - const children = node.children - if (!Array.isArray(children)) return - for (const child of children) { - if (!child || typeof child !== "object") continue - const element = child as { type?: string; properties?: { className?: string[] } } - if (element.type !== "element") continue - const className = element.properties?.className - if (!Array.isArray(className)) continue - const matches = className.includes("diff-oldln") || className.includes("diff-newln") - if (!matches) continue - if (className.includes("line-number-highlight")) continue - className.push("line-number-highlight") - } - }, - } - } - - const [html] = createResource( - () => ranges(), - async (activeRanges) => { - if (!highlighter.getLoadedLanguages().includes(lang())) { - await highlighter.loadLanguage(lang() as BundledLanguage) - } - return highlighter.codeToHtml(local.code || "", { - lang: lang() && lang() in bundledLanguages ? lang() : "text", - theme: "opencode", - transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)], - }) as string - }, - ) - - onMount(() => { - if (!container) return - - let ticking = false - const onScroll = () => { - if (!container) return - // if (ctx.file.active()?.path !== local.path) return - if (ticking) return - ticking = true - requestAnimationFrame(() => { - ticking = false - ctx.file.scroll(local.path, container!.scrollTop) - }) - } - - const onSelectionChange = () => { - if (!container) return - if (isProgrammaticSelection) return - // if (ctx.file.active()?.path !== local.path) return - const d = getSelectionInContainer(container) - if (!d) return - const p = ctx.file.node(local.path)?.selection - if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return - ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech }) - } - - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - const onKeyDown = (e: KeyboardEvent) => { - // if (ctx.file.active()?.path !== local.path) return - const ae = document.activeElement as HTMLElement | undefined - const tag = (ae?.tagName || "").toLowerCase() - const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable) - if (inputFocused) return - if (e.getModifierState(MOD) && e.key.toLowerCase() === "a") { - e.preventDefault() - if (!container) return - const element = container.querySelector("code") as HTMLElement | undefined - if (!element) return - const lines = Array.from(element.querySelectorAll(".line")) - if (!lines.length) return - const r = document.createRange() - const last = lines[lines.length - 1] - r.selectNodeContents(last) - const lastLen = r.toString().length - ctx.file.select(local.path, { startLine: 1, startChar: 0, endLine: lines.length, endChar: lastLen }) - } - } - - container.addEventListener("scroll", onScroll) - document.addEventListener("selectionchange", onSelectionChange) - document.addEventListener("keydown", onKeyDown) - - onCleanup(() => { - container?.removeEventListener("scroll", onScroll) - document.removeEventListener("selectionchange", onSelectionChange) - document.removeEventListener("keydown", onKeyDown) - }) - }) - - // Restore scroll position from store when content is ready - createEffect(() => { - const content = html() - if (!container || !content) return - const top = ctx.file.node(local.path)?.scrollTop - if (top !== undefined && container.scrollTop !== top) container.scrollTop = top - }) - - // Sync selection from store -> DOM - createEffect(() => { - const content = html() - if (!container || !content) return - // if (ctx.file.active()?.path !== local.path) return - const codeEl = container.querySelector("code") as HTMLElement | undefined - if (!codeEl) return - const target = ctx.file.node(local.path)?.selection - const current = getSelectionInContainer(container) - const sel = window.getSelection() - if (!sel) return - if (!target) { - if (current) { - isProgrammaticSelection = true - sel.removeAllRanges() - queueMicrotask(() => { - isProgrammaticSelection = false - }) - } - return - } - const matches = !!( - current && - current.sl === target.startLine && - current.sch === target.startChar && - current.el === target.endLine && - current.ech === target.endChar - ) - if (matches) return - const lines = Array.from(codeEl.querySelectorAll(".line")) - if (lines.length === 0) return - let sIdx = Math.max(0, target.startLine - 1) - let eIdx = Math.max(0, target.endLine - 1) - let sChar = Math.max(0, target.startChar || 0) - let eChar = Math.max(0, target.endChar || 0) - if (sIdx > eIdx || (sIdx === eIdx && sChar > eChar)) { - const ti = sIdx - sIdx = eIdx - eIdx = ti - const tc = sChar - sChar = eChar - eChar = tc - } - if (eChar === 0 && eIdx > sIdx) { - eIdx = eIdx - 1 - eChar = Number.POSITIVE_INFINITY - } - if (sIdx >= lines.length) return - if (eIdx >= lines.length) eIdx = lines.length - 1 - const s = getNodeOffsetInLine(lines[sIdx], sChar) ?? { node: lines[sIdx], offset: 0 } - const e = getNodeOffsetInLine(lines[eIdx], eChar) ?? { node: lines[eIdx], offset: lines[eIdx].childNodes.length } - const range = document.createRange() - range.setStart(s.node, s.offset) - range.setEnd(e.node, e.offset) - isProgrammaticSelection = true - sel.removeAllRanges() - sel.addRange(range) - queueMicrotask(() => { - isProgrammaticSelection = false - }) - }) - - // Build/toggle split layout and apply folding (both unified and split) - createEffect(() => { - const content = html() - if (!container || !content) return - const view = ctx.file.view(local.path) - - const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre")) - if (pres.length === 0) return - const originalPre = pres[0] - - const split = container.querySelector<HTMLElement>(".diff-split") - if (view === "diff-split") { - applySplitDiff(container) - const next = container.querySelector<HTMLElement>(".diff-split") - if (next) next.style.display = "" - originalPre.style.display = "none" - } else { - if (split) split.style.display = "none" - originalPre.style.display = "" - } - - const expanded = ctx.file.folded(local.path) - if (view === "diff-split") { - const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code") - const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code") - if (left) - applyDiffFolding(left, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "left" }) - if (right) - applyDiffFolding(right, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "right" }) - } else { - const code = container.querySelector<HTMLElement>("pre code") - if (code) - applyDiffFolding(code, 3, { - expanded, - onExpand: (key) => ctx.file.unfold(local.path, key), - }) - } - }) - - // Highlight groups + scroll coupling - const clearHighlights = () => { - if (!container) return - container.querySelectorAll<HTMLElement>(".diff-selected").forEach((el) => el.classList.remove("diff-selected")) - } - - const applyHighlight = (idx: number, scroll?: boolean) => { - if (!container) return - const view = ctx.file.view(local.path) - if (view === "raw") return - - clearHighlights() - - const nodes: HTMLElement[] = [] - if (view === "diff-split") { - const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code") - const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code") - if (left) - nodes.push(...Array.from(left.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="remove"]`))) - if (right) - nodes.push(...Array.from(right.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="add"]`))) - } else { - const code = container.querySelector<HTMLElement>("pre code") - if (code) nodes.push(...Array.from(code.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"]`))) - } - - for (const n of nodes) n.classList.add("diff-selected") - if (scroll && nodes.length) nodes[0].scrollIntoView({ block: "center", behavior: "smooth" }) - } - - const countGroups = () => { - if (!container) return 0 - const code = container.querySelector<HTMLElement>("pre code") - if (!code) return 0 - const set = new Set<string>() - for (const el of Array.from(code.querySelectorAll<HTMLElement>(".diff-line[data-chgrp]"))) { - const v = el.getAttribute("data-chgrp") - if (v != undefined) set.add(v) - } - return set.size - } - - let lastIdx: number | undefined = undefined - let lastView: string | undefined - let lastContent: string | undefined - let lastRawIdx: number | undefined = undefined - createEffect(() => { - const content = html() - if (!container || !content) return - const view = ctx.file.view(local.path) - const raw = ctx.file.changeIndex(local.path) - if (raw === undefined) return - const total = countGroups() - if (total <= 0) return - const next = ((raw % total) + total) % total - - const navigated = lastRawIdx !== undefined && lastRawIdx !== raw - - if (next !== raw) { - ctx.file.setChangeIndex(local.path, next) - applyHighlight(next, true) - } else { - if (lastView !== view || lastContent !== content) applyHighlight(next) - if ((lastIdx !== undefined && lastIdx !== next) || navigated) applyHighlight(next, true) - } - - lastRawIdx = raw - lastIdx = next - lastView = view - lastContent = content - }) - - return ( - <div - ref={(el) => { - container = el - }} - innerHTML={html()} - class=" - font-mono text-xs tracking-wide overflow-y-auto h-full - [&]:[counter-reset:line] - [&_pre]:focus-visible:outline-none - [&_pre]:overflow-x-auto [&_pre]:no-scrollbar - [&_code]:min-w-full [&_code]:inline-block - [&_.tab]:relative - [&_.tab::before]:content['⇥'] - [&_.tab::before]:absolute - [&_.tab::before]:opacity-0 - [&_.space]:relative - [&_.space::before]:content-['·'] - [&_.space::before]:absolute - [&_.space::before]:opacity-0 - [&_.line]:inline-block [&_.line]:w-full - [&_.line]:hover:bg-background-element - [&_.line::before]:sticky [&_.line::before]:left-0 - [&_.line::before]:w-12 [&_.line::before]:pr-4 - [&_.line::before]:z-10 - [&_.line::before]:bg-background-panel - [&_.line::before]:text-text-muted/60 - [&_.line::before]:text-right [&_.line::before]:inline-block - [&_.line::before]:select-none - [&_.line::before]:[counter-increment:line] - [&_.line::before]:content-[counter(line)] - [&_.line-number-highlight]:bg-accent/20 - [&_.line-number-highlight::before]:bg-accent/40! - [&_.line-number-highlight::before]:text-background-panel! - [&_code.code-diff_.line::before]:content-[''] - [&_code.code-diff_.line::before]:w-0 - [&_code.code-diff_.line::before]:pr-0 - [&_.diff-split_code.code-diff::before]:w-10 - [&_.diff-split_.diff-newln]:left-0 - [&_.diff-oldln]:sticky [&_.diff-oldln]:left-0 - [&_.diff-oldln]:w-10 [&_.diff-oldln]:pr-2 - [&_.diff-oldln]:z-40 - [&_.diff-oldln]:text-text-muted/60 - [&_.diff-oldln]:text-right [&_.diff-oldln]:inline-block - [&_.diff-oldln]:select-none - [&_.diff-oldln]:bg-background-panel - [&_.diff-newln]:sticky [&_.diff-newln]:left-10 - [&_.diff-newln]:w-10 [&_.diff-newln]:pr-2 - [&_.diff-newln]:z-40 - [&_.diff-newln]:text-text-muted/60 - [&_.diff-newln]:text-right [&_.diff-newln]:inline-block - [&_.diff-newln]:select-none - [&_.diff-newln]:bg-background-panel - [&_.diff-add]:bg-success/20! - [&_.diff-add.diff-selected]:bg-success/50! - [&_.diff-add_.diff-oldln]:bg-success! - [&_.diff-add_.diff-oldln]:text-background-panel! - [&_.diff-add_.diff-newln]:bg-success! - [&_.diff-add_.diff-newln]:text-background-panel! - [&_.diff-remove]:bg-error/20! - [&_.diff-remove.diff-selected]:bg-error/50! - [&_.diff-remove_.diff-newln]:bg-error! - [&_.diff-remove_.diff-newln]:text-background-panel! - [&_.diff-remove_.diff-oldln]:bg-error! - [&_.diff-remove_.diff-oldln]:text-background-panel! - [&_.diff-sign]:inline-block [&_.diff-sign]:px-2 [&_.diff-sign]:select-none - [&_.diff-blank]:bg-background-element - [&_.diff-blank_.diff-oldln]:bg-background-element - [&_.diff-blank_.diff-newln]:bg-background-element - [&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative - [&_.diff-collapsed]:select-none - [&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40! - [&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info - [&_.diff-collapsed]:text-xs - [&_.diff-collapsed_.diff-oldln]:bg-info! - [&_.diff-collapsed_.diff-newln]:bg-info! - " - classList={{ - ...(local.classList || {}), - [local.class ?? ""]: !!local.class, - }} - {...others} - ></div> - ) -} - -function transformerUnifiedDiff(): ShikiTransformer { - const kinds = new Map<number, string>() - const meta = new Map<number, { old?: number; new?: number; sign?: string }>() - let isDiff = false - - return { - name: "unified-diff", - preprocess(input) { - kinds.clear() - meta.clear() - isDiff = false - - const ls = input.split(/\r?\n/) - const out: Array<string> = [] - let oldNo = 0 - let newNo = 0 - let inHunk = false - - for (let i = 0; i < ls.length; i++) { - const s = ls[i] - - const m = s.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/) - if (m) { - isDiff = true - inHunk = true - oldNo = parseInt(m[1], 10) - newNo = parseInt(m[3], 10) - continue - } - - if ( - /^diff --git /.test(s) || - /^Index: /.test(s) || - /^--- /.test(s) || - /^\+\+\+ /.test(s) || - /^[=]{3,}$/.test(s) || - /^\*{3,}$/.test(s) || - /^\\ No newline at end of file$/.test(s) - ) { - isDiff = true - continue - } - - if (!inHunk) { - out.push(s) - continue - } - - if (/^\+/.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "add") - meta.set(ln, { new: newNo, sign: "+" }) - newNo++ - continue - } - - if (/^-/.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "remove") - meta.set(ln, { old: oldNo, sign: "-" }) - oldNo++ - continue - } - - if (/^ /.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "context") - meta.set(ln, { old: oldNo, new: newNo }) - oldNo++ - newNo++ - continue - } - - // fallback in hunks - out.push(s) - } - - return out.join("\n").trimEnd() - }, - code(node) { - if (isDiff) this.addClassToHast(node, "code-diff") - }, - pre(node) { - if (isDiff) this.addClassToHast(node, "code-diff") - }, - line(node, line) { - if (!isDiff) return - const kind = kinds.get(line) - if (!kind) return - - const m = meta.get(line) || {} - - this.addClassToHast(node, "diff-line") - this.addClassToHast(node, `diff-${kind}`) - node.properties = node.properties || {} - ;(node.properties as any)["data-diff"] = kind - if (m.old != undefined) (node.properties as any)["data-old"] = String(m.old) - if (m.new != undefined) (node.properties as any)["data-new"] = String(m.new) - - const oldSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-oldln"] }, - children: [{ type: "text", value: m.old != undefined ? String(m.old) : " " }], - } - const newSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-newln"] }, - children: [{ type: "text", value: m.new != undefined ? String(m.new) : " " }], - } - - if (kind === "add" || kind === "remove" || kind === "context") { - const first = (node.children && (node.children as any[])[0]) as any - if (first && first.type === "element" && first.children && first.children.length > 0) { - const t = first.children[0] - if (t && t.type === "text" && typeof t.value === "string" && t.value.length > 0) { - const ch = t.value[0] - if (ch === "+" || ch === "-" || ch === " ") t.value = t.value.slice(1) - } - } - } - - const signSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-sign"] }, - children: [{ type: "text", value: (m as any).sign || " " }], - } - - // @ts-expect-error hast typing across versions - node.children = [oldSpan, newSpan, signSpan, ...(node.children || [])] - }, - } -} - -function transformerDiffGroups(): ShikiTransformer { - let group = -1 - let inGroup = false - return { - name: "diff-groups", - pre() { - group = -1 - inGroup = false - }, - line(node) { - const props = (node.properties || {}) as any - const kind = props["data-diff"] as string | undefined - if (kind === "add" || kind === "remove") { - if (!inGroup) { - group += 1 - inGroup = true - } - ;(node.properties as any)["data-chgrp"] = String(group) - } else { - inGroup = false - } - }, - } -} - -function applyDiffFolding( - root: HTMLElement, - context = 3, - options?: { expanded?: string[]; onExpand?: (key: string) => void; side?: "left" | "right" }, -) { - if (!root.classList.contains("code-diff")) return - - // Cleanup: unwrap previous collapsed blocks and remove toggles - const blocks = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed-block")) - for (const block of blocks) { - const p = block.parentNode - if (!p) { - block.remove() - continue - } - while (block.firstChild) p.insertBefore(block.firstChild, block) - block.remove() - } - const toggles = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed")) - for (const t of toggles) t.remove() - - const lines = Array.from(root.querySelectorAll<HTMLElement>(".diff-line")) - if (lines.length === 0) return - - const n = lines.length - const isChange = lines.map((l) => l.dataset["diff"] === "add" || l.dataset["diff"] === "remove") - const isContext = lines.map((l) => l.dataset["diff"] === "context") - if (!isChange.some(Boolean)) return - - const visible = new Array(n).fill(false) as boolean[] - for (let i = 0; i < n; i++) if (isChange[i]) visible[i] = true - for (let i = 0; i < n; i++) { - if (isChange[i]) { - const s = Math.max(0, i - context) - const e = Math.min(n - 1, i + context) - for (let j = s; j <= e; j++) if (isContext[j]) visible[j] = true - } - } - - type Range = { start: number; end: number } - const ranges: Range[] = [] - let i = 0 - while (i < n) { - if (!visible[i] && isContext[i]) { - let j = i - while (j + 1 < n && !visible[j + 1] && isContext[j + 1]) j++ - ranges.push({ start: i, end: j }) - i = j + 1 - } else { - i++ - } - } - - for (const r of ranges) { - const start = lines[r.start] - const end = lines[r.end] - const count = r.end - r.start + 1 - const minCollapse = 20 - if (count < minCollapse) { - continue - } - - // Wrap the entire collapsed chunk (including trailing newline) so it takes no space - const block = document.createElement("span") - block.className = "diff-collapsed-block" - start.parentElement?.insertBefore(block, start) - - let cur: Node | undefined = start - while (cur) { - const next: Node | undefined = cur.nextSibling || undefined - block.appendChild(cur) - if (cur === end) { - // Also move the newline after the last line into the block - if (next && next.nodeType === Node.TEXT_NODE && (next.textContent || "").startsWith("\n")) { - block.appendChild(next) - } - break - } - cur = next - } - - block.style.display = "none" - const row = document.createElement("span") - row.className = "line diff-collapsed" - row.setAttribute("data-kind", "collapsed") - row.setAttribute("data-count", String(count)) - row.setAttribute("tabindex", "0") - row.setAttribute("role", "button") - - const oldln = document.createElement("span") - oldln.className = "diff-oldln" - oldln.textContent = " " - - const newln = document.createElement("span") - newln.className = "diff-newln" - newln.textContent = " " - - const sign = document.createElement("span") - sign.className = "diff-sign" - sign.textContent = "…" - - const label = document.createElement("span") - label.textContent = `show ${count} unchanged line${count > 1 ? "s" : ""}` - - const key = `o${start.dataset["old"] || ""}-${end.dataset["old"] || ""}:n${start.dataset["new"] || ""}-${end.dataset["new"] || ""}` - - const show = (record = true) => { - if (record) options?.onExpand?.(key) - const p = block.parentNode - if (p) { - while (block.firstChild) p.insertBefore(block.firstChild, block) - block.remove() - } - row.remove() - } - - row.addEventListener("click", () => show(true)) - row.addEventListener("keydown", (ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault() - show(true) - } - }) - - block.parentElement?.insertBefore(row, block) - if (!options?.side || options.side === "left") row.appendChild(oldln) - if (!options?.side || options.side === "right") row.appendChild(newln) - row.appendChild(sign) - row.appendChild(label) - - if (options?.expanded && options.expanded.includes(key)) { - show(false) - } - } -} - -function applySplitDiff(container: HTMLElement) { - const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre")) - if (pres.length === 0) return - const originalPre = pres[0] - const originalCode = originalPre.querySelector("code") as HTMLElement | undefined - if (!originalCode || !originalCode.classList.contains("code-diff")) return - - // Rebuild split each time to match current content - const existing = container.querySelector<HTMLElement>(".diff-split") - if (existing) existing.remove() - - const grid = document.createElement("div") - grid.className = "diff-split grid grid-cols-2 gap-x-6" - - const makeColumn = () => { - const pre = document.createElement("pre") - pre.className = originalPre.className - const code = document.createElement("code") - code.className = originalCode.className - pre.appendChild(code) - return { pre, code } - } - - const left = makeColumn() - const right = makeColumn() - - // Helpers - const cloneSide = (line: HTMLElement, side: "old" | "new"): HTMLElement => { - const clone = line.cloneNode(true) as HTMLElement - const oldln = clone.querySelector(".diff-oldln") - const newln = clone.querySelector(".diff-newln") - if (side === "old") { - if (newln) newln.remove() - } else { - if (oldln) oldln.remove() - } - return clone - } - - const blankLine = (side: "old" | "new", kind: "add" | "remove"): HTMLElement => { - const span = document.createElement("span") - span.className = "line diff-line diff-blank" - span.setAttribute("data-diff", kind) - const ln = document.createElement("span") - ln.className = side === "old" ? "diff-oldln" : "diff-newln" - ln.textContent = " " - span.appendChild(ln) - return span - } - - const lines = Array.from(originalCode.querySelectorAll<HTMLElement>(".diff-line")) - let i = 0 - while (i < lines.length) { - const cur = lines[i] - const kind = cur.dataset["diff"] - - if (kind === "context") { - left.code.appendChild(cloneSide(cur, "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(cur, "new")) - right.code.appendChild(document.createTextNode("\n")) - i++ - continue - } - - if (kind === "remove") { - // Batch consecutive removes and following adds, then pair - const removes: HTMLElement[] = [] - const adds: HTMLElement[] = [] - let j = i - while (j < lines.length && lines[j].dataset["diff"] === "remove") { - removes.push(lines[j]) - j++ - } - let k = j - while (k < lines.length && lines[k].dataset["diff"] === "add") { - adds.push(lines[k]) - k++ - } - - const pairs = Math.min(removes.length, adds.length) - for (let p = 0; p < pairs; p++) { - left.code.appendChild(cloneSide(removes[p], "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - for (let p = pairs; p < removes.length; p++) { - left.code.appendChild(cloneSide(removes[p], "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(blankLine("new", "remove")) - right.code.appendChild(document.createTextNode("\n")) - } - for (let p = pairs; p < adds.length; p++) { - left.code.appendChild(blankLine("old", "add")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - - i = k - continue - } - - if (kind === "add") { - // Run of adds not preceded by removes - const adds: HTMLElement[] = [] - let j = i - while (j < lines.length && lines[j].dataset["diff"] === "add") { - adds.push(lines[j]) - j++ - } - for (let p = 0; p < adds.length; p++) { - left.code.appendChild(blankLine("old", "add")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - i = j - continue - } - - // Any other kind: mirror as context - left.code.appendChild(cloneSide(cur, "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(cur, "new")) - right.code.appendChild(document.createTextNode("\n")) - i++ - } - - grid.appendChild(left.pre) - grid.appendChild(right.pre) - container.appendChild(grid) -} diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index a5d19f51e..1347ecae6 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -77,7 +77,7 @@ export default function FileTree(props: { <Collapsible class="w-full" forceMount={false} - open={local.file.node(node.path)?.expanded} + // open={local.file.node(node.path)?.expanded} onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))} > <Collapsible.Trigger> @@ -85,7 +85,7 @@ export default function FileTree(props: { <Collapsible.Arrow class="text-text-muted/60 ml-1" /> <FileIcon node={node} - expanded={local.file.node(node.path).expanded} + // expanded={local.file.node(node.path).expanded} class="text-text-muted/60 -ml-1" /> </Node> diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx index c0037f57c..a9be2ae5e 100644 --- a/packages/desktop/src/components/message-progress.tsx +++ b/packages/desktop/src/components/message-progress.tsx @@ -70,9 +70,8 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) const rawStatus = createMemo(() => { - const defaultStatus = "Working..." const last = lastPart() - if (!last) return defaultStatus + if (!last) return undefined if (last.type === "tool") { switch (last.tool) { @@ -102,7 +101,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa } else if (last.type === "text") { return "Gathering thoughts..." } - return defaultStatus + return undefined }) const [status, setStatus] = createSignal(rawStatus()) @@ -111,11 +110,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa createEffect(() => { const newStatus = rawStatus() - if (newStatus === status()) return + if (newStatus === status() || !newStatus) return const timeSinceLastChange = Date.now() - lastStatusChange - if (timeSinceLastChange >= 1000) { + if (timeSinceLastChange >= 1500) { setStatus(newStatus) lastStatusChange = Date.now() if (statusTimeout) { @@ -145,7 +144,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa {/* )} */} {/* </Show> */} <div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base"> - <Spinner /> <span class="text-12-medium">{status()}</span> + <Spinner /> <span class="text-12-medium">{status() ?? "Considering next steps..."}</span> </div> <Show when={eligibleItems().length > 0}> <div diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 3d9622cbe..5ae56f827 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,51 +1,41 @@ -import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui" +import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui" import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { FileIcon } from "@/ui" import { getDirectory, getFilename } from "@/utils" import { createFocusSignal } from "@solid-primitives/active-element" -import { TextSelection, useLocal } from "@/context/local" +import { useLocal } from "@/context/local" import { DateTime } from "luxon" - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { useSDK } from "@/context/sdk" +import { useNavigate } from "@solidjs/router" +import { useSync } from "@/context/sync" interface PromptInputProps { - onSubmit: (parts: ContentPart[]) => void class?: string ref?: (el: HTMLDivElement) => void } export const PromptInput: Component<PromptInputProps> = (props) => { + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() const local = useLocal() + const session = useSession() let editorRef!: HTMLDivElement - const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const] const [store, setStore] = createStore<{ - contentParts: ContentPart[] popoverIsOpen: boolean }>({ - contentParts: defaultParts, popoverIsOpen: false, }) - const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts)) + createEffect(() => { + session.id + editorRef.focus() + }) + const isFocused = createFocusSignal(() => editorRef) const handlePaste = (event: ClipboardEvent) => { @@ -71,14 +61,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) + const handleFileSelect = (path: string | undefined) => { + if (!path) return + addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 }) + setStore("popoverIsOpen", false) + } + const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({ items: local.file.searchFilesAndDirectories, key: (x) => x, - onSelect: (path) => { - if (!path) return - addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 }) - setStore("popoverIsOpen", false) - }, + onSelect: handleFileSelect, }) createEffect(() => { @@ -88,10 +80,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { createEffect( on( - () => store.contentParts, + () => session.prompt.current(), (currentParts) => { const domParts = parseFromDOM() - if (isEqual(currentParts, domParts)) return + if (isPromptEqual(currentParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null @@ -122,8 +114,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ), ) - const parseFromDOM = (): ContentPart[] => { - const newParts: ContentPart[] = [] + createEffect( + on( + () => session.prompt.cursor(), + (cursor) => { + if (cursor === undefined) return + queueMicrotask(() => setCursorPosition(editorRef, cursor)) + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const newParts: Prompt = [] let position = 0 editorRef.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -150,7 +152,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } }) - if (newParts.length === 0) newParts.push(...defaultParts) + if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT) return newParts } @@ -167,12 +169,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("popoverIsOpen", false) } - setStore("contentParts", rawParts) + session.prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { const cursorPosition = getCursorPosition(editorRef) - const rawText = store.contentParts.map((p) => p.content).join("") + const prompt = session.prompt.current() + const rawText = prompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -198,7 +201,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { parts: nextParts, inserted, cursorPositionAfter, - } = store.contentParts.reduce( + } = prompt.reduce( (acc, item) => { if (acc.inserted) { acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length }) @@ -257,7 +260,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) if (!inserted) { - const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === "")) + const baseParts = prompt.filter((item) => !(item.type === "text" && item.content === "")) const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0) const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex } if (part.type === "text") { @@ -270,20 +273,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => { end: appendedAcc.runningIndex + part.content.length, }) } - const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts - setStore("contentParts", next) - setStore("popoverIsOpen", false) + const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT const nextCursor = rawText.length + part.content.length + session.prompt.set(next, nextCursor) + setStore("popoverIsOpen", false) queueMicrotask(() => setCursorPosition(editorRef, nextCursor)) return } - setStore("contentParts", nextParts) + session.prompt.set(nextParts, cursorPositionAfter) setStore("popoverIsOpen", false) queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter)) } + const abort = () => + sdk.client.session.abort({ + path: { + id: session.id!, + }, + }) + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) @@ -293,14 +303,100 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } + if (event.key === "Escape") { + if (store.popoverIsOpen) { + setStore("popoverIsOpen", false) + } else if (session.working()) { + abort() + } + } } - const handleSubmit = (event: Event) => { + const handleSubmit = async (event: Event) => { event.preventDefault() - if (store.contentParts.length > 0) { - props.onSubmit([...store.contentParts]) - setStore("contentParts", defaultParts) + const prompt = session.prompt.current() + const text = prompt.map((part) => part.content).join("") + if (text.trim().length === 0) { + if (session.working()) abort() + return } + + let existing = session.info() + if (!existing) { + const created = await sdk.client.session.create() + existing = created.data ?? undefined + if (existing) navigate(`/session/${existing.id}`) + } + if (!existing) return + + // if (!session.id) { + // session.layout.setOpenedTabs( + // session.layout.copyTabs("", session.id) + // } + + const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) + const attachments = prompt.filter((part) => part.type === "file") + + // const activeFile = local.context.active() + // if (activeFile) { + // registerAttachment( + // activeFile.path, + // activeFile.selection, + // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), + // ) + // } + + // for (const contextFile of local.context.all()) { + // registerAttachment( + // contextFile.path, + // contextFile.selection, + // formatAttachmentLabel(contextFile.path, contextFile.selection), + // ) + // } + + const attachmentParts = attachments.map((attachment) => { + const absolute = toAbsolutePath(attachment.path) + const query = attachment.selection + ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` + : "" + return { + type: "file" as const, + mime: "text/plain", + url: `file://${absolute}${query}`, + filename: getFilename(attachment.path), + source: { + type: "file" as const, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path: absolute, + }, + } + }) + + session.layout.setActiveTab(undefined) + session.messages.setActive(undefined) + session.prompt.set(DEFAULT_PROMPT, 0) + + sdk.client.session.prompt({ + path: { id: existing.id }, + body: { + agent: local.agent.current()!.name, + model: { + modelID: local.model.current()!.id, + providerID: local.model.current()!.provider.id, + }, + parts: [ + { + type: "text", + text, + }, + ...attachmentParts, + ], + }, + }) } return ( @@ -310,11 +406,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}> <For each={flat()}> {(i) => ( - <div + <button classList={{ "w-full flex items-center justify-between rounded-md": true, "bg-surface-raised-base-hover": active() === i, }} + onClick={() => handleFileSelect(i)} > <div class="flex items-center gap-x-2 grow min-w-0"> <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> @@ -326,7 +423,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> </div> <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </div> + </button> )} </For> </Show> @@ -354,7 +451,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - <Show when={isEmpty()}> + <Show when={!session.prompt.dirty()}> <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none"> Plan and build anything </div> @@ -419,29 +516,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => { )} </SelectDialog> </div> - <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" /> + <Tooltip + placement="top" + value={ + <Switch> + <Match when={session.working()}> + <div class="flex items-center gap-2"> + <span>Stop</span> + <span class="text-icon-base text-12-medium text-[10px]!">ESC</span> + </div> + </Match> + <Match when={true}> + <div class="flex items-center gap-2"> + <span>Send</span> + <Icon name="enter" size="small" class="text-icon-base" /> + </div> + </Match> + </Switch> + } + > + <IconButton + type="submit" + disabled={!session.prompt.dirty() && !session.working()} + icon={session.working() ? "stop" : "arrow-up"} + variant="primary" + class="rounded-full" + /> + </Tooltip> </div> </form> </div> ) } -function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean { - if (arrA.length !== arrB.length) return false - for (let i = 0; i < arrA.length; i++) { - const partA = arrA[i] - const partB = arrB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 diff --git a/packages/desktop/src/components/theme.json b/packages/desktop/src/components/theme.json deleted file mode 100644 index 36a4d020d..000000000 --- a/packages/desktop/src/components/theme.json +++ /dev/null @@ -1,558 +0,0 @@ -{ - "colors": { - "actionBar.toggledBackground": "var(--surface-raised-base)", - "activityBarBadge.background": "var(--surface-brand-base)", - "checkbox.border": "var(--border-base)", - "editor.background": "transparent", - "editor.foreground": "var(--text-base)", - "editor.inactiveSelectionBackground": "var(--surface-raised-base)", - "editor.selectionHighlightBackground": "var(--border-active)", - "editorIndentGuide.activeBackground1": "var(--border-weak-base)", - "editorIndentGuide.background1": "var(--border-weak-base)", - "input.placeholderForeground": "var(--text-weak)", - "list.activeSelectionIconForeground": "var(--text-base)", - "list.dropBackground": "var(--surface-raised-base)", - "menu.background": "var(--surface-base)", - "menu.border": "var(--border-base)", - "menu.foreground": "var(--text-base)", - "menu.selectionBackground": "var(--surface-interactive-base)", - "menu.separatorBackground": "var(--border-base)", - "ports.iconRunningProcessForeground": "var(--icon-success-base)", - "sideBarSectionHeader.background": "transparent", - "sideBarSectionHeader.border": "var(--border-weak-base)", - "sideBarTitle.foreground": "var(--text-weak)", - "statusBarItem.remoteBackground": "var(--surface-success-base)", - "statusBarItem.remoteForeground": "var(--text-base)", - "tab.lastPinnedBorder": "var(--border-weak-base)", - "tab.selectedBackground": "var(--surface-raised-base)", - "tab.selectedForeground": "var(--text-weak)", - "terminal.inactiveSelectionBackground": "var(--surface-raised-base)", - "widget.border": "var(--border-base)" - }, - "displayName": "opencode", - "name": "opencode", - "semanticHighlighting": true, - "semanticTokenColors": { - "customLiteral": "var(--syntax-function)", - "newOperator": "var(--syntax-operator)", - "numberLiteral": "var(--syntax-number)", - "stringLiteral": "var(--syntax-string)" - }, - "tokenColors": [ - { - "scope": [ - "meta.embedded", - "source.groovy.embedded", - "string meta.image.inline.markdown", - "variable.legacy.builtin.python" - ], - "settings": { - "foreground": "var(--text-base)" - } - }, - { - "scope": "emphasis", - "settings": { - "fontStyle": "italic" - } - }, - { - "scope": "strong", - "settings": { - "fontStyle": "bold" - } - }, - { - "scope": "header", - "settings": { - "foreground": "var(--markdown-heading)" - } - }, - { - "scope": "comment", - "settings": { - "foreground": "var(--syntax-comment)" - } - }, - { - "scope": "constant.language", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": [ - "constant.numeric", - "variable.other.enummember", - "keyword.operator.plus.exponent", - "keyword.operator.minus.exponent" - ], - "settings": { - "foreground": "var(--syntax-number)" - } - }, - { - "scope": "constant.regexp", - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": "entity.name.tag", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": ["entity.name.tag.css", "entity.name.tag.less"], - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": "entity.other.attribute-name", - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": [ - "entity.other.attribute-name.class.css", - "source.css entity.other.attribute-name.class", - "entity.other.attribute-name.id.css", - "entity.other.attribute-name.parent-selector.css", - "entity.other.attribute-name.parent.less", - "source.css entity.other.attribute-name.pseudo-class", - "entity.other.attribute-name.pseudo-element.css", - "source.css.less entity.other.attribute-name.id", - "entity.other.attribute-name.scss" - ], - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": "invalid", - "settings": { - "foreground": "var(--syntax-critical)" - } - }, - { - "scope": "markup.underline", - "settings": { - "fontStyle": "underline" - } - }, - { - "scope": "markup.bold", - "settings": { - "fontStyle": "bold", - "foreground": "var(--markdown-strong)" - } - }, - { - "scope": "markup.heading", - "settings": { - "fontStyle": "bold", - "foreground": "var(--theme-markdown-heading)" - } - }, - { - "scope": "markup.italic", - "settings": { - "fontStyle": "italic" - } - }, - { - "scope": "markup.strikethrough", - "settings": { - "fontStyle": "strikethrough" - } - }, - { - "scope": "markup.inserted", - "settings": { - "foreground": "var(--text-diff-add-base)" - } - }, - { - "scope": "markup.deleted", - "settings": { - "foreground": "var(--text-diff-delete-base)" - } - }, - { - "scope": "markup.changed", - "settings": { - "foreground": "var(--text-base)" - } - }, - { - "scope": "punctuation.definition.quote.begin.markdown", - "settings": { - "foreground": "var(--markdown-block-quote)" - } - }, - { - "scope": "punctuation.definition.list.begin.markdown", - "settings": { - "foreground": "var(--markdown-list-enumeration)" - } - }, - { - "scope": "markup.inline.raw", - "settings": { - "foreground": "var(--markdown-code)" - } - }, - { - "scope": "punctuation.definition.tag", - "settings": { - "foreground": "var(--syntax-punctuation)" - } - }, - { - "scope": ["meta.preprocessor", "entity.name.function.preprocessor"], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "meta.preprocessor.string", - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": "meta.preprocessor.numeric", - "settings": { - "foreground": "var(--syntax-number)" - } - }, - { - "scope": "meta.structure.dictionary.key.python", - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": "meta.diff.header", - "settings": { - "foreground": "var(--text-weak)" - } - }, - { - "scope": "storage", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "storage.type", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": ["storage.modifier", "keyword.operator.noexcept"], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": ["string", "meta.embedded.assembly"], - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": "string.tag", - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": "string.value", - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": "string.regexp", - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - "punctuation.section.embedded" - ], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": ["meta.template.expression"], - "settings": { - "foreground": "var(--text-base)" - } - }, - { - "scope": [ - "support.type.vendored.property-name", - "support.type.property-name", - "source.css variable", - "source.coffee.embedded" - ], - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": "keyword", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "keyword.control", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "keyword.operator", - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": [ - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.alignof", - "keyword.operator.typeid", - "keyword.operator.alignas", - "keyword.operator.instanceof", - "keyword.operator.logical.python", - "keyword.operator.wordlike" - ], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "keyword.other.unit", - "settings": { - "foreground": "var(--syntax-number)" - } - }, - { - "scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "support.function.git-rebase", - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": "constant.sha.git-rebase", - "settings": { - "foreground": "var(--syntax-number)" - } - }, - { - "scope": ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"], - "settings": { - "foreground": "var(--text-base)" - } - }, - { - "scope": "variable.language", - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal" - ], - "settings": { - "foreground": "var(--syntax-function)" - } - }, - { - "scope": [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy" - ], - "settings": { - "foreground": "var(--syntax-type)" - } - }, - { - "scope": [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby" - ], - "settings": { - "foreground": "var(--syntax-type)" - } - }, - { - "scope": [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator" - ], - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder" - ], - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": ["variable.other.constant", "variable.other.enummember"], - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": ["meta.object-literal.key"], - "settings": { - "foreground": "var(--syntax-variable)" - } - }, - { - "scope": [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color" - ], - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp" - ], - "settings": { - "foreground": "var(--syntax-string)" - } - }, - { - "scope": [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp" - ], - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"], - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": "keyword.operator.quantifier.regexp", - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": ["constant.character", "constant.other.option"], - "settings": { - "foreground": "var(--syntax-keyword)" - } - }, - { - "scope": "constant.character.escape", - "settings": { - "foreground": "var(--syntax-operator)" - } - }, - { - "scope": "entity.name.label", - "settings": { - "foreground": "var(--text-weak)" - } - } - ], - "type": "dark" -} diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 8fc05c452..1785bdf0c 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -5,6 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { useSync } from "./sync" +import { makePersisted } from "@solid-primitives/storage" export type LocalFile = FileNode & Partial<{ @@ -195,18 +196,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const file = (() => { const [store, setStore] = createStore<{ node: Record<string, LocalFile> - // opened: string[] - // active?: string }>({ node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), - // opened: [], }) - // const active = createMemo(() => { - // if (!store.active) return undefined - // return store.node[store.active] - // }) - // const opened = createMemo(() => store.opened.map((x) => store.node[x])) const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) @@ -247,18 +240,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return false } - const resetNode = (path: string) => { - setStore("node", path, { - loaded: undefined, - pinned: undefined, - content: undefined, - selection: undefined, - scrollTop: undefined, - folded: undefined, - view: undefined, - selectedChange: undefined, - }) - } + // const resetNode = (path: string) => { + // setStore("node", path, { + // loaded: undefined, + // pinned: undefined, + // content: undefined, + // selection: undefined, + // scrollTop: undefined, + // folded: undefined, + // view: undefined, + // selectedChange: undefined, + // }) + // } const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") @@ -333,31 +326,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ sdk.event.listen((e) => { const event = e.details switch (event.type) { - case "message.part.updated": - const part = event.properties.part - if (part.type === "tool" && part.state.status === "completed") { - switch (part.tool) { - case "read": - break - case "edit": - // load(part.state.input["filePath"] as string) - break - default: - break - } - } - break case "file.watcher.updated": - // setTimeout(sync.load.changes, 1000) - // const relativePath = relative(event.properties.file) - // if (relativePath.startsWith(".git/")) return - // load(relativePath) + const relativePath = relative(event.properties.file) + if (relativePath.startsWith(".git/")) return + load(relativePath) break } }) return { - node: (path: string) => store.node[path], + node: async (path: string) => { + if (!store.node[path]) { + await init(path) + } + return store.node[path] + }, update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), open, load, @@ -417,121 +400,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ searchFiles, searchFilesAndDirectories, relative, - // active, - // opened, - // close(path: string) { - // setStore("opened", (opened) => opened.filter((x) => x !== path)) - // if (store.active === path) { - // const index = store.opened.findIndex((f) => f === path) - // const previous = store.opened[Math.max(0, index - 1)] - // setStore("active", previous) - // } - // resetNode(path) - // }, - // move(path: string, to: number) { - // const index = store.opened.findIndex((f) => f === path) - // if (index === -1) return - // setStore( - // "opened", - // produce((opened) => { - // opened.splice(to, 0, opened.splice(index, 1)[0]) - // }), - // ) - // setStore("node", path, "pinned", true) - // }, - } - })() - - const session = (() => { - const [store, setStore] = createStore<{ - active?: string - tabs: Record< - string, - { - active?: string - opened: string[] - } - > - }>({ - tabs: { - "": { - opened: [], - }, - }, - }) - - const active = createMemo(() => { - if (!store.active) return undefined - return sync.session.get(store.active) - }) - - createEffect(() => { - if (!store.active) return - sync.session.sync(store.active) - - if (!store.tabs[store.active]) { - setStore("tabs", store.active, { - opened: [], - }) - } - }) - - const tabs = createMemo(() => store.tabs[store.active ?? ""]) - - return { - active, - setActive(sessionId: string | undefined) { - setStore("active", sessionId) - }, - clearActive() { - setStore("active", undefined) - }, - tabs, - copyTabs(from: string, to: string) { - setStore("tabs", to, { - opened: store.tabs[from]?.opened ?? [], - }) - }, - setActiveTab(tab: string | undefined) { - setStore("tabs", store.active ?? "", "active", tab) - }, - async open(tab: string) { - if (tab !== "chat") { - await file.open(tab) - } - if (!tabs()?.opened?.includes(tab)) { - setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab]) - } - setStore("tabs", store.active ?? "", "active", tab) - }, - close(tab: string) { - batch(() => { - if (!tabs()) return - setStore("tabs", store.active ?? "", { - active: tabs()!.active, - opened: tabs()!.opened.filter((x) => x !== tab), - }) - if (tabs()!.active === tab) { - const index = tabs()!.opened.findIndex((f) => f === tab) - const previous = tabs()!.opened[Math.max(0, index - 1)] - setStore("tabs", store.active ?? "", "active", previous) - } - }) - }, - move(tab: string, to: number) { - if (!tabs()) return - const index = tabs()!.opened.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - store.active ?? "", - "opened", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - // setStore("node", path, "pinned", true) - }, } })() @@ -589,12 +457,60 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } })() + const layout = (() => { + const [store, setStore] = makePersisted( + createStore({ + sidebar: { + opened: true, + width: 240, + }, + review: { + state: "closed" as "open" | "closed" | "tab", + }, + }), + { + name: "default-layout", + }, + ) + + return { + sidebar: { + opened: createMemo(() => store.sidebar.opened), + open() { + setStore("sidebar", "opened", true) + }, + close() { + setStore("sidebar", "opened", false) + }, + toggle() { + setStore("sidebar", "opened", (x) => !x) + }, + width: createMemo(() => store.sidebar.width), + resize(width: number) { + setStore("sidebar", "width", width) + }, + }, + review: { + state: createMemo(() => store.review?.state ?? "closed"), + open() { + setStore("review", "state", "open") + }, + close() { + setStore("review", "state", "closed") + }, + tab() { + setStore("review", "state", "tab") + }, + }, + } + })() + const result = { model, agent, file, - session, context, + layout, } return result }, diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx new file mode 100644 index 000000000..61fed945e --- /dev/null +++ b/packages/desktop/src/context/session.tsx @@ -0,0 +1,217 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { batch, createEffect, createMemo } from "solid-js" +import { useSync } from "./sync" +import { makePersisted } from "@solid-primitives/storage" +import { TextSelection, useLocal } from "./local" +import { pipe, sumBy } from "remeda" +import { AssistantMessage } from "@opencode-ai/sdk" + +export const { use: useSession, provider: SessionProvider } = createSimpleContext({ + name: "Session", + init: (props: { sessionId?: string }) => { + const sync = useSync() + const local = useLocal() + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursorPosition?: number + messageId?: string + tabs: { + active?: string + opened: string[] + } + }>({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + tabs: { + opened: [], + }, + }), + { + name: props.sessionId ?? "new-session", + }, + ) + + createEffect(() => { + if (!props.sessionId) return + sync.session.sync(props.sessionId) + }) + + const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined)) + const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => b.id.localeCompare(a.id)), + ) + const lastUserMessage = createMemo(() => { + return userMessages()?.at(0) + }) + const activeMessage = createMemo(() => { + if (!store.messageId) return lastUserMessage() + return userMessages()?.find((m) => m.id === store.messageId) + }) + const working = createMemo(() => { + if (!props.sessionId) return false + const last = lastUserMessage() + if (!last) return false + const assistantMessages = sync.data.message[props.sessionId]?.filter( + (m) => m.role === "assistant" && m.parentID == last?.id, + ) as AssistantMessage[] + const error = assistantMessages?.find((m) => m?.error)?.error + return !last?.summary?.body && !error + }) + + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const tokens = last().tokens + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + + return { + id: props.sessionId, + info, + working, + diffs, + prompt: { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursorPosition), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + batch(() => { + setStore("prompt", prompt) + if (cursorPosition !== undefined) setStore("cursorPosition", cursorPosition) + }) + }, + }, + messages: { + all: messages, + user: userMessages, + last: lastUserMessage, + active: activeMessage, + setActive(id: string | undefined) { + setStore("messageId", id) + }, + }, + usage: { + tokens, + cost, + context, + }, + layout: { + tabs: store.tabs, + setActiveTab(tab: string | undefined) { + setStore("tabs", "active", tab) + }, + setOpenedTabs(tabs: string[]) { + setStore("tabs", "opened", tabs) + }, + async openTab(tab: string) { + if (tab === "chat") { + setStore("tabs", "active", undefined) + return + } + if (tab.startsWith("file://")) { + await local.file.open(tab.replace("file://", "")) + } + if (tab !== "review") { + if (!store.tabs.opened.includes(tab)) { + setStore("tabs", "opened", [...store.tabs.opened, tab]) + } + } + setStore("tabs", "active", tab) + }, + closeTab(tab: string) { + batch(() => { + setStore( + "tabs", + "opened", + store.tabs.opened.filter((x) => x !== tab), + ) + if (store.tabs.active === tab) { + const index = store.tabs.opened.findIndex((f) => f === tab) + const previous = store.tabs.opened[Math.max(0, index - 1)] + setStore("tabs", "active", previous) + } + }) + }, + moveTab(tab: string, to: number) { + const index = store.tabs.opened.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "tabs", + "opened", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + // setStore("node", path, "pinned", true) + }, + }, + } + }, +}) + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export type ContentPart = TextPart | FileAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + } + return true +} diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 06fc05677..bc9491fd3 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -9,7 +9,8 @@ import type { File, FileNode, Project, - Command, + FileDiff, + Todo, } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { createMemo } from "solid-js" @@ -28,6 +29,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ config: Config path: Path session: Session[] + session_diff: { + [sessionID: string]: FileDiff[] + } + todo: { + [sessionID: string]: Todo[] + } + limit: number message: { [sessionID: string]: Message[] } @@ -44,6 +52,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], provider: [], session: [], + session_diff: {}, + todo: {}, + limit: 10, message: {}, part: {}, node: [], @@ -68,6 +79,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) break } + case "session.diff": + setStore("session_diff", event.properties.sessionID, event.properties.diff) + break + case "todo.updated": + setStore("todo", event.properties.sessionID, event.properties.todos) + break case "message.updated": { const messages = store.message[event.properties.info.sessionID] if (!messages) { @@ -118,12 +135,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), session: () => - sdk.client.session.list().then((x) => - setStore( - "session", - (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), - ), - ), + sdk.client.session.list().then((x) => { + const sessions = (x.data ?? []) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, store.limit) + setStore("session", sessions) + }), config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)), changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)), node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), @@ -167,22 +185,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) return store.session[match.index] return undefined }, - async sync(sessionID: string, isRetry = false) { - const [session, messages] = await Promise.all([ - sdk.client.session.get({ path: { id: sessionID } }), - sdk.client.session.messages({ path: { id: sessionID } }), + async sync(sessionID: string, _isRetry = false) { + const [session, messages, todo, diff] = await Promise.all([ + sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }), + sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }), + sdk.client.session.todo({ path: { id: sessionID } }), + sdk.client.session.diff({ path: { id: sessionID } }), ]) - - // If no messages and this might be a new session, retry after a delay - if (!isRetry && messages.data!.length === 0) { - setTimeout(() => this.sync(sessionID, true), 500) - return - } - setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) - draft.session[match.index] = session.data! + if (match.found) draft.session[match.index] = session.data! + if (!match.found) draft.session.splice(match.index, 0, session.data!) + draft.todo[sessionID] = todo.data ?? [] draft.message[sessionID] = messages .data!.map((x) => x.info) .slice() @@ -193,9 +208,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .map(sanitizePart) .sort((a, b) => a.id.localeCompare(b.id)) } + draft.session_diff[sessionID] = diff.data ?? [] }), ) }, + fetch: async (count = 10) => { + setStore("limit", (x) => x + count) + await load.session() + }, + more: createMemo(() => store.session.length === store.limit), }, load, absolute, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9fe5da2f6..63d96ae84 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,18 +3,22 @@ import "@/index.css" import { render } from "solid-js/web" import { Router, Route } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" -import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui" +import { Fonts, MarkedProvider } from "@opencode-ai/ui" import { SDKProvider } from "./context/sdk" import { SyncProvider } from "./context/sync" import { LocalProvider } from "./context/local" -import Home from "@/pages" +import Layout from "@/pages/layout" +import SessionLayout from "@/pages/session-layout" +import Session from "@/pages/session" const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" const url = new URLSearchParams(document.location.search).get("url") || - (location.hostname.includes("opencode.ai") ? `http://${host}:${port}` : "/") + (location.hostname.includes("opencode.ai") || location.hostname.includes("localhost") + ? `http://${host}:${port}` + : "/") const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -25,22 +29,22 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { render( () => ( - <ShikiProvider> - <MarkedProvider> - <SDKProvider url={url}> - <SyncProvider> - <LocalProvider> - <MetaProvider> - <Fonts /> - <Router> - <Route path="/" component={Home} /> - </Router> - </MetaProvider> - </LocalProvider> - </SyncProvider> - </SDKProvider> - </MarkedProvider> - </ShikiProvider> + <MarkedProvider> + <SDKProvider url={url}> + <SyncProvider> + <LocalProvider> + <MetaProvider> + <Fonts /> + <Router root={Layout}> + <Route path={["/", "/session"]} component={SessionLayout}> + <Route path="/:id?" component={Session} /> + </Route> + </Router> + </MetaProvider> + </LocalProvider> + </SyncProvider> + </SDKProvider> + </MarkedProvider> ), root!, ) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx deleted file mode 100644 index 5f04c3dbe..000000000 --- a/packages/desktop/src/pages/index.tsx +++ /dev/null @@ -1,857 +0,0 @@ -import { - Button, - List, - SelectDialog, - Tooltip, - IconButton, - Tabs, - Icon, - Accordion, - Diff, - Collapsible, - DiffChanges, - Message, - Typewriter, - Card, - Code, -} from "@opencode-ai/ui" -import { FileIcon } from "@/ui" -import FileTree from "@/components/file-tree" -import { MessageProgress } from "@/components/message-progress" -import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js" -import { useLocal, type LocalFile } from "@/context/local" -import { createStore } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" -import { ContentPart, PromptInput } from "@/components/prompt-input" -import { DateTime } from "luxon" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, - useDragDropContext, -} from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" -import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { Markdown } from "@opencode-ai/ui" -import { Spinner } from "@/components/spinner" - -export default function Page() { - const local = useLocal() - const sync = useSync() - const sdk = useSDK() - const [store, setStore] = createStore({ - clickTimer: undefined as number | undefined, - fileSelectOpen: false, - }) - let inputRef!: HTMLDivElement - let messageScrollElement!: HTMLDivElement - const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) - - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - setStore("fileSelectOpen", true) - return - } - - const focused = document.activeElement === inputRef - if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } - return - } - - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - inputRef?.focus() - } - } - - const resetClickTimer = () => { - if (!store.clickTimer) return - clearTimeout(store.clickTimer) - setStore("clickTimer", undefined) - } - - const startClickTimer = () => { - const newClickTimer = setTimeout(() => { - setStore("clickTimer", undefined) - }, 300) - setStore("clickTimer", newClickTimer as unknown as number) - } - - const handleFileClick = async (file: LocalFile) => { - if (store.clickTimer) { - resetClickTimer() - local.file.update(file.path, { ...file, pinned: true }) - } else { - local.file.open(file.path) - startClickTimer() - } - } - - // const navigateChange = (dir: 1 | -1) => { - // const active = local.file.active() - // if (!active) return - // const current = local.file.changeIndex(active.path) - // const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir - // local.file.setChangeIndex(active.path, next) - // } - - const handleTabChange = (path: string) => { - local.session.setActiveTab(path) - if (path === "chat") return - local.session.open(path) - } - - const handleTabClose = (file: LocalFile) => { - local.session.close(file.path) - } - - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setActiveItem(id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentFiles = local.session.tabs()?.opened.map((file) => file) - const fromIndex = currentFiles?.indexOf(draggable.id.toString()) - const toIndex = currentFiles?.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex && toIndex !== undefined) { - local.session.move(draggable.id.toString(), toIndex) - } - } - } - - const handleDragEnd = () => { - setActiveItem(undefined) - } - - // const scrollDiffItem = (element: HTMLElement) => { - // element.scrollIntoView({ block: "start", behavior: "instant" }) - // } - - const handleDiffTriggerClick = (event: MouseEvent) => { - // disabling scroll to diff for now - return - // const target = event.currentTarget as HTMLElement - // queueMicrotask(() => { - // if (target.getAttribute("aria-expanded") !== "true") return - // const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null - // if (!item) return - // scrollDiffItem(item) - // }) - } - - const handlePromptSubmit = async (parts: ContentPart[]) => { - const existingSession = local.session.active() - let session = existingSession - if (!session) { - const created = await sdk.client.session.create() - session = created.data ?? undefined - } - if (!session) return - - local.session.setActive(session.id) - if (!existingSession) { - local.session.copyTabs("", session.id) - } - local.session.setActiveTab(undefined) - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - - const text = parts.map((part) => part.content).join("") - const attachments = parts.filter((part) => part.type === "file") - - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - - const attachmentParts = attachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - await sdk.client.session.prompt({ - path: { id: session.id }, - body: { - agent: local.agent.current()!.name, - model: { - modelID: local.model.current()!.id, - providerID: local.model.current()!.provider.id, - }, - parts: [ - { - type: "text", - text, - }, - ...attachmentParts, - ], - }, - }) - } - - const handleNewSession = () => { - local.session.setActive(undefined) - inputRef?.focus() - } - - const TabVisual = (props: { file: LocalFile }): JSX.Element => { - return ( - <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" /> - <span - classList={{ - "text-14-medium": true, - "text-primary": !!props.file.status?.status, - italic: !props.file.pinned, - }} - > - {props.file.name} - </span> - <span class="hidden opacity-70"> - <Switch> - <Match when={props.file.status?.status === "modified"}> - <span class="text-primary">M</span> - </Match> - <Match when={props.file.status?.status === "added"}> - <span class="text-success">A</span> - </Match> - <Match when={props.file.status?.status === "deleted"}> - <span class="text-error">D</span> - </Match> - </Switch> - </span> - </div> - ) - } - - const SortableTab = (props: { - file: LocalFile - onTabClick: (file: LocalFile) => void - onTabClose: (file: LocalFile) => void - }): JSX.Element => { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore - <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> - <Tooltip value={props.file.path} placement="bottom" class="h-full"> - <div class="relative h-full"> - <Tabs.Trigger - value={props.file.path} - class="group/tab pl-3 pr-1" - onClick={() => props.onTabClick(props.file)} - > - <TabVisual file={props.file} /> - <IconButton - icon="close" - class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100 - group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle - hover:opacity-100 group-hover/tab:opacity-100" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - /> - </Tabs.Trigger> - </div> - </Tooltip> - </div> - ) - } - - const ConstrainDragYAxis = (): JSX.Element => { - const context = useDragDropContext() - if (!context) return <></> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <></> - } - - const getDraggableId = (event: unknown): string | undefined => { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined - } - - return ( - <div class="relative h-screen flex flex-col"> - <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header> - <main class="h-[calc(100vh-0rem)] flex"> - <div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start"> - <div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base"> - <span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span> - </div> - <div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3"> - <Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2"> - New Session - </Button> - <List - data={sync.data.session} - key={(x) => x.id} - current={local.session.active()} - onSelect={(s) => local.session.setActive(s?.id)} - onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)} - > - {(session) => { - const diffs = createMemo(() => session.summary?.diffs ?? []) - const filesChanged = createMemo(() => diffs().length) - const updated = DateTime.fromMillis(session.time.updated) - return ( - <Tooltip placement="right" value={session.title}> - <div> - <div class="flex items-center self-stretch gap-6 justify-between"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated.diffNow().as("seconds")) < 60 - ? "Now" - : updated - .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </div> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span> - <DiffChanges diff={diffs()} /> - </div> - </div> - </Tooltip> - ) - }} - </List> - </div> - </div> - <div class="relative bg-background-base w-full h-full overflow-x-hidden"> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List> - <Tabs.Trigger value="chat" class="flex gap-x-4 items-center"> - <div>Chat</div> - {/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */} - {/* <ProgressCircle percentage={local.session.context() ?? 0} /> */} - {/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */} - {/* </Tooltip> */} - </Tabs.Trigger> - {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */} - <SortableProvider ids={local.session.tabs()?.opened ?? []}> - <For each={local.session.tabs()?.opened ?? []}> - {(file) => ( - <SortableTab - file={local.file.node(file)} - onTabClick={handleFileClick} - onTabClose={handleTabClose} - /> - )} - </For> - </SortableProvider> - <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - onClick={() => setStore("fileSelectOpen", true)} - /> - </div> - </Tabs.List> - </div> - <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> - <div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0"> - <Show - when={local.session.active()} - fallback={ - <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> - <div class="text-20-medium text-text-weaker">New session</div> - <div class="flex justify-center items-center gap-3"> - <Icon name="folder" size="small" /> - <div class="text-12-medium text-text-weak"> - {getDirectory(sync.data.path.directory)} - <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> - </div> - </div> - <div class="flex justify-center items-center gap-3"> - <Icon name="pencil-line" size="small" /> - <div class="text-12-medium text-text-weak"> - Last modified - <span class="text-text-strong"> - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - </span> - </div> - </div> - </div> - } - > - {(session) => { - const [store, setStore] = createStore<{ - messageId?: string - }>() - - const messages = createMemo(() => sync.data.message[session().id] ?? []) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - - return ( - <div class="pt-3 flex flex-col flex-1 min-h-0"> - <div class="flex-1 min-h-0"> - <Show when={userMessages().length > 1}> - <ul - role="list" - class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1" - > - <For each={userMessages()}> - {(message) => { - const diffs = createMemo(() => message.summary?.diffs ?? []) - const assistantMessages = createMemo(() => { - return sync.data.message[session().id]?.filter( - (m) => m.role === "assistant" && m.parentID == message.id, - ) as AssistantMessageType[] - }) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const working = createMemo(() => !message.summary?.body && !error()) - - return ( - <li class="group/li flex items-center self-stretch"> - <button - class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default" - onClick={() => setStore("messageId", message.id)} - > - <Switch> - <Match when={working()}> - <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" /> - </Match> - <Match when={true}> - <DiffChanges diff={diffs()} variant="bars" /> - </Match> - </Switch> - <div - data-active={activeMessage()?.id === message.id} - classList={{ - "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, - "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, - }} - > - <Show when={message.summary?.title} fallback="New message"> - {message.summary?.title} - </Show> - </div> - </button> - </li> - ) - }} - </For> - </ul> - </Show> - <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar"> - <For each={userMessages()}> - {(message) => { - const isActive = createMemo(() => activeMessage()?.id === message.id) - const [titled, setTitled] = createSignal(!!message.summary?.title) - const assistantMessages = createMemo(() => { - return sync.data.message[session().id]?.filter( - (m) => m.role === "assistant" && m.parentID == message.id, - ) as AssistantMessageType[] - }) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error()) - const [expanded, setExpanded] = createSignal(false) - const parts = createMemo(() => sync.data.part[message.id]) - const title = createMemo(() => message.summary?.title) - const summary = createMemo(() => message.summary?.body) - const diffs = createMemo(() => message.summary?.diffs ?? []) - const hasToolPart = createMemo(() => - assistantMessages() - ?.flatMap((m) => sync.data.part[m.id]) - .some((p) => p?.type === "tool"), - ) - const working = createMemo(() => !summary() && !error()) - - // allowing time for the animations to finish - createEffect(() => { - title() - setTimeout(() => setTitled(!!title()), 10_000) - }) - createEffect(() => { - const complete = !!summary() || !!error() - setTimeout(() => setCompleted(complete), 1200) - }) - - return ( - <Show when={isActive()}> - <div - data-message={message.id} - class="flex flex-col items-start self-stretch gap-8 pb-50" - > - {/* Title */} - <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> - <div class="w-full text-14-medium text-text-strong"> - <Show - when={titled()} - fallback={ - <Typewriter - as="h1" - text={title()} - class="overflow-hidden text-ellipsis min-w-0 text-nowrap" - /> - } - > - <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap"> - {title()} - </h1> - </Show> - </div> - </div> - <div class="-mt-8"> - <Message message={message} parts={parts()} /> - </div> - {/* Summary */} - <Show when={completed()}> - <div class="w-full flex flex-col gap-6 items-start self-stretch"> - <div class="flex flex-col items-start gap-1 self-stretch"> - <h2 class="text-12-medium text-text-weak"> - <Switch> - <Match when={diffs().length}>Summary</Match> - <Match when={true}>Response</Match> - </Switch> - </h2> - <Show when={summary()}> - {(summary) => ( - <Markdown - classList={{ "[&>*]:fade-up-text": !diffs().length }} - text={summary()} - /> - )} - </Show> - </div> - <Accordion class="w-full" multiple> - <For each={diffs()}> - {(diff) => ( - <Accordion.Item value={diff.file}> - <Accordion.Header> - <Accordion.Trigger onClick={handleDiffTriggerClick}> - <div class="flex items-center justify-between w-full gap-5"> - <div class="grow flex items-center gap-5 min-w-0"> - <FileIcon - node={{ path: diff.file, type: "file" }} - class="shrink-0 size-4" - /> - <div class="flex grow min-w-0"> - <Show when={diff.file.includes("/")}> - <span class="text-text-base truncate-start"> - {getDirectory(diff.file)}‎ - </span> - </Show> - <span class="text-text-strong shrink-0"> - {getFilename(diff.file)} - </span> - </div> - </div> - <div class="shrink-0 flex gap-4 items-center justify-end"> - <DiffChanges diff={diff} /> - <Icon name="chevron-grabber-vertical" size="small" /> - </div> - </div> - </Accordion.Trigger> - </Accordion.Header> - <Accordion.Content> - <Diff - before={{ - name: diff.file!, - contents: diff.before!, - }} - after={{ - name: diff.file!, - contents: diff.after!, - }} - /> - </Accordion.Content> - </Accordion.Item> - )} - </For> - </Accordion> - </div> - </Show> - <Show when={error() && !expanded()}> - <Card variant="error" class="text-text-on-critical-base"> - {error()?.data?.message as string} - </Card> - </Show> - {/* Response */} - <div class="w-full"> - <Switch> - <Match when={!completed()}> - <MessageProgress - assistantMessages={assistantMessages} - done={!working()} - /> - </Match> - <Match when={completed() && hasToolPart()}> - <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}> - <Collapsible.Trigger class="text-text-weak hover:text-text-strong"> - <div class="flex items-center gap-1 self-stretch"> - <div class="text-12-medium"> - <Switch> - <Match when={expanded()}>Hide details</Match> - <Match when={!expanded()}>Show details</Match> - </Switch> - </div> - <Collapsible.Arrow /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <div class="w-full flex flex-col items-start self-stretch gap-3"> - <For each={assistantMessages()}> - {(assistantMessage) => { - const parts = createMemo( - () => sync.data.part[assistantMessage.id], - ) - return <Message message={assistantMessage} parts={parts()} /> - }} - </For> - <Show when={error()}> - <Card variant="error" class="text-text-on-critical-base"> - {error()?.data?.message as string} - </Card> - </Show> - </div> - </Collapsible.Content> - </Collapsible> - </Match> - </Switch> - </div> - </div> - </Show> - ) - }} - </For> - </div> - </div> - </div> - ) - }} - </Show> - </div> - </Tabs.Content> - {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */} - <For each={local.session.tabs()?.opened}> - {(file) => ( - <Tabs.Content value={file} class="select-text"> - {(() => { - { - /* const view = local.file.view(file) */ - } - { - /* const showRaw = view === "raw" || !file.content?.diff */ - } - { - /* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */ - } - const node = local.file.node(file) - return ( - <Code - file={{ name: node.path, contents: node.content?.content ?? "" }} - disableFileHeader - overflow="scroll" - class="pt-3 pb-40" - /> - ) - })()} - </Tabs.Content> - )} - </For> - </Tabs> - <DragOverlay> - {(() => { - const id = activeItem() - if (!id) return null - const draggedFile = local.file.node(id) - if (!draggedFile) return null - return ( - <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent"> - <TabVisual file={draggedFile} /> - </div> - ) - })()} - </DragOverlay> - </DragDropProvider> - <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8"> - <PromptInput - ref={(el) => { - inputRef = el - }} - onSubmit={handlePromptSubmit} - /> - </div> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - <FileTree path="" onFileClick={handleFileClick} /> - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> - </div> - </main> - <Show when={store.fileSelectOpen}> - <SelectDialog - defaultOpen - title="Select file" - placeholder="Search files" - emptyMessage="No files found" - items={local.file.searchFiles} - key={(x) => x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => (x ? local.session.open(x) : undefined)} - > - {(i) => ( - <div - classList={{ - "w-full flex items-center justify-between rounded-md": true, - }} - > - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </div> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </div> - )} - </SelectDialog> - </Show> - </div> - ) -} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx new file mode 100644 index 000000000..d88564007 --- /dev/null +++ b/packages/desktop/src/pages/layout.tsx @@ -0,0 +1,121 @@ +import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui" +import { createMemo, For, ParentProps, Show } from "solid-js" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { A, useParams } from "@solidjs/router" +import { useLocal } from "@/context/local" + +export default function Layout(props: ParentProps) { + const params = useParams() + const sync = useSync() + const local = useLocal() + + return ( + <div class="relative h-screen flex flex-col"> + <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header> + <div class="h-[calc(100vh-0rem)] flex"> + <div + classList={{ + "@container w-14 pb-4 shrink-0 bg-background-weak": true, + "flex flex-col items-start self-stretch justify-between": true, + "border-r border-border-weak-base": true, + "w-70": local.layout.sidebar.opened(), + }} + > + <div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0"> + <div class="h-8 shrink-0 flex items-center self-stretch px-3"> + <Tooltip placement="right" value="Collapse sidebar"> + <IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} /> + </Tooltip> + </div> + <div class="w-full px-3"> + <Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2"> + New Session + </Button> + <Tooltip placement="right" value="New session"> + <IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" /> + </Tooltip> + </div> + <div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3"> + <nav class="w-full"> + <For each={sync.data.session}> + {(session) => { + const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) + return ( + <A + data-active={session.id === params.id} + href={`/session/${session.id}`} + class="group/session focus:outline-none cursor-default" + > + <Tooltip placement="right" value={session.title}> + <div + class="w-full mb-1.5 px-3 py-1 rounded-md + group-data-[active=true]/session:bg-surface-raised-base-hover + group-hover/session:bg-surface-raised-base-hover + group-focus/session:bg-surface-raised-base-hover" + > + <div class="flex items-center self-stretch gap-6 justify-between"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {session.title} + </span> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </div> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </div> + </Tooltip> + </A> + ) + }} + </For> + </nav> + <Show when={sync.session.more()}> + <button + class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" + onClick={() => sync.session.fetch()} + > + Show more + </button> + </Show> + </div> + </div> + <div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0"> + <Button + as={"a"} + href="https://opencode.ai/desktop-feedback" + target="_blank" + class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]" + variant="ghost" + icon="speech-bubble" + > + Share feedback + </Button> + <Tooltip placement="right" value="Share feedback"> + <IconButton + as={"a"} + href="https://opencode.ai/desktop-feedback" + target="_blank" + icon="speech-bubble" + variant="ghost" + size="large" + class="@[4rem]:hidden stroke-[1.5px]" + /> + </Tooltip> + </div> + </div> + <main class="size-full overflow-x-hidden">{props.children}</main> + </div> + </div> + ) +} diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx new file mode 100644 index 000000000..9a24608f0 --- /dev/null +++ b/packages/desktop/src/pages/session-layout.tsx @@ -0,0 +1,12 @@ +import { Show, type ParentProps } from "solid-js" +import { SessionProvider } from "@/context/session" +import { useParams } from "@solidjs/router" + +export default function Layout(props: ParentProps) { + const params = useParams() + return ( + <Show when={params.id || true} keyed> + <SessionProvider sessionId={params.id}>{props.children}</SessionProvider> + </Show> + ) +} diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx new file mode 100644 index 000000000..23d44cfcb --- /dev/null +++ b/packages/desktop/src/pages/session.tsx @@ -0,0 +1,899 @@ +import { + SelectDialog, + IconButton, + Tabs, + Icon, + Accordion, + Diff, + Collapsible, + DiffChanges, + Message, + Typewriter, + Card, + Code, + Tooltip, + ProgressCircle, + Button, +} from "@opencode-ai/ui" +import { FileIcon } from "@/ui" +import { MessageProgress } from "@/components/message-progress" +import { + For, + onCleanup, + onMount, + Show, + Match, + Switch, + createSignal, + createEffect, + createMemo, + createResource, +} from "solid-js" +import { useLocal, type LocalFile } from "@/context/local" +import { createStore } from "solid-js/store" +import { getDirectory, getFilename } from "@/utils" +import { PromptInput } from "@/components/prompt-input" +import { DateTime } from "luxon" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + createSortable, + useDragDropContext, +} from "@thisbeyond/solid-dnd" +import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { JSX } from "solid-js" +import { useSync } from "@/context/sync" +import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" +import { Markdown } from "@opencode-ai/ui" +import { Spinner } from "@/components/spinner" +import { useSession } from "@/context/session" + +export default function Page() { + const local = useLocal() + const sync = useSync() + const session = useSession() + const [store, setStore] = createStore({ + clickTimer: undefined as number | undefined, + fileSelectOpen: false, + activeDraggable: undefined as string | undefined, + }) + let inputRef!: HTMLDivElement + let messageScrollElement!: HTMLDivElement + + const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { + event.preventDefault() + return + } + if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { + event.preventDefault() + setStore("fileSelectOpen", true) + return + } + + const focused = document.activeElement === inputRef + if (focused) { + if (event.key === "Escape") { + inputRef?.blur() + } + return + } + + // if (local.file.active()) { + // const active = local.file.active()! + // if (event.key === "Enter" && active.selection) { + // local.context.add({ + // type: "file", + // path: active.path, + // selection: { ...active.selection }, + // }) + // return + // } + // + // if (event.getModifierState(MOD)) { + // if (event.key.toLowerCase() === "a") { + // return + // } + // if (event.key.toLowerCase() === "c") { + // return + // } + // } + // } + + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { + inputRef?.focus() + } + } + + const resetClickTimer = () => { + if (!store.clickTimer) return + clearTimeout(store.clickTimer) + setStore("clickTimer", undefined) + } + + const startClickTimer = () => { + const newClickTimer = setTimeout(() => { + setStore("clickTimer", undefined) + }, 300) + setStore("clickTimer", newClickTimer as unknown as number) + } + + const handleTabClick = async (tab: string) => { + if (store.clickTimer) { + resetClickTimer() + // local.file.update(file.path, { ...file, pinned: true }) + } else { + if (tab.startsWith("file://")) { + local.file.open(tab.replace("file://", "")) + } + startClickTimer() + } + } + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (draggable && droppable) { + const currentTabs = session.layout.tabs.opened + const fromIndex = currentTabs?.indexOf(draggable.id.toString()) + const toIndex = currentTabs?.indexOf(droppable.id.toString()) + if (fromIndex !== toIndex && toIndex !== undefined) { + session.layout.moveTab(draggable.id.toString(), toIndex) + } + } + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + const FileVisual = (props: { file: LocalFile }): JSX.Element => { + return ( + <div class="flex items-center gap-x-1.5"> + <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" /> + <span + classList={{ + "text-14-medium": true, + "text-primary": !!props.file.status?.status, + italic: !props.file.pinned, + }} + > + {props.file.name} + </span> + <span class="hidden opacity-70"> + <Switch> + <Match when={props.file.status?.status === "modified"}> + <span class="text-primary">M</span> + </Match> + <Match when={props.file.status?.status === "added"}> + <span class="text-success">A</span> + </Match> + <Match when={props.file.status?.status === "deleted"}> + <span class="text-error">D</span> + </Match> + </Switch> + </span> + </div> + ) + } + + const SortableTab = (props: { + tab: string + onTabClick: (tab: string) => void + onTabClose: (tab: string) => void + }): JSX.Element => { + const sortable = createSortable(props.tab) + + const [file] = createResource( + () => props.tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + + return ( + // @ts-ignore + <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> + <div class="relative h-full"> + <Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}> + <Switch> + <Match when={file()}>{(f) => <FileVisual file={f()} />}</Match> + </Switch> + <IconButton + icon="close" + class="mt-0.5 opacity-0 group-data-[selected]/tab:opacity-100 + hover:bg-transparent + hover:opacity-100 group-hover/tab:opacity-100" + variant="ghost" + onClick={() => props.onTabClose(props.tab)} + /> + </Tabs.Trigger> + </div> + </div> + ) + } + + const ConstrainDragYAxis = (): JSX.Element => { + const context = useDragDropContext() + if (!context) return <></> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-y-axis", + order: 100, + callback: (transform) => ({ ...transform, y: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <></> + } + + const getDraggableId = (event: unknown): string | undefined => { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined + } + + return ( + <div class="relative bg-background-base size-full overflow-x-hidden"> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List> + <Tabs.Trigger value="chat" class="flex gap-x-4 items-center"> + <div>Chat</div> + <Tooltip + value={`${new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(session.usage.tokens() ?? 0)} Tokens`} + class="flex items-center gap-1.5" + > + <ProgressCircle percentage={session.usage.context() ?? 0} /> + <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> + </Tooltip> + </Tabs.Trigger> + <Show when={local.layout.review.state() === "tab" && session.diffs().length}> + <Tabs.Trigger value="review" class="flex gap-3 items-center group/tab pr-1"> + <Show when={session.diffs()}> + <DiffChanges changes={session.diffs()} variant="bars" /> + </Show> + <div class="flex items-center gap-1.5"> + <div>Review</div> + <Show when={session.info()?.summary?.files}> + <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> + {session.info()?.summary?.files ?? 0} + </div> + </Show> + <IconButton + icon="close" + class="mt-0.5 -ml-1 opacity-0 group-data-[selected]/tab:opacity-100 + hover:bg-transparent hover:opacity-100 group-hover/tab:opacity-100" + variant="ghost" + onClick={local.layout.review.close} + /> + </div> + </Tabs.Trigger> + </Show> + <SortableProvider ids={session.layout.tabs.opened ?? []}> + <For each={session.layout.tabs.opened ?? []}> + {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />} + </For> + </SortableProvider> + <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> + <Tooltip value="Open file" class="flex items-center"> + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={() => setStore("fileSelectOpen", true)} + /> + </Tooltip> + </div> + </Tabs.List> + </div> + <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> + <div + classList={{ + "w-full grid flex-1 min-h-0": true, + "grid-cols-2": local.layout.review.state() === "open", + }} + > + <div class="relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 max-w-2xl mx-auto"> + <Switch> + <Match when={session.id}> + <div class="h-8 flex shrink-0 self-stretch items-center justify-end"> + <Show when={local.layout.review.state() === "closed" && session.diffs().length}> + <Button icon="layout-right" onClick={local.layout.review.open}> + Review + </Button> + </Show> + </div> + <div + classList={{ + "flex-1 min-h-0 pb-20": true, + "flex items-start justify-start": local.layout.review.state() === "open", + }} + > + <Show when={session.messages.user().length > 1}> + <ul + role="list" + classList={{ + "mr-8 shrink-0 flex flex-col items-start": true, + "absolute right-full w-60 @7xl:gap-2": true, // local.layout.review.state() !== "open", + "": local.layout.review.state() === "open", + }} + > + <For each={session.messages.user()}> + {(message) => { + const assistantMessages = createMemo(() => { + if (!session.id) return [] + return sync.data.message[session.id]?.filter( + (m) => m.role === "assistant" && m.parentID == message.id, + ) as AssistantMessageType[] + }) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const working = createMemo(() => !message.summary?.body && !error()) + + const handleClick = () => session.messages.setActive(message.id) + + return ( + <li + classList={{ + "group/li flex items-center self-stretch justify-end": true, + "@7xl:justify-start": local.layout.review.state() !== "open", + }} + > + <Tooltip + placement="right" + gutter={8} + value={ + <div class="flex items-center gap-2"> + <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> + {message.summary?.title} + </div> + } + > + <button + data-active={session.messages.active()?.id === message.id} + onClick={handleClick} + classList={{ + "group/tick flex items-center justify-start h-2 w-8 -mr-3": true, + "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true, + "@7xl:hidden": local.layout.review.state() !== "open", + }} + > + <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" /> + </button> + </Tooltip> + <button + classList={{ + "hidden items-center self-stretch w-full gap-x-2 cursor-default": true, + "@7xl:flex": local.layout.review.state() !== "open", + }} + onClick={handleClick} + > + <Switch> + <Match when={working()}> + <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" /> + </Match> + <Match when={true}> + <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> + </Match> + </Switch> + <div + data-active={session.messages.active()?.id === message.id} + classList={{ + "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, + "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, + }} + > + <Show when={message.summary?.title} fallback="New message"> + {message.summary?.title} + </Show> + </div> + </button> + </li> + ) + }} + </For> + </ul> + </Show> + <div ref={messageScrollElement} class="grow w-full min-w-0 h-full overflow-y-auto no-scrollbar"> + <For each={session.messages.user()}> + {(message) => { + const isActive = createMemo(() => session.messages.active()?.id === message.id) + const [titled, setTitled] = createSignal(!!message.summary?.title) + const assistantMessages = createMemo(() => { + if (!session.id) return [] + return sync.data.message[session.id]?.filter( + (m) => m.role === "assistant" && m.parentID == message.id, + ) as AssistantMessageType[] + }) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error()) + const [detailsExpanded, setDetailsExpanded] = createSignal(false) + const parts = createMemo(() => sync.data.part[message.id]) + const hasToolPart = createMemo(() => + assistantMessages() + ?.flatMap((m) => sync.data.part[m.id]) + .some((p) => p?.type === "tool"), + ) + const working = createMemo(() => !message.summary?.body && !error()) + + // allowing time for the animations to finish + createEffect(() => { + const title = message.summary?.title + setTimeout(() => setTitled(!!title), 10_000) + }) + createEffect(() => { + const summary = message.summary?.body + const complete = !!summary || !!error() + setTimeout(() => setCompleted(complete), 1200) + }) + + return ( + <Show when={isActive()}> + <div + data-message={message.id} + class="flex flex-col items-start self-stretch gap-8 pb-20" + > + {/* Title */} + <div class="flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10 pb-1"> + <div class="w-full text-14-medium text-text-strong"> + <Show + when={titled()} + fallback={ + <Typewriter + as="h1" + text={message.summary?.title} + class="overflow-hidden text-ellipsis min-w-0 text-nowrap" + /> + } + > + <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap"> + {message.summary?.title} + </h1> + </Show> + </div> + </div> + <div class="-mt-9"> + <Message message={message} parts={parts()} /> + </div> + {/* Summary */} + <Show when={completed()}> + <div class="w-full flex flex-col gap-6 items-start self-stretch"> + <div class="flex flex-col items-start gap-1 self-stretch"> + <h2 class="text-12-medium text-text-weak"> + <Switch> + <Match when={message.summary?.diffs?.length}>Summary</Match> + <Match when={true}>Response</Match> + </Switch> + </h2> + <Show when={message.summary?.body}> + {(summary) => ( + <Markdown + classList={{ + "text-14-regular": !!message.summary?.diffs?.length, + "[&>*]:fade-up-text": !message.summary?.diffs?.length, + }} + text={summary()} + /> + )} + </Show> + </div> + <Accordion class="w-full" multiple> + <For each={message.summary?.diffs ?? []}> + {(diff) => ( + <Accordion.Item value={diff.file}> + <Accordion.Header> + <Accordion.Trigger> + <div class="flex items-center justify-between w-full gap-5"> + <div class="grow flex items-center gap-5 min-w-0"> + <FileIcon + node={{ path: diff.file, type: "file" }} + class="shrink-0 size-4" + /> + <div class="flex grow min-w-0"> + <Show when={diff.file.includes("/")}> + <span class="text-text-base truncate-start"> + {getDirectory(diff.file)}‎ + </span> + </Show> + <span class="text-text-strong shrink-0"> + {getFilename(diff.file)} + </span> + </div> + </div> + <div class="shrink-0 flex gap-4 items-center justify-end"> + <DiffChanges changes={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> + </div> + </div> + </Accordion.Trigger> + </Accordion.Header> + <Accordion.Content class="max-h-[360px] overflow-y-auto no-scrollbar"> + <Diff + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: diff.after!, + }} + /> + </Accordion.Content> + </Accordion.Item> + )} + </For> + </Accordion> + </div> + </Show> + <Show when={error() && !detailsExpanded()}> + <Card variant="error" class="text-text-on-critical-base"> + {error()?.data?.message as string} + </Card> + </Show> + {/* Response */} + <div class="w-full"> + <Switch> + <Match when={!completed()}> + <MessageProgress assistantMessages={assistantMessages} done={!working()} /> + </Match> + <Match when={completed() && hasToolPart()}> + <Collapsible + variant="ghost" + open={detailsExpanded()} + onOpenChange={setDetailsExpanded} + > + <Collapsible.Trigger class="text-text-weak hover:text-text-strong"> + <div class="flex items-center gap-1 self-stretch"> + <div class="text-12-medium"> + <Switch> + <Match when={detailsExpanded()}>Hide details</Match> + <Match when={!detailsExpanded()}>Show details</Match> + </Switch> + </div> + <Collapsible.Arrow /> + </div> + </Collapsible.Trigger> + <Collapsible.Content> + <div class="w-full flex flex-col items-start self-stretch gap-3"> + <For each={assistantMessages()}> + {(assistantMessage) => { + const parts = createMemo(() => sync.data.part[assistantMessage.id]) + return <Message message={assistantMessage} parts={parts()} /> + }} + </For> + <Show when={error()}> + <Card variant="error" class="text-text-on-critical-base"> + {error()?.data?.message as string} + </Card> + </Show> + </div> + </Collapsible.Content> + </Collapsible> + </Match> + </Switch> + </div> + </div> + </Show> + ) + }} + </For> + </div> + </div> + </Match> + <Match when={true}> + <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> + <div class="text-20-medium text-text-weaker">New session</div> + <div class="flex justify-center items-center gap-3"> + <Icon name="folder" size="small" /> + <div class="text-12-medium text-text-weak"> + {getDirectory(sync.data.path.directory)} + <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> + </div> + </div> + <div class="flex justify-center items-center gap-3"> + <Icon name="pencil-line" size="small" /> + <div class="text-12-medium text-text-weak"> + Last modified + <span class="text-text-strong"> + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} + </span> + </div> + </div> + </div> + </Match> + </Switch> + <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8"> + <PromptInput + ref={(el) => { + inputRef = el + }} + /> + </div> + </div> + <Show when={local.layout.review.state() === "open"}> + <div + classList={{ + "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 border-l border-border-weak-base": true, + }} + > + <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch"> + <div class="flex items-center gap-x-3"> + <Tooltip value="Close"> + <IconButton icon="align-right" variant="ghost" onClick={local.layout.review.close} /> + </Tooltip> + <Tooltip value="Open in tab"> + <IconButton + icon="expand" + variant="ghost" + onClick={() => { + local.layout.review.tab() + session.layout.setActiveTab("review") + }} + /> + </Tooltip> + </div> + </div> + <div class="text-14-medium text-text-strong">All changes</div> + <div class="h-full pb-40 overflow-y-auto no-scrollbar"> + <Accordion class="w-full" multiple> + <For each={session.diffs()}> + {(diff) => ( + <Accordion.Item value={diff.file} defaultOpen> + <Accordion.Header> + <Accordion.Trigger> + <div class="flex items-center justify-between w-full gap-5"> + <div class="grow flex items-center gap-5 min-w-0"> + <FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" /> + <div class="flex grow min-w-0"> + <Show when={diff.file.includes("/")}> + <span class="text-text-base truncate-start"> + {getDirectory(diff.file)}‎ + </span> + </Show> + <span class="text-text-strong shrink-0">{getFilename(diff.file)}</span> + </div> + </div> + <div class="shrink-0 flex gap-4 items-center justify-end"> + <DiffChanges changes={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> + </div> + </div> + </Accordion.Trigger> + </Accordion.Header> + <Accordion.Content> + <Diff + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: diff.after!, + }} + /> + </Accordion.Content> + </Accordion.Item> + )} + </For> + </Accordion> + </div> + </div> + </Show> + </div> + </Tabs.Content> + <Show when={local.layout.review.state() === "tab" && session.diffs().length}> + <Tabs.Content value="review" class="select-text"> + <div + classList={{ + "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0": true, + }} + > + <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch sticky top-0 bg-background-stronger z-100"> + <div class="flex items-center gap-x-3"></div> + </div> + <div class="text-14-medium text-text-strong">All changes</div> + <div class="h-full pb-40 overflow-y-auto no-scrollbar"> + <Accordion class="w-full" multiple> + <For each={session.diffs()}> + {(diff) => ( + <Accordion.Item value={diff.file} defaultOpen> + <Accordion.Header> + <Accordion.Trigger> + <div class="flex items-center justify-between w-full gap-5"> + <div class="grow flex items-center gap-5 min-w-0"> + <FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" /> + <div class="flex grow min-w-0"> + <Show when={diff.file.includes("/")}> + <span class="text-text-base truncate-start">{getDirectory(diff.file)}‎</span> + </Show> + <span class="text-text-strong shrink-0">{getFilename(diff.file)}</span> + </div> + </div> + <div class="shrink-0 flex gap-4 items-center justify-end"> + <DiffChanges changes={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> + </div> + </div> + </Accordion.Trigger> + </Accordion.Header> + <Accordion.Content> + <Diff + diffStyle="split" + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: diff.after!, + }} + /> + </Accordion.Content> + </Accordion.Item> + )} + </For> + </Accordion> + </div> + </div> + </Tabs.Content> + </Show> + <For each={session.layout.tabs.opened}> + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <Tabs.Content value={tab} class="select-text mt-3"> + <Switch> + <Match when={file()}> + {(f) => ( + <Code + file={{ name: f().path, contents: f().content?.content ?? "" }} + overflow="scroll" + class="pb-40" + /> + )} + </Match> + </Switch> + </Tabs.Content> + ) + }} + </For> + </Tabs> + <DragOverlay> + <Show when={store.activeDraggable}> + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent"> + <Show when={file()}>{(f) => <FileVisual file={f()} />}</Show> + </div> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + <Show when={session.layout.tabs.active}> + <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8"> + <PromptInput + ref={(el) => { + inputRef = el + }} + /> + </div> + </Show> + <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> + {/* <FileTree path="" onFileClick={ handleTabClick} /> */} + </div> + <div class="hidden shrink-0 w-56 p-2"> + <Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}> + <ul class=""> + <For each={local.file.changes()}> + {(path) => ( + <li> + <button + onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} + class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" + > + <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> + <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> + <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> + {getDirectory(path)} + </span> + </button> + </li> + )} + </For> + </ul> + </Show> + </div> + <Show when={store.fileSelectOpen}> + <SelectDialog + defaultOpen + title="Select file" + placeholder="Search files" + emptyMessage="No files found" + items={local.file.searchFiles} + key={(x) => x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)} + > + {(i) => ( + <div + classList={{ + "w-full flex items-center justify-between rounded-md": true, + }} + > + <div class="flex items-center gap-x-2 grow min-w-0"> + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular"> + <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {getDirectory(i)} + </span> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + </div> + )} + </SelectDialog> + </Show> + </div> + ) +} diff --git a/packages/desktop/src/sst-env.d.ts b/packages/desktop/src/sst-env.d.ts index 47a8fbec7..1b1683a1e 100644 --- a/packages/desktop/src/sst-env.d.ts +++ b/packages/desktop/src/sst-env.d.ts @@ -2,9 +2,7 @@ /* tslint:disable */ /* eslint-disable */ /// <reference types="vite/client" /> -interface ImportMetaEnv { - -} +interface ImportMetaEnv {} interface ImportMeta { readonly env: ImportMetaEnv -}
\ No newline at end of file +} diff --git a/packages/desktop/sst-env.d.ts b/packages/desktop/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/desktop/sst-env.d.ts +++ b/packages/desktop/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/extensions/zed/LICENSE b/packages/extensions/zed/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/packages/extensions/zed/LICENSE @@ -0,0 +1 @@ +../../../LICENSE
\ No newline at end of file diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml new file mode 100644 index 000000000..df9003afb --- /dev/null +++ b/packages/extensions/zed/extension.toml @@ -0,0 +1,36 @@ +id = "opencode" +name = "OpenCode" +description = "The AI coding agent built for the terminal" +version = "1.0.55" +schema_version = 1 +authors = ["Anomaly"] +repository = "https://github.com/sst/opencode" + +[agent_servers.opencode] +name = "OpenCode" +icon = "./icons/opencode.svg" + +[agent_servers.opencode.targets.darwin-aarch64] +archive = "https://github.com/sst/opencode/releases/download/v1.0.55/opencode-darwin-arm64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.darwin-x86_64] +archive = "https://github.com/sst/opencode/releases/download/v1.0.55/opencode-darwin-x64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.linux-aarch64] +archive = "https://github.com/sst/opencode/releases/download/v1.0.55/opencode-linux-arm64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.linux-x86_64] +archive = "https://github.com/sst/opencode/releases/download/v1.0.55/opencode-linux-x64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.windows-x86_64] +archive = "https://github.com/sst/opencode/releases/download/v1.0.55/opencode-windows-x64.zip" +cmd = "./opencode.exe" +args = ["acp"] diff --git a/packages/extensions/zed/icons/opencode.svg b/packages/extensions/zed/icons/opencode.svg new file mode 100644 index 000000000..fc001e49b --- /dev/null +++ b/packages/extensions/zed/icons/opencode.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="#C4CAD4"/> +</svg> diff --git a/packages/function/package.json b/packages/function/package.json index eeb842ca3..359444468 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.23", + "version": "1.0.55", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 01407434e..bcd7c2650 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -6,99 +6,108 @@ 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 - } - "Console": { - "type": "sst.cloudflare.SolidStart" - "url": string - } - "Database": { - "database": string - "host": string - "password": string - "port": number - "type": "sst.sst.Linkable" - "username": string - } - "Desktop": { - "type": "sst.cloudflare.StaticSite" - "url": string - } - "EMAILOCTOPUS_API_KEY": { - "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 - } - "STRIPE_SECRET_KEY": { - "type": "sst.sst.Secret" - "value": string - } - "STRIPE_WEBHOOK_SECRET": { - "type": "sst.sst.Linkable" - "value": string - } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string - } - "ZEN_MODELS1": { - "type": "sst.sst.Secret" - "value": string - } - "ZEN_MODELS2": { - "type": "sst.sst.Secret" - "value": string + 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 + } + Database: { + database: string + host: string + password: string + port: number + type: "sst.sst.Linkable" + username: string + } + Desktop: { + type: "sst.cloudflare.StaticSite" + url: string + } + EMAILOCTOPUS_API_KEY: { + 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 + } + STRIPE_SECRET_KEY: { + type: "sst.sst.Secret" + value: string + } + STRIPE_WEBHOOK_SECRET: { + type: "sst.sst.Linkable" + value: string + } + Web: { + type: "sst.cloudflare.Astro" + url: string + } + ZEN_MODELS1: { + type: "sst.sst.Secret" + value: string + } + ZEN_MODELS2: { + type: "sst.sst.Secret" + value: string } } } -// cloudflare -import * as cloudflare from "@cloudflare/workers-types"; +// 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 - "LogProcessor": cloudflare.Service + Api: cloudflare.Service + AuthApi: cloudflare.Service + AuthStorage: cloudflare.KVNamespace + Bucket: cloudflare.R2Bucket + GatewayKv: cloudflare.KVNamespace + LogProcessor: cloudflare.Service } } import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3e4f5c02c..2cc0cef7b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.23", + "version": "1.0.55", "name": "opencode", "type": "module", "private": true, @@ -43,7 +43,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.4.9", + "@agentclientprotocol/sdk": "0.5.1", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", @@ -54,11 +54,11 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opentui/core": "0.1.33", - "@opentui/solid": "0.1.33", + "@opentui/core": "0.0.0-20251108-0c7899b1", + "@opentui/solid": "0.0.0-20251108-0c7899b1", "@parcel/watcher": "2.5.1", - "@solid-primitives/event-bus": "1.1.2", "@pierre/precision-diffs": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", @@ -77,6 +77,7 @@ "partial-json": "0.1.7", "remeda": "catalog:", "solid-js": "catalog:", + "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", "ulid": "catalog:", diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts index cfa00454b..ef1495245 100644 --- a/packages/opencode/parsers-config.ts +++ b/packages/opencode/parsers-config.ts @@ -203,5 +203,14 @@ export default { ], }, }, + { + filetype: "clojure", + wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm", + queries: { + highlights: [ + "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm", + ], + }, + }, ], } diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 4ce8bfbad..29706c09c 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -41,9 +41,7 @@ for (const [os, arch] of targets) { const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}` await $`mkdir -p ../../node_modules/${opentui}` - await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd( - path.join(dir, "../../node_modules"), - ) + await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules")) await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1` const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}` @@ -51,9 +49,7 @@ for (const [os, arch] of targets) { await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet() await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1` - const parserWorker = fs.realpathSync( - path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"), - ) + const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js")) const workerPath = "./src/cli/cmd/tui/worker.ts" await Bun.build({ diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 1bac6cb09..3e989cc6a 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -55,18 +55,10 @@ if (!Script.preview) { } // Calculate SHA values - const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1` - .text() - .then((x) => x.trim()) - const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1` - .text() - .then((x) => x.trim()) - const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1` - .text() - .then((x) => x.trim()) - const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1` - .text() - .then((x) => x.trim()) + const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2) @@ -131,7 +123,7 @@ if (!Script.preview) { "", "package() {", ` cd "opencode-\${pkgver}/packages/opencode"`, - ' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"', + ' install -Dm755 $(find dist/*/bin/opencode) "${pkgdir}/usr/bin/opencode"', "}", "", ].join("\n") diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1eae36e66..ff71b0453 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1,9 +1,12 @@ import { + RequestError, type Agent as ACPAgent, type AgentSideConnection, type AuthenticateRequest, + type AuthMethod, type CancelNotification, type InitializeRequest, + type InitializeResponse, type LoadSessionRequest, type NewSessionRequest, type PermissionOption, @@ -17,294 +20,340 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" +import type { ACPConfig, ACPSessionState } from "./types" import { Provider } from "../provider/provider" -import { SessionPrompt } from "../session/prompt" import { Installation } from "@/installation" -import { SessionLock } from "@/session/lock" -import { Bus } from "@/bus" import { MessageV2 } from "@/session/message-v2" -import { Storage } from "@/storage/storage" -import { Command } from "@/command" -import { Agent as Agents } from "@/agent/agent" -import { Permission } from "@/permission" -import { SessionCompaction } from "@/session/compaction" -import type { Config } from "@/config/config" +import { Config } from "@/config/config" import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" +import { LoadAPIKeyError } from "ai" +import type { OpencodeClient } from "@opencode-ai/sdk" export namespace ACP { const log = Log.create({ service: "acp-agent" }) + export async function init({ sdk }: { sdk: OpencodeClient }) { + const model = await defaultModel({ sdk }) + return { + create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { + if (!fullConfig.defaultModel) { + fullConfig.defaultModel = model + } + return new Agent(connection, fullConfig) + }, + } + } + export class Agent implements ACPAgent { - private sessionManager = new ACPSessionManager() private connection: AgentSideConnection private config: ACPConfig + private sdk: OpencodeClient + private sessionManager - constructor(connection: AgentSideConnection, config: ACPConfig = {}) { + constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config - this.setupEventSubscriptions() + this.sdk = config.sdk + this.sessionManager = new ACPSessionManager(this.sdk) } - private setupEventSubscriptions() { + private setupEventSubscriptions(session: ACPSessionState) { + const sessionId = session.id + const directory = session.cwd + const options: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "always", kind: "allow_always", name: "Always allow" }, { optionId: "reject", kind: "reject_once", name: "Reject" }, ] - Bus.subscribe(Permission.Event.Updated, async (event) => { - const acpSession = this.sessionManager.get(event.properties.sessionID) - if (!acpSession) return - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId: acpSession.id, - toolCall: { - toolCallId: permission.callID ?? permission.id, - status: "pending", - title: permission.title, - rawInput: permission.metadata, - kind: toToolKind(permission.type), - locations: toLocations(permission.type, permission.metadata), - }, - options, - }) - .catch((error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", - }) - return - }) - if (!res) return - if (res.outcome.outcome !== "selected") { - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", - }) - return - } - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: res.outcome.optionId as "once" | "always" | "reject", - }) - } catch (err) { - if (!(err instanceof Permission.RejectedError)) { - log.error("unexpected error when handling permission", { error: err }) - throw err - } - } - }) - - Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => { - const props = event.properties - const { part } = props - const acpSession = this.sessionManager.get(part.sessionID) - if (!acpSession) return - - const message = await Storage.read<MessageV2.Info>([ - "message", - part.sessionID, - part.messageID, - ]).catch(() => undefined) - if (!message || message.role !== "assistant") return - - if (part.type === "tool") { - switch (part.state.status) { - case "pending": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) - break - case "running": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" - ? "completed" - : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, + this.config.sdk.event.subscribe({ query: { directory } }).then(async (events) => { + for await (const event of events.stream) { + switch (event.type) { + case "permission.updated": + try { + const permission = event.properties + const res = await this.connection + .requestPermission({ + sessionId, + toolCall: { + toolCallId: permission.callID ?? permission.id, + status: "pending", + title: permission.title, + rawInput: permission.metadata, + kind: toToolKind(permission.type), + locations: toLocations(permission.type, permission.metadata), + }, + options, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: "reject", + }, + query: { directory }, }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) + return + }) + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: "reject", + }, + query: { directory }, + }) + return } + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: res.outcome.optionId as "once" | "always" | "reject", + }, + query: { directory }, + }) + } catch (err) { + log.error("unexpected error when handling permission", { error: err }) + } finally { + break } - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, + case "message.part.updated": + log.info("message part updated", { event: event.properties }) + try { + const props = event.properties + const { part } = props + + const message = await this.config.sdk.session + .message({ + throwOnError: true, + path: { + id: part.sessionID, + messageID: part.messageID, }, - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, + query: { directory }, + }) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, + }, + }) + .catch((err) => { + log.error("failed to send tool pending to ACP", { error: err }) + }) + break + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((err) => { + log.error("failed to send tool in_progress to ACP", { error: err }) + }) + break + case "completed": + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, }, - }, - ], - rawOutput: { - error: part.state.error, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - const delta = props.delta - if (delta && part.synthetic !== true) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, - }, - }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) - }) - } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: delta, - }, - }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), + }, + }) + .catch((err) => { + log.error("failed to send session update for todo", { error: err }) + }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) + } + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool completed to ACP", { error: err }) + }) + break + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + content: [ + { + type: "content", + content: { + type: "text", + text: part.state.error, + }, + }, + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool error to ACP", { error: err }) + }) + break + } + } else if (part.type === "text") { + const delta = props.delta + if (delta && part.synthetic !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((err) => { + log.error("failed to send text to ACP", { error: err }) + }) + } + } else if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((err) => { + log.error("failed to send reasoning to ACP", { error: err }) + }) + } + } + } finally { + break + } } } }) } - async initialize(params: InitializeRequest) { + async initialize(params: InitializeRequest): Promise<InitializeResponse> { log.info("initialize", { protocolVersion: params.protocolVersion }) + const authMethod: AuthMethod = { + description: "Run `opencode auth login` in the terminal", + name: "Login with opencode", + id: "opencode-login", + } + + // If client supports terminal-auth capability, use that instead. + if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { + authMethod._meta = { + "terminal-auth": { + command: "opencode", + args: ["auth", "login"], + label: "OpenCode Login", + }, + } + } + return { protocolVersion: 1, agentCapabilities: { @@ -318,17 +367,10 @@ export namespace ACP { image: true, }, }, - authMethods: [ - { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", - id: "opencode-login", - }, - ], - _meta: { - opencode: { - version: Installation.VERSION, - }, + authMethods: [authMethod], + agentInfo: { + name: "OpenCode", + version: Installation.VERSION, }, } } @@ -338,45 +380,83 @@ export namespace ACP { } async newSession(params: NewSessionRequest) { - const model = await defaultModel(this.config) - const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) - - log.info("creating_session", { mcpServers: params.mcpServers.length }) - const load = await this.loadSession({ - cwd: params.cwd, - mcpServers: params.mcpServers, - sessionId: session.id, - }) + const directory = params.cwd + try { + const model = await defaultModel(this.config, directory) - return { - sessionId: session.id, - models: load.models, - modes: load.modes, - _meta: {}, + // Store ACP session state + const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const sessionId = state.id + + log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) + + const load = await this.loadSession({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + this.setupEventSubscriptions(state) + + return { + sessionId, + models: load.models, + modes: load.modes, + _meta: {}, + } + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: this.config.defaultModel?.providerID ?? "unknown", + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e } } async loadSession(params: LoadSessionRequest) { - const model = await defaultModel(this.config) + const directory = params.cwd + const model = await defaultModel(this.config, directory) const sessionId = params.sessionId - const providers = await Provider.list() - const entries = Object.entries(providers).sort((a, b) => { - const nameA = a[1].info.name.toLowerCase() - const nameB = b[1].info.name.toLowerCase() + const providers = await this.sdk.config + .providers({ throwOnError: true, query: { directory } }) + .then((x) => x.data.providers) + const entries = providers.sort((a, b) => { + const nameA = a.name.toLowerCase() + const nameB = b.name.toLowerCase() if (nameA < nameB) return -1 if (nameA > nameB) return 1 return 0 }) - const availableModels = entries.flatMap(([providerID, provider]) => { - const models = Provider.sort(Object.values(provider.info.models)) + const availableModels = entries.flatMap((provider) => { + const models = Provider.sort(Object.values(provider.models)) return models.map((model) => ({ - modelId: `${providerID}/${model.id}`, - name: `${provider.info.name}/${model.name}`, + modelId: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, })) }) - const availableCommands = (await Command.list()).map((command) => ({ + const agents = await this.config.sdk.app + .agents({ + throwOnError: true, + query: { + directory, + }, + }) + .then((resp) => resp.data) + + const commands = await this.config.sdk.command + .list({ + throwOnError: true, + query: { + directory, + }, + }) + .then((resp) => resp.data) + + const availableCommands = commands.map((command) => ({ name: command.name, description: command.description ?? "", })) @@ -387,17 +467,7 @@ export namespace ACP { description: "compact the session", }) - setTimeout(() => { - this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) - - const availableModes = (await Agents.list()) + const availableModes = agents .filter((agent) => agent.mode !== "subagent") .map((agent) => ({ id: agent.name, @@ -405,8 +475,7 @@ export namespace ACP { description: agent.description, })) - const currentModeId = - availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id + const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id const mcpServers: Record<string, Config.Mcp> = {} for (const server of params.mcpServers) { @@ -433,10 +502,31 @@ export namespace ACP { await Promise.all( Object.entries(mcpServers).map(async ([key, mcp]) => { - await MCP.add(key, mcp) + await this.sdk.mcp + .add({ + throwOnError: true, + query: { directory }, + body: { + name: key, + config: mcp, + }, + }) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) }), ) + setTimeout(() => { + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands, + }, + }) + }, 0) + return { sessionId, models: { @@ -453,12 +543,8 @@ export namespace ACP { async setSessionModel(params: SetSessionModelRequest) { const session = this.sessionManager.get(params.sessionId) - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`) - } - const parsed = Provider.parseModel(params.modelId) - const model = await Provider.getModel(parsed.providerID, parsed.modelID) + const model = Provider.parseModel(params.modelId) this.sessionManager.setModel(session.id, { providerID: model.providerID, @@ -471,31 +557,31 @@ export namespace ACP { } async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> { - const session = this.sessionManager.get(params.sessionId) - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`) - } - await Agents.get(params.modeId).then((agent) => { - if (!agent) throw new Error(`Agent not found: ${params.modeId}`) - }) + this.sessionManager.get(params.sessionId) + await this.config.sdk.app + .agents({ throwOnError: true }) + .then((x) => x.data) + .then((agent) => { + if (!agent) throw new Error(`Agent not found: ${params.modeId}`) + }) this.sessionManager.setMode(params.sessionId, params.modeId) } async prompt(params: PromptRequest) { const sessionID = params.sessionId - const acpSession = this.sessionManager.get(sessionID) - if (!acpSession) { - throw new Error(`Session not found: ${sessionID}`) - } + const session = this.sessionManager.get(sessionID) + const directory = session.cwd - const current = acpSession.model - const model = current ?? (await defaultModel(this.config)) + const current = session.model + const model = current ?? (await defaultModel(this.config, directory)) if (!current) { - this.sessionManager.setModel(acpSession.id, model) + this.sessionManager.setModel(session.id, model) } - const agent = acpSession.modeId ?? "build" + const agent = session.modeId ?? "build" - const parts: SessionPrompt.PromptInput["parts"] = [] + const parts: Array< + { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string } + > = [] for (const part of params.prompt) { switch (part.type) { case "text": @@ -509,12 +595,14 @@ export namespace ACP { parts.push({ type: "file", url: `data:${part.mimeType};base64,${part.data}`, + filename: "image", mime: part.mimeType, }) } else if (part.uri && part.uri.startsWith("http:")) { parts.push({ type: "file", url: part.uri, + filename: "image", mime: part.mimeType, }) } @@ -545,7 +633,7 @@ export namespace ACP { const cmd = (() => { const text = parts - .filter((p) => p.type === "text") + .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join("") .trim() @@ -562,36 +650,50 @@ export namespace ACP { } if (!cmd) { - await SessionPrompt.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, + await this.sdk.session.prompt({ + path: { id: sessionID }, + body: { + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + parts, + agent, + }, + query: { + directory, }, - parts, - agent, }) return done } - const command = await Command.get(cmd.name) + const command = await this.config.sdk.command + .list({ throwOnError: true, query: { directory } }) + .then((x) => x.data.find((c) => c.name === cmd.name)) if (command) { - await SessionPrompt.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, + await this.sdk.session.command({ + path: { id: sessionID }, + body: { + command: command.name, + arguments: cmd.args, + model: model.providerID + "/" + model.modelID, + agent, + }, + query: { + directory, + }, }) return done } switch (cmd.name) { case "compact": - await SessionCompaction.run({ - sessionID, - providerID: model.providerID, - modelID: model.modelID, + await this.config.sdk.session.summarize({ + path: { id: sessionID }, + throwOnError: true, + query: { + directory, + }, }) break } @@ -600,7 +702,14 @@ export namespace ACP { } async cancel(params: CancelNotification) { - SessionLock.abort(params.sessionId) + const session = this.sessionManager.get(params.sessionId) + await this.config.sdk.session.abort({ + path: { id: params.sessionId }, + throwOnError: true, + query: { + directory: session.cwd, + }, + }) } } @@ -651,17 +760,33 @@ export namespace ACP { } } - async function defaultModel(config: ACPConfig) { + async function defaultModel(config: ACPConfig, cwd?: string) { + const sdk = config.sdk const configured = config.defaultModel if (configured) return configured - return Provider.defaultModel() + + const model = await sdk.config + .get({ throwOnError: true, query: { directory: cwd } }) + .then((resp) => { + const cfg = resp.data + if (!cfg.model) return undefined + const parsed = Provider.parseModel(cfg.model) + return { + providerID: parsed.providerID, + modelID: parsed.modelID, + } + }) + .catch((error) => { + log.error("failed to load user config for default model", { error }) + return undefined + }) + + return model ?? { providerID: "opencode", modelID: "big-pickle" } } function parseUri( uri: string, - ): - | { type: "file"; url: string; filename: string; mime: string } - | { type: "text"; text: string } { + ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { try { if (uri.startsWith("file://")) { const path = uri.slice(7) diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 5d45ee283..63948a8c1 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,15 +1,33 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import { Session } from "../session" -import { Provider } from "../provider/provider" +import { RequestError, type McpServer } from "@agentclientprotocol/sdk" import type { ACPSessionState } from "./types" +import { Log } from "@/util/log" +import type { OpencodeClient } from "@opencode-ai/sdk" + +const log = Log.create({ service: "acp-session-manager" }) export class ACPSessionManager { private sessions = new Map<string, ACPSessionState>() + private sdk: OpencodeClient + + constructor(sdk: OpencodeClient) { + this.sdk = sdk + } async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> { - const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` }) + const session = await this.sdk.session + .create({ + body: { + title: `ACP Session ${crypto.randomUUID()}`, + }, + query: { + directory: cwd, + }, + throwOnError: true, + }) + .then((x) => x.data) + const sessionId = session.id - const resolvedModel = model ?? (await Provider.defaultModel()) + const resolvedModel = model const state: ACPSessionState = { id: sessionId, @@ -18,44 +36,35 @@ export class ACPSessionManager { createdAt: new Date(), model: resolvedModel, } + log.info("creating_session", { state }) this.sessions.set(sessionId, state) return state } - get(sessionId: string) { - return this.sessions.get(sessionId) - } - - async remove(sessionId: string) { - const state = this.sessions.get(sessionId) - if (!state) return - - await Session.remove(sessionId).catch(() => {}) - this.sessions.delete(sessionId) - } - - has(sessionId: string) { - return this.sessions.has(sessionId) + get(sessionId: string): ACPSessionState { + const session = this.sessions.get(sessionId) + if (!session) { + log.error("session not found", { sessionId }) + throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) + } + return session } getModel(sessionId: string) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) return session.model } setModel(sessionId: string, model: ACPSessionState["model"]) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) session.model = model this.sessions.set(sessionId, session) return session } setMode(sessionId: string, modeId: string) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) session.modeId = modeId this.sessions.set(sessionId, session) return session diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 56308cb76..8507228ed 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,11 +1,12 @@ import type { McpServer } from "@agentclientprotocol/sdk" +import type { OpencodeClient } from "@opencode-ai/sdk" export interface ACPSessionState { id: string cwd: string mcpServers: McpServer[] createdAt: Date - model: { + model?: { providerID: string modelID: string } @@ -13,6 +14,7 @@ export interface ACPSessionState { } export interface ACPConfig { + sdk: OpencodeClient defaultModel?: { providerID: string modelID: string diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index a6933708b..f1050ae72 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -20,6 +20,8 @@ export namespace Agent { edit: Config.Permission, bash: z.record(z.string(), Config.Permission), webfetch: Config.Permission.optional(), + doom_loop: Config.Permission.optional(), + external_directory: Config.Permission.optional(), }), model: z .object({ @@ -45,6 +47,8 @@ export namespace Agent { "*": "allow", }, webfetch: "allow", + doom_loop: "ask", + external_directory: "ask", } const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) @@ -244,6 +248,8 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag edit: merged.edit ?? "allow", webfetch: merged.webfetch ?? "allow", bash: mergedBash ?? { "*": "allow" }, + doom_loop: merged.doom_loop, + external_directory: merged.external_directory, } return result diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index de461e170..7d27f9416 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,6 +3,8 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" +import { Server } from "@/server/server" +import { createOpencodeClient } from "@opencode-ai/sdk" const log = Log.create({ service: "acp-command" }) @@ -17,15 +19,34 @@ export const AcpCommand = cmd({ command: "acp", describe: "Start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs.option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return yargs + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }) }, - handler: async (opts) => { - if (opts.cwd) process.chdir(opts["cwd"]) + handler: async (args) => { await bootstrap(process.cwd(), async () => { + const server = Server.listen({ + port: args.port, + hostname: args.hostname, + }) + + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + }) + const input = new WritableStream<Uint8Array>({ write(chunk) { return new Promise<void>((resolve, reject) => { @@ -50,9 +71,10 @@ export const AcpCommand = cmd({ }) const stream = ndJsonStream(input, output) + const agent = await ACP.init({ sdk }) new AgentSideConnection((conn) => { - return new ACP.Agent(conn) + return agent.create(conn, { sdk }) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index aa833e977..ae24fbef5 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -102,223 +102,223 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh().catch(() => {}) - const providers = await ModelsDev.get() - const priority: Record<string, number> = { - opencode: 0, - anthropic: 1, - "github-copilot": 2, - openai: 3, - google: 4, - openrouter: 5, - vercel: 6, - } - let provider = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, + await ModelsDev.refresh().catch(() => {}) + const providers = await ModelsDev.get() + const priority: Record<string, number> = { + opencode: 0, + anthropic: 1, + "github-copilot": 2, + openai: 3, + google: 4, + openrouter: 5, + vercel: 6, + } + let provider = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] <= 1 ? "recommended" : undefined, + })), ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] <= 1 ? "recommended" : undefined, - })), - ), - { - value: "other", - label: "Other", - }, - ], - }) + { + value: "other", + label: "Other", + }, + ], + }) - if (prompts.isCancel(provider)) throw new UI.CancelledError() + if (prompts.isCancel(provider)) throw new UI.CancelledError() - const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) - if (plugin && plugin.auth) { - let index = 0 - if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: [ - ...plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - ], - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } - const method = plugin.auth.methods[index] + const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + if (plugin && plugin.auth) { + let index = 0 + if (plugin.auth.methods.length > 1) { + const method = await prompts.select({ + message: "Login method", + options: [ + ...plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index.toString(), + })), + ], + }) + if (prompts.isCancel(method)) throw new UI.CancelledError() + index = parseInt(method) + } + const method = plugin.auth.methods[index] - // Handle prompts for all auth types - await new Promise((resolve) => setTimeout(resolve, 10)) - const inputs: Record<string, string> = {} - if (method.prompts) { - for (const prompt of method.prompts) { - if (prompt.condition && !prompt.condition(inputs)) { - continue - } - if (prompt.type === "select") { - const value = await prompts.select({ - message: prompt.message, - options: prompt.options, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value + // Handle prompts for all auth types + await new Promise((resolve) => setTimeout(resolve, 10)) + const inputs: Record<string, string> = {} + if (method.prompts) { + for (const prompt of method.prompts) { + if (prompt.condition && !prompt.condition(inputs)) { + continue + } + if (prompt.type === "select") { + const value = await prompts.select({ + message: prompt.message, + options: prompt.options, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } else { + const value = await prompts.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } } } - } - if (method.type === "oauth") { - const authorize = await method.authorize(inputs) - - if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) - } + if (method.type === "oauth") { + const authorize = await method.authorize(inputs) - if (authorize.method === "auto") { - if (authorize.instructions) { - prompts.log.info(authorize.instructions) + if (authorize.url) { + prompts.log.info("Go to: " + authorize.url) } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() - if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) + + if (authorize.method === "auto") { + if (authorize.instructions) { + prompts.log.info(authorize.instructions) + } + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + const result = await authorize.callback() + if (result.type === "failed") { + spinner.stop("Failed to authorize", 1) + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + spinner.stop("Login successful") + } } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + + if (authorize.method === "code") { + const code = await prompts.text({ + message: "Paste the authorization code here: ", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(code)) throw new UI.CancelledError() + const result = await authorize.callback(code) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") } - if ("key" in result) { - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + prompts.log.success("Login successful") } - spinner.stop("Login successful") } + + prompts.outro("Done") + return } - if (authorize.method === "code") { - const code = await prompts.text({ - message: "Paste the authorization code here: ", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + if (method.type === "api") { + if (method.authorize) { + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") } - if ("key" in result) { + if (result.type === "success") { + const saveProvider = result.provider ?? provider await Auth.set(saveProvider, { type: "api", key: result.key, }) + prompts.log.success("Login successful") } - prompts.log.success("Login successful") + prompts.outro("Done") + return } } + } + if (provider === "other") { + provider = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(provider)) throw new UI.CancelledError() + provider = provider.replace(/^@ai-sdk\//, "") + if (prompts.isCancel(provider)) throw new UI.CancelledError() + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } + + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID", + ) prompts.outro("Done") return } - if (method.type === "api") { - if (method.authorize) { - const result = await method.authorize(inputs) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - prompts.log.success("Login successful") - } - prompts.outro("Done") - return - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } + + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } - } - if (provider === "other") { - provider = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await Auth.set(provider, { + type: "api", + key, }) - if (prompts.isCancel(provider)) throw new UI.CancelledError() - provider = provider.replace(/^@ai-sdk\//, "") - if (prompts.isCancel(provider)) throw new UI.CancelledError() - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID", - ) prompts.outro("Done") - return - } - - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } - - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } - - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await Auth.set(provider, { - type: "api", - key, - }) - - prompts.outro("Done") }, }) }, diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 66cfba20d..4c18bce90 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -18,7 +18,7 @@ const TreeCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - process.stdout.write(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }) + EOL) + process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) }) }, }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 82952f1f6..27f460501 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -18,10 +18,13 @@ export const ExportCommand = cmd({ handler: async (args) => { await bootstrap(process.cwd(), async () => { let sessionID = args.sessionID + process.stderr.write(`Exporting session: ${sessionID ?? "latest"}`) if (!sessionID) { UI.empty() - prompts.intro("Export session") + prompts.intro("Export session", { + output: process.stderr, + }) const sessions = [] for await (const session of Session.list()) { @@ -29,8 +32,12 @@ export const ExportCommand = cmd({ } if (sessions.length === 0) { - prompts.log.error("No sessions found") - prompts.outro("Done") + prompts.log.error("No sessions found", { + output: process.stderr, + }) + prompts.outro("Done", { + output: process.stderr, + }) return } @@ -44,6 +51,7 @@ export const ExportCommand = cmd({ value: session.id, hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, })), + output: process.stderr, }) if (prompts.isCancel(selectedSession)) { @@ -52,12 +60,14 @@ export const ExportCommand = cmd({ sessionID = selectedSession as string - prompts.outro("Exporting session...") + prompts.outro("Exporting session...", { + output: process.stderr, + }) } try { const sessionInfo = await Session.get(sessionID!) - const messages = await Session.messages(sessionID!) + const messages = await Session.messages({ sessionID: sessionID! }) const exportData = { info: sessionInfo, diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 0cefb2533..c29a22a82 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -6,7 +6,7 @@ export const GenerateCommand = { handler: async () => { const specs = await Server.openapi() const json = JSON.stringify(specs, null, 2) - + // Wait for stdout to finish writing before process.exit() is called await new Promise<void>((resolve, reject) => { process.stdout.write(json, (err) => { diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts new file mode 100644 index 000000000..1c0c605f3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/import.ts @@ -0,0 +1,98 @@ +import type { Argv } from "yargs" +import { Session } from "../../session" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { Storage } from "../../storage/storage" +import { Instance } from "../../project/instance" +import { EOL } from "os" + +export const ImportCommand = cmd({ + command: "import <file>", + describe: "import session data from JSON file or URL", + builder: (yargs: Argv) => { + return yargs.positional("file", { + describe: "path to JSON file or opencode.ai share URL", + type: "string", + demandOption: true, + }) + }, + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + let exportData: + | { + info: Session.Info + messages: Array<{ + info: any + parts: any[] + }> + } + | undefined + + const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") + + if (isUrl) { + const urlMatch = args.file.match(/https?:\/\/opencode\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (!urlMatch) { + process.stdout.write(`Invalid URL format. Expected: https://opencode.ai/s/<slug>`) + process.stdout.write(EOL) + return + } + + const slug = urlMatch[1] + const response = await fetch(`https://api.opencode.ai/share_data?id=${slug}`) + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const data = await response.json() + + if (!data.info || !data.messages || Object.keys(data.messages).length === 0) { + process.stdout.write(`Share not found: ${slug}`) + process.stdout.write(EOL) + return + } + + exportData = { + info: data.info, + messages: Object.values(data.messages).map((msg: any) => { + const { parts, ...info } = msg + return { + info, + parts, + } + }), + } + } else { + const file = Bun.file(args.file) + exportData = await file.json().catch(() => {}) + if (!exportData) { + process.stdout.write(`File not found: ${args.file}`) + process.stdout.write(EOL) + return + } + } + + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } + + await Storage.write(["session", Instance.project.id, exportData.info.id], exportData.info) + + for (const msg of exportData.messages) { + await Storage.write(["message", exportData.info.id, msg.info.id], msg.info) + + for (const part of msg.parts) { + await Storage.write(["part", msg.info.id, part.id], part) + } + } + + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) + }) + }, +}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a979f8baa..b646f0b15 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,21 +1,15 @@ import type { Argv } from "yargs" import path from "path" -import { Bus } from "../../bus" -import { Provider } from "../../provider/provider" -import { Session } from "../../session" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" -import { Config } from "../../config/config" import { bootstrap } from "../bootstrap" -import { MessageV2 } from "../../session/message-v2" -import { Identifier } from "../../id/id" -import { Agent } from "../../agent/agent" import { Command } from "../../command" -import { SessionPrompt } from "../../session/prompt" import { EOL } from "os" -import { Permission } from "@/permission" import { select } from "@clack/prompts" +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk" +import { Server } from "../../server/server" +import { Provider } from "../../provider/provider" const TOOL: Record<string, [string, string]> = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -84,11 +78,19 @@ export const RunCommand = cmd({ type: "string", describe: "title for the session (uses truncated prompt if no value provided)", }) + .option("attach", { + type: "string", + describe: "attach to a running opencode server (e.g., http://localhost:4096)", + }) + .option("port", { + type: "number", + describe: "port for the local server (defaults to random port if no value provided)", + }) }, handler: async (args) => { let message = args.message.join(" ") - let fileParts: any[] = [] + const fileParts: any[] = [] if (args.file) { const files = Array.isArray(args.file) ? args.file : [args.file] @@ -124,216 +126,220 @@ export const RunCommand = cmd({ process.exit(1) } - await bootstrap(process.cwd(), async () => { - if (args.command) { - const exists = await Command.get(args.command) - if (!exists) { - UI.error(`Command "${args.command}" not found`) - process.exit(1) + const execute = async (sdk: OpencodeClient, sessionID: string) => { + const printEvent = (color: string, type: string, title: string) => { + UI.println( + color + `|`, + UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, + "", + UI.Style.TEXT_NORMAL + title, + ) + } + + const outputJsonEvent = (type: string, data: any) => { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true } + return false } - const session = await (async () => { - if (args.continue) { - const it = Session.list() - try { - for await (const s of it) { - if (s.parentID === undefined) { - return s + + const events = await sdk.event.subscribe() + let errorMsg: string | undefined + + const eventProcessor = (async () => { + for await (const event of events.stream) { + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && part.state.status === "completed") { + if (outputJsonEvent("tool_use", { part })) continue + const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] + const title = + part.state.title || + (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") + printEvent(color, tool, title) + if (part.tool === "bash" && part.state.output?.trim()) { + UI.println() + UI.println(part.state.output) } } - return - } finally { - await it.return() - } - } - if (args.session) return Session.get(args.session) + if (part.type === "step-start") { + if (outputJsonEvent("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (outputJsonEvent("step_finish", { part })) continue + } - const title = (() => { - if (args.title !== undefined) { - if (args.title === "") { - return message.slice(0, 50) + (message.length > 50 ? "..." : "") + if (part.type === "text" && part.time?.end) { + if (outputJsonEvent("text", { part })) continue + const isPiped = !process.stdout.isTTY + if (!isPiped) UI.println() + process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) + if (!isPiped) UI.println() } - return args.title } - return undefined - })() - return Session.create({ - title, - }) - })() + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + errorMsg = errorMsg ? errorMsg + EOL + err : err + if (outputJsonEvent("error", { error: props.error })) continue + UI.error(err) + } - if (!session) { - UI.error("Session not found") - process.exit(1) - } + if (event.type === "session.idle" && event.properties.sessionID === sessionID) { + break + } - const cfg = await Config.get() - if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) { - try { - await Session.share(session.id) - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8)) - } catch (error) { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } else { - throw error + if (event.type === "permission.updated") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue + const result = await select({ + message: `Permission required to run: ${permission.title}`, + options: [ + { value: "once", label: "Allow once" }, + { value: "always", label: "Always allow" }, + { value: "reject", label: "Reject" }, + ], + initialValue: "once", + }).catch(() => "reject") + const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject" + await sdk.postSessionIdPermissionsPermissionId({ + path: { id: sessionID, permissionID: permission.id }, + body: { response }, + }) } } - } - - const agent = await (async () => { - if (args.agent) return Agent.get(args.agent) - const build = Agent.get("build") - if (build) return build - return Agent.list().then((x) => x[0]) - })() - - const { providerID, modelID } = await (async () => { - if (args.model) return Provider.parseModel(args.model) - if (agent.model) return agent.model - return await Provider.defaultModel() })() - function printEvent(color: string, type: string, title: string) { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) + if (args.command) { + await sdk.session.command({ + path: { id: sessionID }, + body: { + agent: args.agent || "build", + model: args.model, + command: args.command, + arguments: message, + }, + }) + } else { + const modelParam = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + path: { id: sessionID }, + body: { + agent: args.agent || "build", + model: modelParam, + parts: [...fileParts, { type: "text", text: message }], + }, + }) } - function outputJsonEvent(type: string, data: any) { - if (args.format === "json") { - const jsonEvent = { - type, - timestamp: Date.now(), - sessionID: session?.id, - ...data, - } - process.stdout.write(JSON.stringify(jsonEvent) + EOL) - return true - } - return false - } + await eventProcessor + if (errorMsg) process.exit(1) + } - const messageID = Identifier.ascending("message") + if (args.attach) { + const sdk = createOpencodeClient({ baseUrl: args.attach }) - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.part.sessionID !== session.id) return - if (evt.properties.part.messageID === messageID) return - const part = evt.properties.part + const sessionID = await (async () => { + if (args.continue) { + const result = await sdk.session.list() + return result.data?.find((s) => !s.parentID)?.id + } + if (args.session) return args.session + + const title = + args.title !== undefined + ? args.title === "" + ? message.slice(0, 50) + (message.length > 50 ? "..." : "") + : args.title + : undefined - if (part.type === "tool" && part.state.status === "completed") { - if (outputJsonEvent("tool_use", { part })) return - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 - ? JSON.stringify(part.state.input) - : "Unknown") + const result = await sdk.session.create({ body: title ? { title } : {} }) + return result.data?.id + })() - printEvent(color, tool, title) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } - if (part.tool === "bash" && part.state.output && part.state.output.trim()) { - UI.println() - UI.println(part.state.output) + const cfgResult = await sdk.config.get() + if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { + const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) } + return { error } + }) + if (!shareResult.error) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) } + } - if (part.type === "step-start") { - if (outputJsonEvent("step_start", { part })) return - } + return await execute(sdk, sessionID) + } - if (part.type === "step-finish") { - if (outputJsonEvent("step_finish", { part })) return - } + await bootstrap(process.cwd(), async () => { + const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" }) + const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` }) - if (part.type === "text") { - const text = part.text - const isPiped = !process.stdout.isTTY + if (args.command) { + const exists = await Command.get(args.command) + if (!exists) { + server.stop() + UI.error(`Command "${args.command}" not found`) + process.exit(1) + } + } - if (part.time?.end) { - if (outputJsonEvent("text", { part })) return - if (!isPiped) UI.println() - process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL) - if (!isPiped) UI.println() - } + const sessionID = await (async () => { + if (args.continue) { + const result = await sdk.session.list() + return result.data?.find((s) => !s.parentID)?.id } - }) + if (args.session) return args.session - let errorMsg: string | undefined - Bus.subscribe(Session.Event.Error, async (evt) => { - const { sessionID, error } = evt.properties - if (sessionID !== session.id || !error) return - let err = String(error.name) + const title = + args.title !== undefined + ? args.title === "" + ? message.slice(0, 50) + (message.length > 50 ? "..." : "") + : args.title + : undefined - if ("data" in error && error.data && "message" in error.data) { - err = error.data.message - } - errorMsg = errorMsg ? errorMsg + EOL + err : err + const result = await sdk.session.create({ body: title ? { title } : {} }) + return result.data?.id + })() - if (outputJsonEvent("error", { error })) return - UI.error(err) - }) + if (!sessionID) { + server.stop() + UI.error("Session not found") + process.exit(1) + } - Bus.subscribe(Permission.Event.Updated, async (evt) => { - const permission = evt.properties - const message = `Permission required to run: ${permission.title}` - - const result = await select({ - message, - options: [ - { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow" }, - { value: "reject", label: "Reject" }, - ], - initialValue: "once", - }).catch(() => "reject") - const response = (result.toString().includes("cancel") ? "reject" : result) as - | "once" - | "always" - | "reject" - - Permission.respond({ - sessionID: session.id, - permissionID: permission.id, - response, + const cfgResult = await sdk.config.get() + if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { + const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } }) - }) - - await (async () => { - if (args.command) { - return await SessionPrompt.command({ - messageID, - sessionID: session.id, - agent: agent.name, - model: providerID + "/" + modelID, - command: args.command, - arguments: message, - }) + if (!shareResult.error) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) } - return await SessionPrompt.prompt({ - sessionID: session.id, - messageID, - model: { - providerID, - modelID, - }, - agent: agent.name, - parts: [ - ...fileParts, - { - id: Identifier.ascending("part"), - type: "text", - text: message, - }, - ], - }) - })() - if (errorMsg) process.exit(1) + } + + await execute(sdk, sessionID) + server.stop() }) }, }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 5abee45f4..58e8397db 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -68,9 +68,7 @@ async function getAllSessions(): Promise<Session.Info[]> { if (!project) continue const sessionKeys = await Storage.list(["session", project.id]) - const projectSessions = await Promise.all( - sessionKeys.map((key) => Storage.read<Session.Info>(key)), - ) + const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key))) for (const session of projectSessions) { if (session) { @@ -87,16 +85,12 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 - let filteredSessions = days - ? sessions.filter((session) => session.time.updated >= cutoffTime) - : sessions + let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { if (projectFilter === "") { const currentProject = await getCurrentProject() - filteredSessions = filteredSessions.filter( - (session) => session.projectID === currentProject.id, - ) + filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id) } else { filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) } @@ -125,9 +119,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } if (filteredSessions.length > 1000) { - console.log( - `Large dataset detected (${filteredSessions.length} sessions). This may take a while...`, - ) + console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`) } if (filteredSessions.length === 0) { @@ -142,7 +134,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro const batch = filteredSessions.slice(i, i + BATCH_SIZE) const batchPromises = batch.map(async (session) => { - const messages = await Session.messages(session.id) + const messages = await Session.messages({ sessionID: session.id }) let sessionCost = 0 let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } @@ -262,8 +254,7 @@ export function displayStats(stats: SessionStats, toolLimit?: number) { const percentage = ((count / totalToolUsage) * 100).toFixed(1) const maxToolLength = 18 - const truncatedTool = - tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool + const truncatedTool = tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool const toolName = truncatedTool.padEnd(maxToolLength) const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)` diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e57ef8cd6..dc4eabfdd 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,8 +1,8 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" -import { RouteProvider, useRoute, type Route } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js" +import { RouteProvider, useRoute } from "@tui/context/route" +import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -24,10 +24,11 @@ import { PromptHistoryProvider } from "./component/prompt/history" import { DialogAlert } from "./ui/dialog-alert" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" -import type { SessionRoute } from "./context/route" import { Session as SessionApi } from "@/session" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" +import { Provider } from "@/provider/provider" +import { ArgsProvider, useArgs, type Args } from "./context/args" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -89,25 +90,10 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { }) } -export function tui(input: { - url: string - sessionID?: string - model?: string - agent?: string - prompt?: string - onExit?: () => Promise<void> -}) { +export function tui(input: { url: string; args: Args; onExit?: () => Promise<void> }) { // promise to prevent immediate exit return new Promise<void>(async (resolve) => { const mode = await getTerminalBackgroundColor() - - const routeData: Route | undefined = input.sessionID - ? { - type: "session", - sessionID: input.sessionID, - } - : undefined - const onExit = async () => { await input.onExit?.() resolve() @@ -116,40 +102,34 @@ export function tui(input: { render( () => { return ( - <ErrorBoundary - fallback={(error, reset) => ( - <ErrorComponent error={error} reset={reset} onExit={onExit} /> - )} - > - <ExitProvider onExit={onExit}> - <KVProvider> - <ToastProvider> - <RouteProvider data={routeData}> - <SDKProvider url={input.url}> - <SyncProvider> - <ThemeProvider mode={mode}> - <LocalProvider - initialModel={input.model} - initialAgent={input.agent} - initialPrompt={input.prompt} - > - <KeybindProvider> - <DialogProvider> - <CommandProvider> - <PromptHistoryProvider> - <App /> - </PromptHistoryProvider> - </CommandProvider> - </DialogProvider> - </KeybindProvider> - </LocalProvider> - </ThemeProvider> - </SyncProvider> - </SDKProvider> - </RouteProvider> - </ToastProvider> - </KVProvider> - </ExitProvider> + <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} />}> + <ArgsProvider {...input.args}> + <ExitProvider onExit={onExit}> + <KVProvider> + <ToastProvider> + <RouteProvider> + <SDKProvider url={input.url}> + <SyncProvider> + <ThemeProvider mode={mode}> + <LocalProvider> + <KeybindProvider> + <DialogProvider> + <CommandProvider> + <PromptHistoryProvider> + <App /> + </PromptHistoryProvider> + </CommandProvider> + </DialogProvider> + </KeybindProvider> + </LocalProvider> + </ThemeProvider> + </SyncProvider> + </SDKProvider> + </RouteProvider> + </ToastProvider> + </KVProvider> + </ExitProvider> + </ArgsProvider> </ErrorBoundary> ) }, @@ -173,42 +153,49 @@ function App() { const kv = useKV() const command = useCommandDialog() const { event } = useSDK() - const sync = useSync() const toast = useToast() - const [sessionExists, setSessionExists] = createSignal(false) const { theme, mode, setMode } = useTheme() + const sync = useSync() const exit = useExit() - useKeyboard(async (evt) => { - if (!Installation.isLocal()) return - if (evt.meta && evt.name === "t") { - renderer.toggleDebugOverlay() - return - } - - if (evt.meta && evt.name === "d") { - renderer.console.toggle() - return - } + createEffect(() => { + console.log(JSON.stringify(route.data)) }) - // Make sure session is valid, otherwise redirect to home - createEffect(async () => { - if (route.data.type === "session") { - const data = route.data as SessionRoute - await sync.session.sync(data.sessionID).catch(() => { - toast.show({ - message: `Session not found: ${data.sessionID}`, - variant: "error", + const args = useArgs() + onMount(() => { + batch(() => { + if (args.agent) local.agent.set(args.agent) + if (args.model) { + const { providerID, modelID } = Provider.parseModel(args.model) + if (!providerID || !modelID) + return toast.show({ + variant: "warning", + message: `Invalid model format: ${args.model}`, + duration: 3000, + }) + local.model.set({ providerID, modelID }, { recent: true }) + } + if (args.sessionID) { + route.navigate({ + type: "session", + sessionID: args.sessionID, }) - return route.navigate({ type: "home" }) - }) - setSessionExists(true) - } + } + }) }) createEffect(() => { - console.log(JSON.stringify(route.data)) + if (sync.status !== "complete") return + if (args.continue) { + const match = sync.data.session.at(0)?.id + if (match) { + route.navigate({ + type: "session", + sessionID: match, + }) + } + } }) command.register(() => [ @@ -328,6 +315,24 @@ function App() { onSelect: exit, category: "System", }, + { + title: "Toggle debug panel", + category: "System", + value: "app.debug", + onSelect: (dialog) => { + renderer.toggleDebugOverlay() + dialog.clear() + }, + }, + { + title: "Toggle console", + category: "System", + value: "app.fps", + onSelect: (dialog) => { + renderer.console.toggle() + dialog.clear() + }, + }, ]) createEffect(() => { @@ -367,6 +372,27 @@ function App() { } }) + event.on(SessionApi.Event.Error.type, (evt) => { + const error = evt.properties.error + const message = (() => { + if (!error) return "An error occured" + + if (typeof error === "object") { + const data = error.data + if ("message" in data && typeof data.message === "string") { + return data.message + } + } + return String(error) + })() + + toast.show({ + variant: "error", + message, + duration: 5000, + }) + }) + return ( <box width={dimensions().width} @@ -392,7 +418,7 @@ function App() { <Match when={route.data.type === "home"}> <Home /> </Match> - <Match when={route.data.type === "session" && sessionExists()}> + <Match when={route.data.type === "session"}> <Session /> </Match> </Switch> @@ -405,12 +431,7 @@ function App() { flexShrink={0} > <box flexDirection="row"> - <box - flexDirection="row" - backgroundColor={theme.backgroundElement} - paddingLeft={1} - paddingRight={1} - > + <box flexDirection="row" backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}> <text fg={theme.textMuted}>open</text> <text fg={theme.text} attributes={TextAttributes.BOLD}> code{" "} @@ -426,11 +447,7 @@ function App() { tab </text> <text fg={local.agent.color(local.agent.current().name)}>{""}</text> - <text - bg={local.agent.color(local.agent.current().name)} - fg={theme.background} - wrapMode={undefined} - > + <text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}> <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span> <span> AGENT </span> </text> diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 38f1b6719..7da6507ea 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -17,6 +17,9 @@ export const AttachCommand = cmd({ }), handler: async (args) => { if (args.dir) process.chdir(args.dir) - await tui(args) + await tui({ + url: args.url, + args: {}, + }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 04f2f6523..9f2d927e6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -4,12 +4,19 @@ import { useSync } from "@tui/context/sync" import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" +import { useTheme } from "../context/theme" + +function Free() { + const { theme } = useTheme() + return <span style={{ fg: theme.secondary }}>Free</span> +} export function DialogModel() { const local = useLocal() const sync = useSync() const dialog = useDialog() const [ref, setRef] = createSignal<DialogSelectRef<unknown>>() + const { theme } = useTheme() const options = createMemo(() => { return [ @@ -29,6 +36,7 @@ export function DialogModel() { title: model.name ?? item.modelID, description: provider.name, category: "Recent", + footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, }, ] }) @@ -51,6 +59,7 @@ export function DialogModel() { title: info.name ?? model, description: provider.name, category: provider.name, + footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, })), filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))), ), diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 4a720aa15..5e0095a8d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" @@ -20,6 +20,8 @@ export function DialogSessionList() { const deleteKeybind = "ctrl+d" + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const options = createMemo(() => { const today = new Date().toDateString() return sync.data.session @@ -39,6 +41,11 @@ export function DialogSessionList() { footer: Locale.time(x.time.updated), } }) + .slice(0, 150) + }) + + createEffect(() => { + console.log("session count", sync.data.session.length) }) onMount(() => { @@ -49,7 +56,7 @@ export function DialogSessionList() { <DialogSelect title="Sessions" options={options()} - limit={50} + current={currentSessionID()} onMove={() => { setToDelete(undefined) }} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index d1ef5ca56..e427e24e9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -77,10 +77,7 @@ export function DialogStatus() { </For> </box> )} - <Show - when={enabledFormatters().length > 0} - fallback={<text fg={theme.text}>No Formatters</text>} - > + <Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}> <box> <text fg={theme.text}>{enabledFormatters().length} Formatters</text> <For each={enabledFormatters()}> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index 60411e562..5240603f8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,26 +1,24 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" -import { THEMES, useTheme } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { onCleanup, onMount } from "solid-js" export function DialogThemeList() { const theme = useTheme() - const options = Object.keys(THEMES).map((value) => ({ + const options = Object.keys(theme.all()).map((value) => ({ title: value, - value: value as keyof typeof THEMES, + value: value, })) const dialog = useDialog() let confirmed = false - let ref: DialogSelectRef<keyof typeof THEMES> + let ref: DialogSelectRef<string> const initial = theme.selected onMount(() => { - // highlight the first theme in the list when we open it for UX - theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES) + theme.set(Object.keys(theme.all())[0]) }) onCleanup(() => { - // if we close the dialog without confirming, reset back to the initial theme if (!confirmed) theme.set(initial) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index a4c1f33d9..68578e708 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -83,12 +83,7 @@ export function Autocomplete(props: { const extmarkStart = store.index const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) - const styleId = - part.type === "file" - ? props.fileStyleId - : part.type === "agent" - ? props.agentStyleId - : undefined + const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined const extmarkId = input.extmarks.create({ start: extmarkStart, @@ -164,7 +159,6 @@ export function Autocomplete(props: { ) const agents = createMemo(() => { - if (store.index !== 0) return [] const agents = sync.data.agent return agents .filter((agent) => !agent.builtIn && agent.mode !== "primary") @@ -186,9 +180,7 @@ export function Autocomplete(props: { ) }) - const session = createMemo(() => - props.sessionID ? sync.session.get(props.sessionID) : undefined, - ) + const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [] const s = session() @@ -211,7 +203,6 @@ export function Autocomplete(props: { display: "/undo", description: "undo the last message", onSelect: () => { - hide() command.trigger("session.undo") }, }, @@ -244,6 +235,16 @@ export function Autocomplete(props: { onSelect: () => command.trigger("session.rename"), }, { + display: "/copy", + description: "copy session transcript to clipboard", + onSelect: () => command.trigger("session.copy"), + }, + { + display: "/export", + description: "export session transcript to file", + onSelect: () => command.trigger("session.export"), + }, + { display: "/timeline", description: "jump to message", onSelect: () => command.trigger("session.timeline"), @@ -316,14 +317,12 @@ export function Autocomplete(props: { const options = createMemo(() => { const mixed: AutocompleteOption[] = ( - store.visible === "@" - ? [...agents(), ...(files.loading ? files.latest || [] : files())] - : [...commands()] + store.visible === "@" ? [...agents(), ...(files.loading ? files.latest || [] : files())] : [...commands()] ).filter((x) => x.disabled !== true) const currentFilter = filter() if (!currentFilter) return mixed.slice(0, 10) const result = fuzzysort.go(currentFilter, mixed, { - keys: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""], + keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, }) return result.map((arr) => arr.obj) @@ -365,7 +364,7 @@ export function Autocomplete(props: { function hide() { const text = props.input().plainText - if (store.visible === "/" && !text.endsWith(" ")) { + if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) } @@ -423,13 +422,8 @@ export function Autocomplete(props: { if (e.name === "@") { const cursorOffset = props.input().cursorOffset const charBeforeCursor = - cursorOffset === 0 - ? undefined - : props.input().getTextRange(cursorOffset - 1, cursorOffset) - const canTrigger = - charBeforeCursor === undefined || - charBeforeCursor === "" || - /\s/.test(charBeforeCursor) + cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset) + const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor) if (canTrigger) show("@") } @@ -477,10 +471,7 @@ export function Autocomplete(props: { {option.display} </text> <Show when={option.description}> - <text - fg={index() === store.selected ? theme.background : theme.textMuted} - wrapMode="none" - > + <text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none"> {option.description} </text> </Show> diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 785eb7e4b..4b852e669 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -3,11 +3,11 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, - KeyEvent, PasteEvent, t, dim, fg, + type KeyBinding, } from "@opentui/core" import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js" import { useLocal } from "@tui/context/local" @@ -28,6 +28,7 @@ import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" import type { FilePart } from "@opencode-ai/sdk" import { TuiEvent } from "../../event" +import { iife } from "@/util/iife" export type PromptProps = { sessionID?: string @@ -83,7 +84,7 @@ export function Prompt(props: PromptProps) { shift: binding.shift || undefined, action: "submit" as const, })), - ] + ] satisfies KeyBinding[] }) const fileStyleId = syntax().getStyleId("extmark.file")! @@ -156,10 +157,12 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", + disabled: status() !== "working", category: "Session", - disabled: true, onSelect: (dialog) => { if (!props.sessionID) return + if (autocomplete.visible) return + if (!input.focused) return sdk.client.session.abort({ path: { id: props.sessionID, @@ -197,16 +200,6 @@ export function Prompt(props: PromptProps) { input.focus() }) - local.setInitialPrompt.listen((initialPrompt) => { - batch(() => { - setStore("prompt", { - input: initialPrompt, - parts: [], - }) - input.insertText(initialPrompt) - }) - }) - onMount(() => { promptPartTypeId = input.extmarks.registerType("prompt-part") }) @@ -331,9 +324,7 @@ export function Prompt(props: PromptProps) { // Expand pasted text inline before submitting const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort( - (a: { start: number }, b: { start: number }) => b.start - a.start, - ) + const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) for (const extmark of sortedExtmarks) { const partIndex = store.extmarkToPartIndex.get(extmark.id) @@ -361,8 +352,15 @@ export function Prompt(props: PromptProps) { }, }) setStore("mode", "normal") - } else if (inputText.startsWith("/")) { - const [command, ...args] = inputText.split(" ") + } else if ( + inputText.startsWith("/") && + iife(() => { + const command = inputText.split(" ")[0].slice(1) + console.log(command) + return sync.data.command.some((x) => x.name === command) + }) + ) { + let [command, ...args] = inputText.split(" ") sdk.client.session.command({ path: { id: sessionID, @@ -489,28 +487,15 @@ export function Prompt(props: PromptProps) { <box flexDirection="row" {...SplitBorder} - borderColor={ - keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border - } + borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border} justifyContent="space-evenly" > - <box - backgroundColor={theme.backgroundElement} - width={3} - height="100%" - alignItems="center" - paddingTop={1} - > + <box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}> <text attributes={TextAttributes.BOLD} fg={theme.primary}> {store.mode === "normal" ? ">" : "!"} </text> </box> - <box - paddingTop={1} - paddingBottom={1} - backgroundColor={theme.backgroundElement} - flexGrow={1} - > + <box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}> <textarea placeholder={ props.showPlaceholder @@ -528,7 +513,8 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() }} keyBindings={textareaKeybindings()} - onKeyDown={async (e: KeyEvent) => { + // TODO: fix this any + onKeyDown={async (e: any) => { if (props.disabled) { e.preventDefault() return @@ -564,10 +550,7 @@ export function Prompt(props: PromptProps) { return } if (store.mode === "shell") { - if ( - (e.name === "backspace" && input.visualCursor.offset === 0) || - e.name === "escape" - ) { + if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { setStore("mode", "normal") e.preventDefault() return @@ -577,8 +560,7 @@ export function Prompt(props: PromptProps) { if (!autocomplete.visible) { if ( (keybind.match("history_previous", e) && input.cursorOffset === 0) || - (keybind.match("history_next", e) && - input.cursorOffset === input.plainText.length) + (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) ) { const direction = keybind.match("history_previous", e) ? -1 : 1 const item = history.move(direction, input.plainText) @@ -594,24 +576,10 @@ export function Prompt(props: PromptProps) { return } - if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) - input.cursorOffset = 0 - if ( - keybind.match("history_next", e) && - input.visualCursor.visualRow === input.height - 1 - ) + if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0 + if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) input.cursorOffset = input.plainText.length } - if (!autocomplete.visible) { - if (keybind.match("session_interrupt", e) && props.sessionID) { - sdk.client.session.abort({ - path: { - id: props.sessionID, - }, - }) - return - } - } }} onSubmit={submit} onPaste={async (event: PasteEvent) => { @@ -650,7 +618,10 @@ export function Prompt(props: PromptProps) { } catch {} const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if (lineCount >= 5 && !sync.data.config.experimental?.disable_paste_summary) { + if ( + (lineCount >= 3 || pastedContent.length > 150) && + !sync.data.config.experimental?.disable_paste_summary + ) { event.preventDefault() const currentOffset = input.visualCursor.offset const virtualText = `[Pasted ~${lineCount} lines]` @@ -697,12 +668,7 @@ export function Prompt(props: PromptProps) { syntaxStyle={syntax()} /> </box> - <box - backgroundColor={theme.backgroundElement} - width={1} - justifyContent="center" - alignItems="center" - ></box> + <box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box> </box> <box flexDirection="row" justifyContent="space-between"> <text flexShrink={0} wrapMode="none" fg={theme.text}> @@ -723,8 +689,7 @@ export function Prompt(props: PromptProps) { <Match when={props.hint}>{props.hint!}</Match> <Match when={true}> <text fg={theme.text}> - {keybind.print("command_list")}{" "} - <span style={{ fg: theme.textMuted }}>commands</span> + {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> </text> </Match> </Switch> diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx new file mode 100644 index 000000000..ffd43009a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -0,0 +1,14 @@ +import { createSimpleContext } from "./helper" + +export interface Args { + model?: string + agent?: string + prompt?: string + continue?: boolean + sessionID?: string +} + +export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ + name: "Args", + init: (props: Args) => props, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index ef26ee656..fe81fd180 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,5 +1,5 @@ import { createStore } from "solid-js/store" -import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js" +import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { uniqueBy } from "remeda" @@ -8,12 +8,12 @@ import { Global } from "@/global" import { iife } from "@/util/iife" import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" -import { createEventBus } from "@solid-primitives/event-bus" import { Provider } from "@/provider/provider" +import { useArgs } from "./args" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", - init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => { + init: () => { const sync = useSync() const toast = useToast() @@ -22,9 +22,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return !!provider?.models[model.modelID] } - function getFirstValidModel( - ...modelFns: (() => { providerID: string; modelID: string } | undefined)[] - ) { + function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) { for (const modelFn of modelFns) { const model = modelFn() if (!model) continue @@ -32,25 +30,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - // Set initial model if provided - onMount(() => { - batch(() => { - if (props.initialAgent) { - agent.set(props.initialAgent) - } - if (props.initialModel) { - const { providerID, modelID } = Provider.parseModel(props.initialModel) - if (!providerID || !modelID) - return toast.show({ - variant: "warning", - message: `Invalid model format: ${props.initialModel}`, - duration: 3000, - }) - model.set({ providerID, modelID }, { recent: true }) - } - }) - }) - // Automatically update model when agent changes createEffect(() => { const value = agent.current() @@ -149,9 +128,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("ready", true) }) + const args = useArgs() const fallbackModel = createMemo(() => { - if (props.initialModel) { - const { providerID, modelID } = Provider.parseModel(props.initialModel) + if (args.model) { + const { providerID, modelID } = Provider.parseModel(args.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -213,9 +193,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const current = currentModel() if (!current) return const recent = modelStore.recent - const index = recent.findIndex( - (x) => x.providerID === current.providerID && x.modelID === current.modelID, - ) + const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) if (index === -1) return let next = index + direction if (next < 0) next = recent.length - 1 @@ -251,18 +229,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) - const setInitialPrompt = createEventBus<string>() - - onMount(() => { - if (props.initialPrompt) setInitialPrompt.emit(props.initialPrompt) - }) - const result = { model, agent, - get setInitialPrompt() { - return setInitialPrompt - }, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index ef230dc98..b906de99b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -14,16 +14,13 @@ export type Route = HomeRoute | SessionRoute export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ name: "Route", - init: (props: { data?: Route }) => { + init: () => { const [store, setStore] = createStore<Route>( - props.data ?? - ( - process.env["OPENCODE_ROUTE"] - ? JSON.parse(process.env["OPENCODE_ROUTE"]) - : { + process.env["OPENCODE_ROUTE"] + ? JSON.parse(process.env["OPENCODE_ROUTE"]) + : { type: "home", - } - ), + }, ) return { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index b0c2ea869..e53cd60b9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -16,12 +16,13 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@/util/binary" import { createSimpleContext } from "./helper" +import type { Snapshot } from "@/snapshot" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { const [store, setStore] = createStore<{ - ready: boolean + status: "loading" | "partial" | "complete" provider: Provider[] agent: Agent[] command: Command[] @@ -30,6 +31,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } config: Config session: Session[] + session_diff: { + [sessionID: string]: Snapshot.FileDiff[] + } todo: { [sessionID: string]: Todo[] } @@ -46,12 +50,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: FormatterStatus[] }>({ config: {}, - ready: false, + status: "loading", agent: [], permission: {}, command: [], provider: [], session: [], + session_diff: {}, todo: {}, message: {}, part: {}, @@ -104,6 +109,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("todo", event.properties.sessionID, event.properties.todos) break + case "session.diff": + setStore("session_diff", event.properties.sessionID, event.properties.diff) + break + case "session.deleted": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { @@ -145,6 +154,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.properties.info.sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) + if (draft.length > 100) draft.shift() }), ) break @@ -210,27 +220,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)), sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), sdk.client.config.get().then((x) => setStore("config", x.data!)), - ]).then(() => setStore("ready", true)) - - // non-blocking - Promise.all([ - sdk.client.session.list().then((x) => - setStore( - "session", - (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)), + ]).then(() => { + setStore("status", "partial") + // non-blocking + Promise.all([ + sdk.client.session.list().then((x) => + setStore( + "session", + (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)), + ), ), - ), - sdk.client.command.list().then((x) => setStore("command", x.data ?? [])), - sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), - sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), - sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), - ]) + sdk.client.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), + sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), + sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), + ]).then(() => { + setStore("status", "complete") + }) + }) const result = { data: store, set: setStore, + get status() { + return store.status + }, get ready() { - return store.ready + return store.status !== "loading" }, session: { get(sessionID: string) { @@ -249,11 +265,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return last.time.completed ? "idle" : "working" }, async sync(sessionID: string) { - const [session, messages, todo] = await Promise.all([ + if (store.message[sessionID]) return + const now = Date.now() + console.log("syncing", sessionID) + const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }), - sdk.client.session.messages({ path: { id: sessionID } }), + sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }), sdk.client.session.todo({ path: { id: sessionID } }), + sdk.client.session.diff({ path: { id: sessionID } }), ]) + console.log("fetched in " + (Date.now() - now), sessionID) setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) @@ -264,8 +285,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ for (const message of messages.data!) { draft.part[message.info.id] = message.parts } + draft.session_diff[sessionID] = diff.data ?? [] }), ) + console.log("synced in " + (Date.now() - now), sessionID) }, }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 93eae6c21..74c2ff7f0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,5 +1,5 @@ -import { SyntaxStyle, RGBA } from "@opentui/core" -import { createMemo, createSignal } from "solid-js" +import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" +import { createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" import aura from "./theme/aura.json" with { type: "json" } @@ -26,6 +26,8 @@ import tokyonight from "./theme/tokyonight.json" with { type: "json" } import vesper from "./theme/vesper.json" with { type: "json" } import zenburn from "./theme/zenburn.json" with { type: "json" } import { useKV } from "./kv" +import { useRenderer } from "@opentui/solid" +import { createStore } from "solid-js/store" type Theme = { primary: RGBA @@ -86,14 +88,14 @@ type Variant = { dark: HexColor | RefName light: HexColor | RefName } -type ColorValue = HexColor | RefName | Variant +type ColorValue = HexColor | RefName | Variant | RGBA type ThemeJson = { $schema?: string defs?: Record<string, HexColor | RefName> theme: Record<keyof Theme, ColorValue> } -export const THEMES: Record<string, ThemeJson> = { +export const DEFAULT_THEMES: Record<string, ThemeJson> = { aura, ayu, catppuccin, @@ -122,6 +124,7 @@ export const THEMES: Record<string, ThemeJson> = { function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue): RGBA { + if (c instanceof RGBA) return c if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c]) return resolveColor(c[mode]) } @@ -137,514 +140,27 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const sync = useSync() const kv = useKV() + const [store, setStore] = createStore({ + themes: DEFAULT_THEMES, + mode: props.mode, + active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string, + }) - const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode")) - const [mode, setMode] = createSignal(props.mode) + const renderer = useRenderer() + renderer + .getPalette({ + size: 16, + }) + .then((colors) => { + if (!colors.palette[0]) return + setStore("themes", "system", generateSystem(colors, store.mode)) + }) const values = createMemo(() => { - return resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode()) + return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) }) - const syntax = createMemo(() => { - return SyntaxStyle.fromTheme([ - { - scope: ["prompt"], - style: { - foreground: values().accent, - }, - }, - { - scope: ["extmark.file"], - style: { - foreground: values().warning, - bold: true, - }, - }, - { - scope: ["extmark.agent"], - style: { - foreground: values().secondary, - bold: true, - }, - }, - { - scope: ["extmark.paste"], - style: { - foreground: values().background, - background: values().warning, - bold: true, - }, - }, - { - scope: ["comment"], - style: { - foreground: values().syntaxComment, - italic: true, - }, - }, - { - scope: ["comment.documentation"], - style: { - foreground: values().syntaxComment, - italic: true, - }, - }, - { - scope: ["string", "symbol"], - style: { - foreground: values().syntaxString, - }, - }, - { - scope: ["number", "boolean"], - style: { - foreground: values().syntaxNumber, - }, - }, - { - scope: ["character.special"], - style: { - foreground: values().syntaxString, - }, - }, - { - scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], - style: { - foreground: values().syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.type"], - style: { - foreground: values().syntaxType, - bold: true, - italic: true, - }, - }, - { - scope: ["keyword.function", "function.method"], - style: { - foreground: values().syntaxFunction, - }, - }, - { - scope: ["keyword"], - style: { - foreground: values().syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.import"], - style: { - foreground: values().syntaxKeyword, - }, - }, - { - scope: ["operator", "keyword.operator", "punctuation.delimiter"], - style: { - foreground: values().syntaxOperator, - }, - }, - { - scope: ["keyword.conditional.ternary"], - style: { - foreground: values().syntaxOperator, - }, - }, - { - scope: ["variable", "variable.parameter", "function.method.call", "function.call"], - style: { - foreground: values().syntaxVariable, - }, - }, - { - scope: ["variable.member", "function", "constructor"], - style: { - foreground: values().syntaxFunction, - }, - }, - { - scope: ["type", "module"], - style: { - foreground: values().syntaxType, - }, - }, - { - scope: ["constant"], - style: { - foreground: values().syntaxNumber, - }, - }, - { - scope: ["property"], - style: { - foreground: values().syntaxVariable, - }, - }, - { - scope: ["class"], - style: { - foreground: values().syntaxType, - }, - }, - { - scope: ["parameter"], - style: { - foreground: values().syntaxVariable, - }, - }, - { - scope: ["punctuation", "punctuation.bracket"], - style: { - foreground: values().syntaxPunctuation, - }, - }, - { - scope: [ - "variable.builtin", - "type.builtin", - "function.builtin", - "module.builtin", - "constant.builtin", - ], - style: { - foreground: values().error, - }, - }, - { - scope: ["variable.super"], - style: { - foreground: values().error, - }, - }, - { - scope: ["string.escape", "string.regexp"], - style: { - foreground: values().syntaxKeyword, - }, - }, - { - scope: ["keyword.directive"], - style: { - foreground: values().syntaxKeyword, - italic: true, - }, - }, - { - scope: ["punctuation.special"], - style: { - foreground: values().syntaxOperator, - }, - }, - { - scope: ["keyword.modifier"], - style: { - foreground: values().syntaxKeyword, - italic: true, - }, - }, - { - scope: ["keyword.exception"], - style: { - foreground: values().syntaxKeyword, - italic: true, - }, - }, - // Markdown specific styles - { - scope: ["markup.heading"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.1"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.2"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.3"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.4"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.5"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.heading.6"], - style: { - foreground: values().markdownHeading, - bold: true, - }, - }, - { - scope: ["markup.bold", "markup.strong"], - style: { - foreground: values().markdownStrong, - bold: true, - }, - }, - { - scope: ["markup.italic"], - style: { - foreground: values().markdownEmph, - italic: true, - }, - }, - { - scope: ["markup.list"], - style: { - foreground: values().markdownListItem, - }, - }, - { - scope: ["markup.quote"], - style: { - foreground: values().markdownBlockQuote, - italic: true, - }, - }, - { - scope: ["markup.raw", "markup.raw.block"], - style: { - foreground: values().markdownCode, - }, - }, - { - scope: ["markup.raw.inline"], - style: { - foreground: values().markdownCode, - background: values().background, - }, - }, - { - scope: ["markup.link"], - style: { - foreground: values().markdownLink, - underline: true, - }, - }, - { - scope: ["markup.link.label"], - style: { - foreground: values().markdownLinkText, - underline: true, - }, - }, - { - scope: ["markup.link.url"], - style: { - foreground: values().markdownLink, - underline: true, - }, - }, - { - scope: ["label"], - style: { - foreground: values().markdownLinkText, - }, - }, - { - scope: ["spell", "nospell"], - style: { - foreground: values().text, - }, - }, - { - scope: ["conceal"], - style: { - foreground: values().textMuted, - }, - }, - // Additional common highlight groups - { - scope: ["string.special", "string.special.url"], - style: { - foreground: values().markdownLink, - underline: true, - }, - }, - { - scope: ["character"], - style: { - foreground: values().syntaxString, - }, - }, - { - scope: ["float"], - style: { - foreground: values().syntaxNumber, - }, - }, - { - scope: ["comment.error"], - style: { - foreground: values().error, - italic: true, - bold: true, - }, - }, - { - scope: ["comment.warning"], - style: { - foreground: values().warning, - italic: true, - bold: true, - }, - }, - { - scope: ["comment.todo", "comment.note"], - style: { - foreground: values().info, - italic: true, - bold: true, - }, - }, - { - scope: ["namespace"], - style: { - foreground: values().syntaxType, - }, - }, - { - scope: ["field"], - style: { - foreground: values().syntaxVariable, - }, - }, - { - scope: ["type.definition"], - style: { - foreground: values().syntaxType, - bold: true, - }, - }, - { - scope: ["keyword.export"], - style: { - foreground: values().syntaxKeyword, - }, - }, - { - scope: ["attribute", "annotation"], - style: { - foreground: values().warning, - }, - }, - { - scope: ["tag"], - style: { - foreground: values().error, - }, - }, - { - scope: ["tag.attribute"], - style: { - foreground: values().syntaxKeyword, - }, - }, - { - scope: ["tag.delimiter"], - style: { - foreground: values().syntaxOperator, - }, - }, - { - scope: ["markup.strikethrough"], - style: { - foreground: values().textMuted, - }, - }, - { - scope: ["markup.underline"], - style: { - foreground: values().text, - underline: true, - }, - }, - { - scope: ["markup.list.checked"], - style: { - foreground: values().success, - }, - }, - { - scope: ["markup.list.unchecked"], - style: { - foreground: values().textMuted, - }, - }, - { - scope: ["diff.plus"], - style: { - foreground: values().diffAdded, - }, - }, - { - scope: ["diff.minus"], - style: { - foreground: values().diffRemoved, - }, - }, - { - scope: ["diff.delta"], - style: { - foreground: values().diffContext, - }, - }, - { - scope: ["error"], - style: { - foreground: values().error, - bold: true, - }, - }, - { - scope: ["warning"], - style: { - foreground: values().warning, - bold: true, - }, - }, - { - scope: ["info"], - style: { - foreground: values().info, - }, - }, - { - scope: ["debug"], - style: { - foreground: values().textMuted, - }, - }, - ]) - }) + const syntax = createMemo(() => generateSyntax(values())) return { theme: new Proxy(values(), { @@ -654,16 +170,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }), get selected() { - return theme() + return store.active + }, + all() { + return store.themes }, syntax, - mode, + mode() { + return store.mode + }, setMode(mode: "dark" | "light") { - setMode(mode) + setStore("mode", mode) }, set(theme: string) { - if (!THEMES[theme]) return - setTheme(theme) + setStore("active", theme) kv.set("theme", theme) }, get ready() { @@ -672,3 +192,676 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } }, }) + +function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { + const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) + const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const palette = colors.palette.filter((x) => x !== null).map((x) => RGBA.fromHex(x)) + const isDark = mode == "dark" + + // Generate gray scale based on terminal background + const grays = generateGrayScale(bg, isDark) + const textMuted = generateMutedTextColor(bg, isDark) + + // ANSI color references + const ansiColors = { + black: palette[0], + red: palette[1], + green: palette[2], + yellow: palette[3], + blue: palette[4], + magenta: palette[5], + cyan: palette[6], + white: palette[7], + } + + return { + theme: { + // Primary colors using ANSI + primary: ansiColors.cyan, + secondary: ansiColors.magenta, + accent: ansiColors.cyan, + + // Status colors using ANSI + error: ansiColors.red, + warning: ansiColors.yellow, + success: ansiColors.green, + info: ansiColors.cyan, + + // Text colors + text: fg, + textMuted, + + // Background colors + background: bg, + backgroundPanel: grays[2], + backgroundElement: grays[3], + + // Border colors + borderSubtle: grays[6], + border: grays[7], + borderActive: grays[8], + + // Diff colors + diffAdded: ansiColors.green, + diffRemoved: ansiColors.red, + diffContext: grays[7], + diffHunkHeader: grays[7], + diffHighlightAdded: ansiColors.green, + diffHighlightRemoved: ansiColors.red, + diffAddedBg: grays[2], + diffRemovedBg: grays[2], + diffContextBg: grays[1], + diffLineNumber: grays[6], + diffAddedLineNumberBg: grays[3], + diffRemovedLineNumberBg: grays[3], + + // Markdown colors + markdownText: fg, + markdownHeading: fg, + markdownLink: ansiColors.blue, + markdownLinkText: ansiColors.cyan, + markdownCode: ansiColors.green, + markdownBlockQuote: ansiColors.yellow, + markdownEmph: ansiColors.yellow, + markdownStrong: fg, + markdownHorizontalRule: grays[7], + markdownListItem: ansiColors.blue, + markdownListEnumeration: ansiColors.cyan, + markdownImage: ansiColors.blue, + markdownImageText: ansiColors.cyan, + markdownCodeBlock: fg, + + // Syntax colors + syntaxComment: textMuted, + syntaxKeyword: ansiColors.magenta, + syntaxFunction: ansiColors.blue, + syntaxVariable: fg, + syntaxString: ansiColors.green, + syntaxNumber: ansiColors.yellow, + syntaxType: ansiColors.cyan, + syntaxOperator: ansiColors.cyan, + syntaxPunctuation: fg, + }, + } +} + +function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> { + const grays: Record<number, RGBA> = {} + + // RGBA stores floats in range 0-1, convert to 0-255 + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + + const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + for (let i = 1; i <= 12; i++) { + const factor = i / 12.0 + + let grayValue: number + let newR: number + let newG: number + let newB: number + + if (isDark) { + if (luminance < 10) { + grayValue = Math.floor(factor * 0.4 * 255) + newR = grayValue + newG = grayValue + newB = grayValue + } else { + const newLum = luminance + (255 - luminance) * factor * 0.4 + + const ratio = newLum / luminance + newR = Math.min(bgR * ratio, 255) + newG = Math.min(bgG * ratio, 255) + newB = Math.min(bgB * ratio, 255) + } + } else { + if (luminance > 245) { + grayValue = Math.floor(255 - factor * 0.4 * 255) + newR = grayValue + newG = grayValue + newB = grayValue + } else { + const newLum = luminance * (1 - factor * 0.4) + + const ratio = newLum / luminance + newR = Math.max(bgR * ratio, 0) + newG = Math.max(bgG * ratio, 0) + newB = Math.max(bgB * ratio, 0) + } + } + + grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB)) + } + + return grays +} + +function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA { + // RGBA stores floats in range 0-1, convert to 0-255 + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + + const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + let grayValue: number + + if (isDark) { + if (bgLum < 10) { + // Very dark/black background + grayValue = 180 // #b4b4b4 + } else { + // Scale up for lighter dark backgrounds + grayValue = Math.min(Math.floor(160 + bgLum * 0.3), 200) + } + } else { + if (bgLum > 245) { + // Very light/white background + grayValue = 75 // #4b4b4b + } else { + // Scale down for darker light backgrounds + grayValue = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60) + } + } + + return RGBA.fromInts(grayValue, grayValue, grayValue) +} + +function generateSyntax(theme: Theme) { + return SyntaxStyle.fromTheme([ + { + scope: ["prompt"], + style: { + foreground: theme.accent, + }, + }, + { + scope: ["extmark.file"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["extmark.agent"], + style: { + foreground: theme.secondary, + bold: true, + }, + }, + { + scope: ["extmark.paste"], + style: { + foreground: theme.background, + background: theme.warning, + bold: true, + }, + }, + { + scope: ["comment"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["comment.documentation"], + style: { + foreground: theme.syntaxComment, + italic: true, + }, + }, + { + scope: ["string", "symbol"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["number", "boolean"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["character.special"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.type"], + style: { + foreground: theme.syntaxType, + bold: true, + italic: true, + }, + }, + { + scope: ["keyword.function", "function.method"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["keyword"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.import"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["operator", "keyword.operator", "punctuation.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.conditional.ternary"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["variable", "variable.parameter", "function.method.call", "function.call"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["variable.member", "function", "constructor"], + style: { + foreground: theme.syntaxFunction, + }, + }, + { + scope: ["type", "module"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["constant"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["property"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["class"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["parameter"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["punctuation", "punctuation.bracket"], + style: { + foreground: theme.syntaxPunctuation, + }, + }, + { + scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["variable.super"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["string.escape", "string.regexp"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["keyword.directive"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["punctuation.special"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["keyword.modifier"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + { + scope: ["keyword.exception"], + style: { + foreground: theme.syntaxKeyword, + italic: true, + }, + }, + // Markdown specific styles + { + scope: ["markup.heading"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.1"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.2"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.3"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.4"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.5"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.heading.6"], + style: { + foreground: theme.markdownHeading, + bold: true, + }, + }, + { + scope: ["markup.bold", "markup.strong"], + style: { + foreground: theme.markdownStrong, + bold: true, + }, + }, + { + scope: ["markup.italic"], + style: { + foreground: theme.markdownEmph, + italic: true, + }, + }, + { + scope: ["markup.list"], + style: { + foreground: theme.markdownListItem, + }, + }, + { + scope: ["markup.quote"], + style: { + foreground: theme.markdownBlockQuote, + italic: true, + }, + }, + { + scope: ["markup.raw", "markup.raw.block"], + style: { + foreground: theme.markdownCode, + }, + }, + { + scope: ["markup.raw.inline"], + style: { + foreground: theme.markdownCode, + background: theme.background, + }, + }, + { + scope: ["markup.link"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["markup.link.label"], + style: { + foreground: theme.markdownLinkText, + underline: true, + }, + }, + { + scope: ["markup.link.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["label"], + style: { + foreground: theme.markdownLinkText, + }, + }, + { + scope: ["spell", "nospell"], + style: { + foreground: theme.text, + }, + }, + { + scope: ["conceal"], + style: { + foreground: theme.textMuted, + }, + }, + // Additional common highlight groups + { + scope: ["string.special", "string.special.url"], + style: { + foreground: theme.markdownLink, + underline: true, + }, + }, + { + scope: ["character"], + style: { + foreground: theme.syntaxString, + }, + }, + { + scope: ["float"], + style: { + foreground: theme.syntaxNumber, + }, + }, + { + scope: ["comment.error"], + style: { + foreground: theme.error, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.warning"], + style: { + foreground: theme.warning, + italic: true, + bold: true, + }, + }, + { + scope: ["comment.todo", "comment.note"], + style: { + foreground: theme.info, + italic: true, + bold: true, + }, + }, + { + scope: ["namespace"], + style: { + foreground: theme.syntaxType, + }, + }, + { + scope: ["field"], + style: { + foreground: theme.syntaxVariable, + }, + }, + { + scope: ["type.definition"], + style: { + foreground: theme.syntaxType, + bold: true, + }, + }, + { + scope: ["keyword.export"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["attribute", "annotation"], + style: { + foreground: theme.warning, + }, + }, + { + scope: ["tag"], + style: { + foreground: theme.error, + }, + }, + { + scope: ["tag.attribute"], + style: { + foreground: theme.syntaxKeyword, + }, + }, + { + scope: ["tag.delimiter"], + style: { + foreground: theme.syntaxOperator, + }, + }, + { + scope: ["markup.strikethrough"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["markup.underline"], + style: { + foreground: theme.text, + underline: true, + }, + }, + { + scope: ["markup.list.checked"], + style: { + foreground: theme.success, + }, + }, + { + scope: ["markup.list.unchecked"], + style: { + foreground: theme.textMuted, + }, + }, + { + scope: ["diff.plus"], + style: { + foreground: theme.diffAdded, + }, + }, + { + scope: ["diff.minus"], + style: { + foreground: theme.diffRemoved, + }, + }, + { + scope: ["diff.delta"], + style: { + foreground: theme.diffContext, + }, + }, + { + scope: ["error"], + style: { + foreground: theme.error, + bold: true, + }, + }, + { + scope: ["warning"], + style: { + foreground: theme.warning, + bold: true, + }, + }, + { + scope: ["info"], + style: { + foreground: theme.info, + }, + }, + { + scope: ["debug"], + style: { + foreground: theme.textMuted, + }, + }, + ]) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json b/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json index 8eff42c5f..24c74733d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json @@ -218,4 +218,4 @@ "light": "nightOwlFg" } } -}
\ No newline at end of file +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index a01bfa6a2..6f63258b9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,5 @@ -import { Prompt } from "@tui/component/prompt" -import { createMemo, Match, Show, Switch, type ParentProps } from "solid-js" +import { Prompt, type PromptRef } from "@tui/component/prompt" +import { createMemo, Match, onMount, Show, Switch, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { useKeybind } from "../context/keybind" import type { KeybindsConfig } from "@opencode-ai/sdk" @@ -7,6 +7,10 @@ import { Logo } from "../component/logo" import { Locale } from "@/util/locale" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" +import { useArgs } from "../context/args" + +// TODO: what is the best way to do this? +let once = false export function Home() { const sync = useSync() @@ -26,11 +30,7 @@ export function Home() { </Match> <Match when={true}> <span style={{ fg: theme.success }}>•</span>{" "} - {Locale.pluralize( - Object.values(sync.data.mcp).length, - "{} mcp server", - "{} mcp servers", - )} + {Locale.pluralize(Object.values(sync.data.mcp).length, "{} mcp server", "{} mcp servers")} </Match> </Switch> </text> @@ -38,15 +38,18 @@ export function Home() { </Show> ) + let prompt: PromptRef + const args = useArgs() + onMount(() => { + if (once) return + if (args.prompt) { + prompt.set({ input: args.prompt, parts: [] }) + once = true + } + }) + return ( - <box - flexGrow={1} - justifyContent="center" - alignItems="center" - paddingLeft={2} - paddingRight={2} - gap={1} - > + <box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}> <Logo /> <box width={39}> <HelpRow keybind="command_list">Commands</HelpRow> @@ -55,7 +58,7 @@ export function Home() { <HelpRow keybind="agent_cycle">Switch agent</HelpRow> </box> <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}> - <Prompt hint={Hint} /> + <Prompt ref={(r) => (prompt = r)} hint={Hint} /> </box> <Toast /> </box> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 019042519..9988fbd55 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -46,16 +46,10 @@ export function Header() { }) const context = createMemo(() => { - const last = messages().findLast( - (x) => x.role === "assistant" && x.tokens.output > 0, - ) as AssistantMessage + const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage if (!last) return const total = - last.tokens.input + - last.tokens.output + - last.tokens.reasoning + - last.tokens.cache.read + - last.tokens.cache.write + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID] let result = total.toLocaleString() if (model?.limit.context) { @@ -67,13 +61,7 @@ export function Header() { const { theme } = useTheme() return ( - <box - paddingLeft={1} - paddingRight={1} - {...SplitBorder} - borderColor={theme.backgroundElement} - flexShrink={0} - > + <box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={theme.backgroundElement} flexShrink={0}> <Show when={shareEnabled()} fallback={ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f37be32f7..886505c7a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -5,6 +5,7 @@ import { createSignal, For, Match, + on, Show, Switch, useContext, @@ -18,14 +19,7 @@ import { SplitBorder } from "@tui/component/border" import { useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { - AssistantMessage, - Part, - ToolPart, - UserMessage, - TextPart, - ReasoningPart, -} from "@opencode-ai/sdk" +import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -40,13 +34,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" -import { - useKeyboard, - useRenderer, - useTerminalDimensions, - type BoxProps, - type JSX, -} from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { Shimmer } from "@tui/ui/shimmer" @@ -59,12 +47,17 @@ import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" +import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import { Clipboard } from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" +import { Editor } from "../../util/editor" +import { Global } from "@/global" +import fs from "fs/promises" +import stripAnsi from "strip-ansi" addDefaultParsers(parsers.parsers) @@ -101,7 +94,20 @@ export function Session() { const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide())) const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) - createEffect(() => sync.session.sync(route.sessionID)) + createEffect(async () => { + await sync.session + .sync(route.sessionID) + .then(() => { + scroll.scrollBy(100_000) + }) + .catch(() => { + toast.show({ + message: `Session not found: ${route.sessionID}`, + variant: "error", + }) + return navigate({ type: "home" }) + }) + }) const toast = useToast() @@ -120,6 +126,7 @@ export function Session() { if (evt.name === "return") return "once" if (evt.name === "a") return "always" if (evt.name === "d") return "reject" + if (evt.name === "escape") return "reject" return }) if (response) { @@ -142,12 +149,6 @@ export function Session() { }, 50) } - // snap to bottom when revert position changes - createEffect((old) => { - if (old !== session()?.revert?.messageID) toBottom() - return session()?.revert?.messageID - }) - const local = useLocal() function moveChild(direction: number) { @@ -170,6 +171,15 @@ export function Session() { const command = useCommandDialog() command.register(() => [ { + title: "Rename session", + value: "session.rename", + keybind: "session_rename", + category: "Session", + onSelect: (dialog) => { + dialog.replace(() => <DialogSessionRename session={route.sessionID} />) + }, + }, + { title: "Jump to message", value: "session.timeline", keybind: "session_timeline", @@ -253,14 +263,18 @@ export function Session() { const revert = session().revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return - sdk.client.session.revert({ - path: { - id: route.sessionID, - }, - body: { - messageID: message.id, - }, - }) + sdk.client.session + .revert({ + path: { + id: route.sessionID, + }, + body: { + messageID: message.id, + }, + }) + .then(() => { + toBottom() + }) const parts = sync.data.part[message.id] prompt.set( parts.reduce( @@ -308,7 +322,7 @@ export function Session() { }, }, { - title: "Toggle sidebar", + title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", keybind: "sidebar_toggle", category: "Session", @@ -446,6 +460,105 @@ export function Session() { }, }, { + title: "Copy session transcript", + value: "session.copy", + keybind: "session_copy", + category: "Session", + onSelect: async (dialog) => { + try { + // Format session transcript as markdown + const sessionData = session() + const sessionMessages = messages() + + let transcript = `# ${sessionData.title}\n\n` + transcript += `**Session ID:** ${sessionData.id}\n` + transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + + for (const msg of sessionMessages) { + const parts = sync.data.part[msg.id] ?? [] + const role = msg.role === "user" ? "User" : "Assistant" + transcript += `## ${role}\n\n` + + for (const part of parts) { + if (part.type === "text" && !part.synthetic) { + transcript += `${part.text}\n\n` + } else if (part.type === "tool") { + transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + } + } + + transcript += `---\n\n` + } + + // Copy to clipboard + await Clipboard.copy(transcript) + toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) + } catch (error) { + toast.show({ message: "Failed to copy session transcript", variant: "error" }) + } + dialog.clear() + }, + }, + { + title: "Export session transcript to file", + value: "session.export", + keybind: "session_export", + category: "Session", + onSelect: async (dialog) => { + try { + // Format session transcript as markdown + const sessionData = session() + const sessionMessages = messages() + + let transcript = `# ${sessionData.title}\n\n` + transcript += `**Session ID:** ${sessionData.id}\n` + transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + + for (const msg of sessionMessages) { + const parts = sync.data.part[msg.id] ?? [] + const role = msg.role === "user" ? "User" : "Assistant" + transcript += `## ${role}\n\n` + + for (const part of parts) { + if (part.type === "text" && !part.synthetic) { + transcript += `${part.text}\n\n` + } else if (part.type === "tool") { + transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + } + } + + transcript += `---\n\n` + } + + // Save to file in data directory + const exportDir = path.join(Global.Path.data, "exports") + await fs.mkdir(exportDir, { recursive: true }) + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const filename = `session-${sessionData.id.slice(0, 8)}-${timestamp}.md` + const filepath = path.join(exportDir, filename) + + await Bun.write(filepath, transcript) + + // Open with EDITOR if available + const result = await Editor.open({ value: transcript, renderer }) + if (result !== undefined) { + // User edited the file, save the changes + await Bun.write(filepath, result) + } + + toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + } catch (error) { + toast.show({ message: "Failed to export session", variant: "error" }) + } + dialog.clear() + }, + }, + { title: "Next child session", value: "session.child.next", keybind: "session_child_cycle", @@ -513,6 +626,9 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + // snap to bottom when session changes + createEffect(on(() => route.sessionID, toBottom)) + return ( <context.Provider value={{ @@ -522,14 +638,7 @@ export function Session() { conceal, }} > - <box - flexDirection="row" - paddingBottom={1} - paddingTop={1} - paddingLeft={2} - paddingRight={2} - gap={2} - > + <box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}> <box flexGrow={1} gap={1}> <Show when={session()}> <Show when={session().parentID}> @@ -544,19 +653,13 @@ export function Session() { paddingRight={2} > <text fg={theme.text}> - Previous{" "} - <span style={{ fg: theme.textMuted }}> - {keybind.print("session_child_cycle_reverse")} - </span> + Previous <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> </text> <text fg={theme.text}> <b>Viewing subagent session</b> </text> <text fg={theme.text}> - <span style={{ fg: theme.textMuted }}> - {keybind.print("session_child_cycle")} - </span>{" "} - Next + <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Next </text> </box> </Show> @@ -565,7 +668,14 @@ export function Session() { </Show> <scrollbox ref={(r) => (scroll = r)} - scrollbarOptions={{ visible: false }} + scrollbarOptions={{ + paddingLeft: 2, + visible: false, + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} stickyScroll={true} stickyStart="bottom" flexGrow={1} @@ -605,18 +715,12 @@ export function Session() { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={ - hover() ? theme.backgroundElement : theme.backgroundPanel - } + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} > + <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text> <text fg={theme.textMuted}> - {revert()!.reverted.length} message reverted - </text> - <text fg={theme.textMuted}> - <span style={{ fg: theme.text }}> - {keybind.print("messages_redo")} - </span>{" "} - or /redo to restore + <span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to + restore </text> <Show when={revert()!.diffFiles?.length}> <box marginTop={1}> @@ -625,16 +729,10 @@ export function Session() { <text> {file.filename} <Show when={file.additions > 0}> - <span style={{ fg: theme.diffAdded }}> - {" "} - +{file.additions} - </span> + <span style={{ fg: theme.diffAdded }}> +{file.additions}</span> </Show> <Show when={file.deletions > 0}> - <span style={{ fg: theme.diffRemoved }}> - {" "} - -{file.deletions} - </span> + <span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span> </Show> </text> )} @@ -654,9 +752,7 @@ export function Session() { index={index()} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - <DialogMessage messageID={message.id} sessionID={route.sessionID} /> - )) + dialog.replace(() => <DialogMessage messageID={message.id} sessionID={route.sessionID} />) }} message={message as UserMessage} parts={sync.data.part[message.id] ?? []} @@ -712,9 +808,7 @@ function UserMessage(props: { index: number pending?: string }) { - const text = createMemo( - () => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0], - ) + const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() const { theme } = useTheme() @@ -755,14 +849,8 @@ function UserMessage(props: { }) return ( <text fg={theme.text}> - <span style={{ bg: bg(), fg: theme.background }}> - {" "} - {MIME_BADGE[file.mime] ?? file.mime}{" "} - </span> - <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> - {" "} - {file.filename}{" "} - </span> + <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span> + <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span> </text> ) }} @@ -773,16 +861,9 @@ function UserMessage(props: { {sync.data.config.username ?? "You"}{" "} <Show when={queued()} - fallback={ - <span style={{ fg: theme.textMuted }}> - ({Locale.time(props.message.time.created)}) - </span> - } + fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>} > - <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> - {" "} - QUEUED{" "} - </span> + <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> </Show> </text> </box> @@ -796,11 +877,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return ( <> <For each={props.parts}> - {(part) => { + {(part, index) => { const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( <Show when={component()}> - <Dynamic component={component()} part={part as any} message={props.message} /> + <Dynamic + last={index() === props.parts.length - 1} + component={component()} + part={part as any} + message={props.message} + /> </Show> ) }} @@ -822,8 +908,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las <Show when={ !props.message.time.completed || - (props.last && - props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls")) + (props.last && props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls")) } > <box @@ -835,9 +920,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las customBorderChars={SplitBorder.customBorderChars} borderColor={theme.backgroundElement} > - <text fg={local.agent.color(props.message.mode)}> - {Locale.titlecase(props.message.mode)} - </text> + <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text> <Shimmer text={`${props.message.modelID}`} color={theme.text} /> </box> </Show> @@ -849,9 +932,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las > <box paddingLeft={3}> <text marginTop={1}> - <span style={{ fg: local.agent.color(props.message.mode) }}> - {Locale.titlecase(props.message.mode)} - </span>{" "} + <span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "} <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span> </text> </box> @@ -866,32 +947,36 @@ const PART_MAPPING = { reasoning: ReasoningPart, } -function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) { - const { theme } = useTheme() +function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { + const { theme, syntax } = useTheme() + const ctx = use() return ( <Show when={props.part.text.trim()}> <box id={"text-" + props.part.id} + paddingLeft={2} marginTop={1} - flexShrink={0} + flexDirection="row" + gap={1} border={["left"]} customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} + borderColor={theme.backgroundElement} > - <box - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - backgroundColor={theme.backgroundPanel} - > - <text fg={theme.text}>{props.part.text.trim()}</text> - </box> + <code + filetype="markdown" + drawUnstyledText={false} + streaming={true} + syntaxStyle={syntax()} + content={props.part.text.trim()} + conceal={ctx.conceal()} + fg={theme.text} + /> </box> </Show> ) } -function TextPart(props: { part: TextPart; message: AssistantMessage }) { +function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { syntax } = useTheme() return ( @@ -900,6 +985,7 @@ function TextPart(props: { part: TextPart; message: AssistantMessage }) { <code filetype="markdown" drawUnstyledText={false} + streaming={true} syntaxStyle={syntax()} content={props.part.text.trim()} conceal={ctx.conceal()} @@ -911,7 +997,7 @@ function TextPart(props: { part: TextPart; message: AssistantMessage }) { // Pending messages moved to individual tool pending functions -function ToolPart(props: { part: ToolPart; message: AssistantMessage }) { +function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { const { theme } = useTheme() const sync = useSync() const [margin, setMargin] = createSignal(0) @@ -1060,7 +1146,7 @@ ToolRegistry.register<typeof BashTool>({ name: "bash", container: "block", render(props) { - const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? "")) + const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const { theme } = useTheme() return ( <> @@ -1122,16 +1208,10 @@ ToolRegistry.register<typeof WriteTool>({ </ToolTitle> <box flexDirection="row"> <box flexShrink={0}> - <For each={numbers()}> - {(value) => <text style={{ fg: theme.textMuted }}>{value}</text>} - </For> + <For each={numbers()}>{(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}</For> </box> <box paddingLeft={1} flexGrow={1}> - <code - filetype={filetype(props.input.filePath!)} - syntaxStyle={syntax()} - content={code()} - /> + <code filetype={filetype(props.input.filePath!)} syntaxStyle={syntax()} content={code()} /> </box> </box> </> @@ -1146,8 +1226,7 @@ ToolRegistry.register<typeof GlobTool>({ return ( <> <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}> - Glob "{props.input.pattern}"{" "} - <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> + Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> <Show when={props.metadata.count}>({props.metadata.count} matches)</Show> </ToolTitle> </> @@ -1161,8 +1240,7 @@ ToolRegistry.register<typeof GrepTool>({ render(props) { return ( <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}> - Grep "{props.input.pattern}"{" "} - <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> + Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show> </ToolTitle> ) @@ -1198,11 +1276,7 @@ ToolRegistry.register<typeof TaskTool>({ return ( <> - <ToolTitle - icon="%" - fallback="Delegating..." - when={props.input.subagent_type ?? props.input.description} - > + <ToolTitle icon="%" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}> Task [{props.input.subagent_type ?? "unknown"}] {props.input.description} </ToolTitle> <Show when={props.metadata.summary?.length}> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index c63297db2..f6f3f7c73 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -9,6 +9,7 @@ export function Sidebar(props: { sessionID: string }) { const sync = useSync() const { theme } = useTheme() const session = createMemo(() => sync.session.get(props.sessionID)!) + const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) @@ -21,16 +22,10 @@ export function Sidebar(props: { sessionID: string }) { }) const context = createMemo(() => { - const last = messages().findLast( - (x) => x.role === "assistant" && x.tokens.output > 0, - ) as AssistantMessage + const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage if (!last) return const total = - last.tokens.input + - last.tokens.output + - last.tokens.reasoning + - last.tokens.cache.read + - last.tokens.cache.write + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID] return { tokens: total.toLocaleString(), @@ -40,136 +35,134 @@ export function Sidebar(props: { sessionID: string }) { return ( <Show when={session()}> - <box flexShrink={0} gap={1} width={40}> - <box> - <text fg={theme.text}> - <b>{session().title}</b> - </text> - <Show when={session().share?.url}> - <text fg={theme.textMuted}>{session().share!.url}</text> - </Show> - </box> - <box> - <text fg={theme.text}> - <b>Context</b> - </text> - <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text> - <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text> - <text fg={theme.textMuted}>{cost()} spent</text> - </box> - <Show when={Object.keys(sync.data.mcp).length > 0}> + <scrollbox width={40}> + <box flexShrink={0} gap={1} paddingRight={1}> <box> <text fg={theme.text}> - <b>MCP</b> + <b>{session().title}</b> </text> - <For each={Object.entries(sync.data.mcp)}> - {([key, item]) => ( - <box flexDirection="row" gap={1}> - <text - flexShrink={0} - style={{ - fg: { - connected: theme.success, - failed: theme.error, - disabled: theme.textMuted, - }[item.status], - }} - > - • - </text> - <text fg={theme.text} wrapMode="word"> - {key}{" "} - <span style={{ fg: theme.textMuted }}> - <Switch> - <Match when={item.status === "connected"}>Connected</Match> - <Match when={item.status === "failed" && item}> - {(val) => <i>{val().error}</i>} - </Match> - <Match when={item.status === "disabled"}>Disabled in configuration</Match> - </Switch> - </span> - </text> - </box> - )} - </For> + <Show when={session().share?.url}> + <text fg={theme.textMuted}>{session().share!.url}</text> + </Show> </box> - </Show> - <Show when={sync.data.lsp.length > 0}> <box> <text fg={theme.text}> - <b>LSP</b> + <b>Context</b> </text> - <For each={sync.data.lsp}> - {(item) => ( - <box flexDirection="row" gap={1}> - <text - flexShrink={0} - style={{ - fg: { - connected: theme.success, - error: theme.error, - }[item.status], - }} - > - • - </text> - <text fg={theme.textMuted}> - {item.id} {item.root} - </text> - </box> - )} - </For> + <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text> + <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text> + <text fg={theme.textMuted}>{cost()} spent</text> </box> - </Show> - <Show when={session().summary?.diffs}> - <box> - <text fg={theme.text}> - <b>Modified Files</b> - </text> - <For each={session().summary?.diffs || []}> - {(item) => { - const file = createMemo(() => { - const splits = item.file.split(path.sep).filter(Boolean) - const last = splits.at(-1)! - const rest = splits.slice(0, -1).join(path.sep) - return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last - }) - return ( - <box flexDirection="row" gap={1} justifyContent="space-between"> - <text fg={theme.textMuted} wrapMode="char"> - {file()} + <Show when={Object.keys(sync.data.mcp).length > 0}> + <box> + <text fg={theme.text}> + <b>MCP</b> + </text> + <For each={Object.entries(sync.data.mcp)}> + {([key, item]) => ( + <box flexDirection="row" gap={1}> + <text + flexShrink={0} + style={{ + fg: { + connected: theme.success, + failed: theme.error, + disabled: theme.textMuted, + }[item.status], + }} + > + • + </text> + <text fg={theme.text} wrapMode="word"> + {key}{" "} + <span style={{ fg: theme.textMuted }}> + <Switch> + <Match when={item.status === "connected"}>Connected</Match> + <Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match> + <Match when={item.status === "disabled"}>Disabled in configuration</Match> + </Switch> + </span> </text> - <box flexDirection="row" gap={1} flexShrink={0}> - <Show when={item.additions}> - <text fg={theme.diffAdded}>+{item.additions}</text> - </Show> - <Show when={item.deletions}> - <text fg={theme.diffRemoved}>-{item.deletions}</text> - </Show> - </box> </box> - ) - }} - </For> - </box> - </Show> - <Show when={todo().length > 0}> - <box> - <text fg={theme.text}> - <b>Todo</b> - </text> - <For each={todo()}> - {(todo) => ( - <text - style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }} - > - [{todo.status === "completed" ? "✓" : " "}] {todo.content} - </text> - )} - </For> - </box> - </Show> - </box> + )} + </For> + </box> + </Show> + <Show when={sync.data.lsp.length > 0}> + <box> + <text fg={theme.text}> + <b>LSP</b> + </text> + <For each={sync.data.lsp}> + {(item) => ( + <box flexDirection="row" gap={1}> + <text + flexShrink={0} + style={{ + fg: { + connected: theme.success, + error: theme.error, + }[item.status], + }} + > + • + </text> + <text fg={theme.textMuted}> + {item.id} {item.root} + </text> + </box> + )} + </For> + </box> + </Show> + <Show when={diff().length > 0}> + <box> + <text fg={theme.text}> + <b>Modified Files</b> + </text> + <For each={diff() || []}> + {(item) => { + const file = createMemo(() => { + const splits = item.file.split(path.sep).filter(Boolean) + const last = splits.at(-1)! + const rest = splits.slice(0, -1).join(path.sep) + return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last + }) + return ( + <box flexDirection="row" gap={1} justifyContent="space-between"> + <text fg={theme.textMuted} wrapMode="char"> + {file()} + </text> + <box flexDirection="row" gap={1} flexShrink={0}> + <Show when={item.additions}> + <text fg={theme.diffAdded}>+{item.additions}</text> + </Show> + <Show when={item.deletions}> + <text fg={theme.diffRemoved}>-{item.deletions}</text> + </Show> + </box> + </box> + ) + }} + </For> + </box> + </Show> + <Show when={todo().length > 0}> + <box> + <text fg={theme.text}> + <b>Todo</b> + </text> + <For each={todo()}> + {(todo) => ( + <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}> + [{todo.status === "completed" ? "✓" : " "}] {todo.content} + </text> + )} + </For> + </box> + </Show> + </box> + </scrollbox> </Show> ) } diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index b05bb5829..5b6f5e83b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -2,11 +2,9 @@ import { cmd } from "@/cli/cmd/cmd" import { tui } from "./app" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" -import { upgrade } from "@/cli/upgrade" -import { Session } from "@/session" -import { bootstrap } from "@/cli/bootstrap" import path from "path" import { UI } from "@/cli/ui" +import { iife } from "@/util/iife" declare global { const OPENCODE_WORKER_PATH: string @@ -33,8 +31,8 @@ export const TuiThreadCommand = cmd({ }) .option("session", { alias: ["s"], - describe: "session id to continue", type: "string", + describe: "session id to continue", }) .option("prompt", { alias: ["p"], @@ -56,12 +54,6 @@ export const TuiThreadCommand = cmd({ default: "127.0.0.1", }), handler: async (args) => { - const prompt = await (async () => { - const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined - if (!args.prompt) return piped - return piped ? piped + "\n" + args.prompt : args.prompt - })() - // Resolve relative paths against PWD to preserve behavior when using --cwd flag const baseCwd = process.env.PWD ?? process.cwd() const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() @@ -77,58 +69,40 @@ export const TuiThreadCommand = cmd({ return } - await bootstrap(cwd, async () => { - upgrade() - - const sessionID = await (async () => { - if (args.continue) { - const it = Session.list() - try { - for await (const s of it) { - if (s.parentID === undefined) { - return s.id - } - } - return - } finally { - await it.return() - } - } - if (args.session) { - return args.session - } - return undefined - })() - - const worker = new Worker(workerPath, { - env: Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ), - ), - }) - worker.onerror = console.error - const client = Rpc.client<typeof rpc>(worker) - process.on("uncaughtException", (e) => { - console.error(e) - }) - process.on("unhandledRejection", (e) => { - console.error(e) - }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) - await tui({ - url: server.url, - sessionID, - model: args.model, + const worker = new Worker(workerPath, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) + worker.onerror = console.error + const client = Rpc.client<typeof rpc>(worker) + process.on("uncaughtException", (e) => { + console.error(e) + }) + process.on("unhandledRejection", (e) => { + console.error(e) + }) + const server = await client.call("server", { + port: args.port, + hostname: args.hostname, + }) + const prompt = await iife(async () => { + const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined + if (!args.prompt) return piped + return piped ? piped + "\n" + args.prompt : args.prompt + }) + await tui({ + url: server.url, + args: { + continue: args.continue, + sessionID: args.session, agent: args.agent, + model: args.model, prompt, - onExit: async () => { - await client.call("shutdown", undefined) - }, - }) + }, + onExit: async () => { + await client.call("shutdown", undefined) + }, }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index f08e0dabf..19624147d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -20,17 +20,10 @@ export function DialogHelp() { <text fg={theme.textMuted}>esc/enter</text> </box> <box paddingBottom={1}> - <text fg={theme.textMuted}> - Press Ctrl+P to see all available actions and commands in any context. - </text> + <text fg={theme.textMuted}>Press Ctrl+P to see all available actions and commands in any context.</text> </box> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> - <box - paddingLeft={3} - paddingRight={3} - backgroundColor={theme.primary} - onMouseUp={() => dialog.clear()} - > + <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}> <text fg={theme.background}>ok</text> </box> </box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 1c37b981c..bc9b78dc0 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,7 +23,6 @@ export interface DialogSelectProps<T> { title: string onTrigger: (option: DialogSelectOption<T>) => void }[] - limit?: number current?: T } @@ -31,7 +30,7 @@ export interface DialogSelectOption<T = any> { title: string value: T description?: string - footer?: string + footer?: JSX.Element | string category?: string disabled?: boolean bg?: RGBA @@ -58,9 +57,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { const result = pipe( props.options, filter((x) => x.disabled !== true), - take(props.limit ?? Infinity), - (x) => - !needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj), + (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)), ) return result }) @@ -175,7 +172,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { props.onFilter?.(e) }) }} - onKeyDown={(e) => {}} focusedBackgroundColor={theme.backgroundPanel} cursorColor={theme.primary} focusedTextColor={theme.textMuted} @@ -216,15 +212,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { props.onSelect?.(option) }} onMouseOver={() => { - const index = filtered().findIndex((x) => - isDeepEqual(x.value, option.value), - ) + const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} - backgroundColor={ - active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0) - } + backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} paddingLeft={1} paddingRight={1} gap={1} @@ -232,9 +224,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { <Option title={option.title} footer={option.footer} - description={ - option.description !== category ? option.description : undefined - } + description={option.description !== category ? option.description : undefined} active={active()} current={isDeepEqual(option.value, props.current)} /> @@ -250,9 +240,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { <For each={props.keybind ?? []}> {(item) => ( <text> - <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}> - {Keybind.toString(item.keybind)} - </span> + <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.textMuted }}> {item.title}</span> </text> )} @@ -267,12 +255,17 @@ function Option(props: { description?: string active?: boolean current?: boolean - footer?: string + footer?: JSX.Element | string onMouseOver?: () => void }) { const { theme } = useTheme() return ( <> + <Show when={props.current && !props.active}> + <text flexShrink={0} fg={theme.primary} marginRight={0.5}> + ● + </text> + </Show> <text flexGrow={1} fg={props.active ? theme.background : props.current ? theme.primary : theme.text} @@ -281,10 +274,7 @@ function Option(props: { wrapMode="none" > {Locale.truncate(props.title, 62)} - <span style={{ fg: props.active ? theme.background : theme.textMuted }}> - {" "} - {props.description} - </span> + <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> </text> <Show when={props.footer}> <box flexShrink={0}> diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts new file mode 100644 index 000000000..2b81068b3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -0,0 +1,114 @@ +import { RGBA } from "@opentui/core" + +export namespace Terminal { + export type Colors = Awaited<ReturnType<typeof colors>> + /** + * Query terminal colors including background, foreground, and palette (0-15). + * Uses OSC escape sequences to retrieve actual terminal color values. + * + * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. + * OSC 10/11 (foreground/background) typically work in most environments. + * + * Returns an object with background, foreground, and colors array. + * Any query that fails will be null/empty. + */ + export async function colors(): Promise<{ + background: RGBA | null + foreground: RGBA | null + colors: RGBA[] + }> { + if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } + + return new Promise((resolve) => { + let background: RGBA | null = null + let foreground: RGBA | null = null + const paletteColors: RGBA[] = [] + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const parseColor = (colorStr: string): RGBA | null => { + if (colorStr.startsWith("rgb:")) { + const parts = colorStr.substring(4).split("/") + return RGBA.fromInts( + parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit + parseInt(parts[1], 16) >> 8, + parseInt(parts[2], 16) >> 8, + 255, + ) + } + if (colorStr.startsWith("#")) { + return RGBA.fromHex(colorStr) + } + if (colorStr.startsWith("rgb(")) { + const parts = colorStr.substring(4, colorStr.length - 1).split(",") + return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) + } + return null + } + + const handler = (data: Buffer) => { + const str = data.toString() + + // Match OSC 11 (background color) + const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) + if (bgMatch) { + background = parseColor(bgMatch[1]) + } + + // Match OSC 10 (foreground color) + const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) + if (fgMatch) { + foreground = parseColor(fgMatch[1]) + } + + // Match OSC 4 (palette colors) + const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) + for (const match of paletteMatches) { + const index = parseInt(match[1]) + const color = parseColor(match[2]) + if (color) paletteColors[index] = color + } + + // Return immediately if we have all 16 palette colors + if (paletteColors.filter((c) => c !== undefined).length === 16) { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + } + } + + process.stdin.setRawMode(true) + process.stdin.on("data", handler) + + // Query background (OSC 11) + process.stdout.write("\x1b]11;?\x07") + // Query foreground (OSC 10) + process.stdout.write("\x1b]10;?\x07") + // Query palette colors 0-15 (OSC 4) + for (let i = 0; i < 16; i++) { + process.stdout.write(`\x1b]4;${i};?\x07`) + } + + timeout = setTimeout(() => { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + }, 1000) + }) + } + + export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { + const result = await colors() + if (!result.background) return "dark" + + const { r, g, b } = result.background + // Calculate luminance using relative luminance formula + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + + // Determine if dark or light based on luminance threshold + return luminance > 0.5 ? "light" : "dark" + } +} diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index d268449c1..32cd5562a 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -3,6 +3,7 @@ import { Server } from "@/server/server" import { Log } from "@/util/log" import { Instance } from "@/project/instance" import { Rpc } from "@/util/rpc" +import { upgrade } from "@/cli/upgrade" await Log.init({ print: process.argv.includes("--print-logs"), @@ -25,6 +26,8 @@ process.on("uncaughtException", (e) => { }) }) +upgrade() + let server: Bun.Server<undefined> export const rpc = { async server(input: { port: number; hostname: string }) { diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 8fc8a9915..3d3036b1b 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -2,6 +2,29 @@ import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" import open from "open" +import { networkInterfaces } from "os" + +function getNetworkIPs() { + const nets = networkInterfaces() + const results: string[] = [] + + for (const name of Object.keys(nets)) { + const net = nets[name] + if (!net) continue + + for (const netInfo of net) { + // Skip internal and non-IPv4 addresses + if (netInfo.internal || netInfo.family !== "IPv4") continue + + // Skip Docker bridge networks (typically 172.x.x.x) + if (netInfo.address.startsWith("172.")) continue + + results.push(netInfo.address) + } + } + + return results +} export const WebCommand = cmd({ command: "web", @@ -29,12 +52,32 @@ export const WebCommand = cmd({ UI.empty() UI.println(UI.logo(" ")) UI.empty() - UI.println( - UI.Style.TEXT_INFO_BOLD + " Web interface: ", - UI.Style.TEXT_NORMAL, - server.url.toString(), - ) - open(server.url.toString()).catch(() => {}) + + if (hostname === "0.0.0.0") { + // Show localhost for local access + const localhostUrl = `http://localhost:${server.port}` + UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) + + // Show network IPs for remote access + const networkIPs = getNetworkIPs() + if (networkIPs.length > 0) { + for (const ip of networkIPs) { + UI.println( + UI.Style.TEXT_INFO_BOLD + " Network access: ", + UI.Style.TEXT_NORMAL, + `http://${ip}:${server.port}`, + ) + } + } + + // Open localhost in browser + open(localhostUrl.toString()).catch(() => {}) + } else { + const displayUrl = server.url.toString() + UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) + open(displayUrl).catch(() => {}) + } + await new Promise(() => {}) await server.stop() }, diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 7c873ae50..b3ff5670e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -8,8 +8,7 @@ export function FormatError(input: unknown) { return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` if (Config.JsonError.isInstance(input)) { return ( - `Config file at ${input.data.path} is not valid JSON(C)` + - (input.data.message ? `: ${input.data.message}` : "") + `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") ) } if (Config.ConfigDirectoryTypoError.isInstance(input)) { @@ -20,10 +19,8 @@ export function FormatError(input: unknown) { } if (Config.InvalidError.isInstance(input)) return [ - `Config file at ${input.data.path} is invalid` + - (input.data.message ? `: ${input.data.message}` : ""), - ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? - []), + `Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""), + ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), ].join("\n") if (UI.CancelledError.isInstance(input)) return "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index efd31ccba..eaac1dd4f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,11 +11,7 @@ import { lazy } from "../util/lazy" import { NamedError } from "../util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" -import { - type ParseError as JsoncParseError, - parse as parseJsonc, - printParseErrorCode, -} from "jsonc-parser" +import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" @@ -50,10 +46,7 @@ export namespace Config { if (value.type === "wellknown") { process.env[value.key] = value.token const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any - result = mergeDeep( - result, - await load(JSON.stringify(wellknown.config ?? {}), process.cwd()), - ) + result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) } } @@ -80,6 +73,15 @@ export namespace Config { const promises: Promise<void>[] = [] for (const dir of directories) { await assertValid(dir) + + for (const file of ["opencode.jsonc", "opencode.json"]) { + result = mergeDeep(result, await loadFile(path.join(dir, file))) + // to satisy the type checker + result.agent ??= {} + result.mode ??= {} + result.plugin ??= [] + } + promises.push(installDependencies(dir)) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) @@ -150,18 +152,10 @@ export namespace Config { const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Bun.file(gitignore).exists() - if (!hasGitIgnore) - await Bun.write( - gitignore, - ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"), - ) + if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) await BunProc.run( - [ - "add", - "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), - "--exact", - ], + ["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], { cwd: dir, }, @@ -321,10 +315,7 @@ export namespace Config { type: z.literal("remote").describe("Type of MCP server connection"), url: z.string().describe("URL of the remote MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - headers: z - .record(z.string(), z.string()) - .optional() - .describe("Headers to send with the request"), + headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), timeout: z .number() .int() @@ -369,6 +360,8 @@ export namespace Config { edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), + doom_loop: Permission.optional(), + external_directory: Permission.optional(), }) .optional(), }) @@ -380,70 +373,30 @@ export namespace Config { export const Keybinds = z .object({ - leader: z - .string() - .optional() - .default("ctrl+x") - .describe("Leader key for keybind combinations"), - app_exit: z - .string() - .optional() - .default("ctrl+c,ctrl+d,<leader>q") - .describe("Exit the application"), + leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"), editor_open: z.string().optional().default("<leader>e").describe("Open external editor"), theme_list: z.string().optional().default("<leader>t").describe("List available themes"), sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"), status_view: z.string().optional().default("<leader>s").describe("View status"), - session_export: z - .string() - .optional() - .default("<leader>x") - .describe("Export session to editor"), + session_export: z.string().optional().default("<leader>x").describe("Export session to editor"), session_new: z.string().optional().default("<leader>n").describe("Create a new session"), session_list: z.string().optional().default("<leader>l").describe("List all sessions"), - session_timeline: z - .string() - .optional() - .default("<leader>g") - .describe("Show session timeline"), + session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), - session_interrupt: z - .string() - .optional() - .default("escape") - .describe("Interrupt current session"), + session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("<leader>c").describe("Compact the session"), - messages_page_up: z - .string() - .optional() - .default("pageup") - .describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown") - .describe("Scroll messages down by one page"), - messages_half_page_up: z - .string() - .optional() - .default("ctrl+alt+u") - .describe("Scroll messages up by half page"), + messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), + messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), + messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() .optional() .default("ctrl+alt+d") .describe("Scroll messages down by half page"), - messages_first: z - .string() - .optional() - .default("ctrl+g,home") - .describe("Navigate to first message"), - messages_last: z - .string() - .optional() - .default("ctrl+alt+g,end") - .describe("Navigate to last message"), + messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), + messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), messages_copy: z.string().optional().default("<leader>y").describe("Copy message"), messages_undo: z.string().optional().default("<leader>u").describe("Undo message"), messages_redo: z.string().optional().default("<leader>r").describe("Redo message"), @@ -454,11 +407,7 @@ export namespace Config { .describe("Toggle code block concealment in messages"), model_list: z.string().optional().default("<leader>m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), - model_cycle_recent_reverse: z - .string() - .optional() - .default("shift+f2") - .describe("Previous recently used model"), + model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("<leader>a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), @@ -467,23 +416,11 @@ export namespace Config { input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), input_submit: z.string().optional().default("return").describe("Submit input"), - input_newline: z - .string() - .optional() - .default("shift+return,ctrl+j") - .describe("Insert newline in input"), + input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), - session_child_cycle: z - .string() - .optional() - .default("ctrl+right") - .describe("Next child session"), - session_child_cycle_reverse: z - .string() - .optional() - .default("ctrl+left") - .describe("Previous child session"), + session_child_cycle: z.string().optional().default("ctrl+right").describe("Next child session"), + session_child_cycle_reverse: z.string().optional().default("ctrl+left").describe("Previous child session"), }) .strict() .meta({ @@ -525,23 +462,13 @@ export namespace Config { autoshare: z .boolean() .optional() - .describe( - "@deprecated Use 'share' field instead. Share newly created sessions automatically", - ), + .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), autoupdate: z.boolean().optional().describe("Automatically update to the latest version"), - disabled_providers: z - .array(z.string()) - .optional() - .describe("Disable providers that are loaded automatically"), - model: z - .string() - .describe("Model to use in the format of provider/model, eg anthropic/claude-2") - .optional(), + disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), + model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), small_model: z .string() - .describe( - "Small model to use for tasks like title generation in the format of provider/model", - ) + .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), username: z .string() @@ -598,10 +525,7 @@ export namespace Config { ) .optional() .describe("Custom provider configurations and model overrides"), - mcp: z - .record(z.string(), Mcp) - .optional() - .describe("MCP (Model Context Protocol) server configurations"), + mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), formatter: z .record( z.string(), @@ -645,16 +569,15 @@ export namespace Config { error: "For custom LSP servers, 'extensions' array is required.", }, ), - instructions: z - .array(z.string()) - .optional() - .describe("Additional instruction files or patterns to include"), + instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: z .object({ edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), + doom_loop: Permission.optional(), + external_directory: Permission.optional(), }) .optional(), tools: z.record(z.string(), z.boolean()).optional(), @@ -682,10 +605,7 @@ export namespace Config { .optional(), }) .optional(), - chatMaxRetries: z - .number() - .optional() - .describe("Number of retries for chat completions on failure"), + chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), disable_paste_summary: z.boolean().optional(), }) .optional(), @@ -715,10 +635,7 @@ export namespace Config { if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" result = mergeDeep(result, rest) - await Bun.write( - path.join(Global.Path.config, "config.json"), - JSON.stringify(result, null, 2), - ) + await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fs.unlink(path.join(Global.Path.config, "config")) }) .catch(() => {}) @@ -757,9 +674,7 @@ export namespace Config { if (filePath.startsWith("~/")) { filePath = path.join(os.homedir(), filePath.slice(2)) } - const resolvedPath = path.isAbsolute(filePath) - ? filePath - : path.resolve(configDir, filePath) + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const fileContent = ( await Bun.file(resolvedPath) .text() diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 3a143d072..aae7061c1 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -165,11 +165,7 @@ export namespace File { const project = Instance.project if (project.vcs !== "git") return [] - const diffOutput = await $`git diff --numstat HEAD` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text() const changedFiles: Info[] = [] @@ -261,14 +257,9 @@ export namespace File { if (project.vcs === "git") { let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() - if (!diff.trim()) - diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() + if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() if (diff.trim()) { - const original = await $`git show HEAD:${file}` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, @@ -321,9 +312,7 @@ export namespace File { const limit = input.limit ?? 100 const result = await state().then((x) => x.files()) if (!input.query) - return input.dirs !== false - ? result.dirs.toSorted().slice(0, limit) - : result.files.slice(0, limit) + return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit) const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target) log.info("search", { query: input.query, results: sorted.length }) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index d2a9eee83..d1bff181f 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,3 +1,4 @@ +import { readableStreamToText } from "bun" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" @@ -177,6 +178,48 @@ export const ruff: Info = { }, } +export const rlang: Info = { + name: "air", + command: ["air", "format", "$FILE"], + extensions: [".R"], + async enabled() { + const airPath = Bun.which("air") + if (airPath == null) return false + + try { + const proc = Bun.spawn(["air", "--help"], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + const output = await readableStreamToText(proc.stdout) + + // Check for "Air: An R language server and formatter" + const firstLine = output.split("\n")[0] + const hasR = firstLine.includes("R language") + const hasFormatter = firstLine.includes("formatter") + return hasR && hasFormatter + } catch (error) { + return false + } + }, +} + +export const uvformat: Info = { + name: "uv format", + command: ["uv", "format", "--", "$FILE"], + extensions: [".py", ".pyi"], + async enabled() { + if (await ruff.enabled()) return false + if (Bun.which("uv") !== null) { + const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + const code = await proc.exited + return code === 0 + } + return false + }, +} + export const rubocop: Info = { name: "rubocop", command: ["rubocop", "--autocorrect", "$FILE"], diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b42940934..0cc7bcdc7 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -69,6 +69,7 @@ export namespace Format { log.info("checking", { name: item.name, ext }) if (!item.extensions.includes(ext)) continue if (!(await isEnabled(item))) continue + log.info("enabled", { name: item.name, ext }) result.push(item) } return result @@ -112,13 +113,12 @@ export namespace Format { ...item.environment, }) } catch (error) { - log.error("failed", { + log.error("failed to format file", { error, command: item.command, ...item.environment, + file, }) - // re-raising - throw error } } }) diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index ac80dac3e..035bccecf 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -6,6 +6,7 @@ import { Bus } from "../bus" const SUPPORTED_IDES = [ { name: "Windsurf" as const, cmd: "windsurf" }, + { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" }, { name: "Visual Studio Code" as const, cmd: "code" }, { name: "Cursor" as const, cmd: "cursor" }, { name: "VSCodium" as const, cmd: "codium" }, @@ -43,7 +44,7 @@ export namespace Ide { } export function alreadyInstalled() { - return process.env["OPENCODE_CALLER"] === "vscode" + return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders" } export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 7fd7aeb10..015806997 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -17,6 +17,7 @@ import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" +import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { TuiSpawnCommand } from "./cli/cmd/tui/spawn" @@ -87,6 +88,7 @@ const cli = yargs(hideBin(process.argv)) .command(ModelsCommand) .command(StatsCommand) .command(ExportCommand) + .command(ImportCommand) .command(GithubCommand) .fail((msg) => { if ( diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 323810e3e..f4209e5b8 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -132,6 +132,7 @@ export namespace Installation { const formula = await getBrewFormula() cmd = $`brew install ${formula}`.env({ HOMEBREW_NO_AUTO_UPDATE: "1", + ...process.env, }) break } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 1a6e2cb71..02363a599 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -62,6 +62,14 @@ export namespace LSPClient { // Return server initialization options return [input.server.initialization ?? {}] }) + connection.onRequest("client/registerCapability", async () => {}) + connection.onRequest("client/unregisterCapability", async () => {}) + connection.onRequest("workspace/workspaceFolders", async () => [ + { + name: "workspace", + uri: "file://" + input.root, + }, + ]) connection.listen() l.info("sending initialize") diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 67dd838ac..8640489b1 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -197,9 +197,7 @@ export namespace LSP { await run(async (client) => { if (!clients.includes(client)) return - const wait = waitForDiagnostics - ? client.waitForDiagnostics({ path: input }) - : Promise.resolve() + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() await client.notify.open({ path: input }) return wait }).catch((err) => { diff --git a/packages/opencode/src/lsp/language.ts b/packages/opencode/src/lsp/language.ts index b7bcd8e91..7980f05e8 100644 --- a/packages/opencode/src/lsp/language.ts +++ b/packages/opencode/src/lsp/language.ts @@ -4,6 +4,9 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = { ".bib": "bibtex", ".bibtex": "bibtex", ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", ".coffee": "coffeescript", ".c": "c", ".cpp": "cpp", diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index d0fb11e94..1132bbc7c 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -88,9 +88,7 @@ export namespace LSPServer { ), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch( - () => {}, - ) + const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) if (!tsserver) return const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { cwd: root, @@ -113,13 +111,7 @@ export namespace LSPServer { export const Vue: Info = { id: "vue", extensions: [".vue"], - root: NearestRoot([ - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), async spawn(root) { let binary = Bun.which("vue-language-server") const args: string[] = [] @@ -167,31 +159,17 @@ export namespace LSPServer { export const ESLint: Info = { id: "eslint", - root: NearestRoot([ - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], async spawn(root) { const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {}) if (!eslint) return log.info("spawning eslint server") - const serverPath = path.join( - Global.Path.bin, - "vscode-eslint", - "server", - "out", - "eslintServer.js", - ) + const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") if (!(await Bun.file(serverPath).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading and building VS Code ESLint server") - const response = await fetch( - "https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip", - ) + const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") @@ -316,25 +294,12 @@ export namespace LSPServer { export const Pyright: Info = { id: "pyright", extensions: [".py", ".pyi"], - root: NearestRoot([ - "pyproject.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "pyrightconfig.json", - ]), + root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), async spawn(root) { let binary = Bun.which("pyright-langserver") const args = [] if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "pyright", - "dist", - "pyright-langserver.js", - ) + const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "pyright"], { @@ -352,11 +317,9 @@ export namespace LSPServer { const initialization: Record<string, string> = {} - const potentialVenvPaths = [ - process.env["VIRTUAL_ENV"], - path.join(root, ".venv"), - path.join(root, "venv"), - ].filter((p): p is string => p !== undefined) + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) for (const venvPath of potentialVenvPaths) { const isWindows = process.platform === "win32" const potentialPythonPath = isWindows @@ -407,9 +370,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading elixir-ls from GitHub releases") - const response = await fetch( - "https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip", - ) + const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") await Bun.file(zipPath).write(response) @@ -459,9 +420,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading zls from GitHub releases") - const releaseResponse = await fetch( - "https://api.github.com/repos/zigtools/zls/releases/latest", - ) + const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch zls release info") return @@ -636,13 +595,7 @@ export namespace LSPServer { export const Clangd: Info = { id: "clangd", - root: NearestRoot([ - "compile_commands.json", - "compile_flags.txt", - ".clangd", - "CMakeLists.txt", - "Makefile", - ]), + root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], async spawn(root) { let bin = Bun.which("clangd", { @@ -652,9 +605,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch( - "https://api.github.com/repos/clangd/clangd/releases/latest", - ) + const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch clangd release info") return @@ -723,24 +674,12 @@ export namespace LSPServer { export const Svelte: Info = { id: "svelte", extensions: [".svelte"], - root: NearestRoot([ - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), async spawn(root) { let binary = Bun.which("svelteserver") const args: string[] = [] if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "svelte-language-server", - "bin", - "server.js", - ) + const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { @@ -775,17 +714,9 @@ export namespace LSPServer { export const Astro: Info = { id: "astro", extensions: [".astro"], - root: NearestRoot([ - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch( - () => {}, - ) + const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) if (!tsserver) { log.info("typescript not found, required for Astro language server") return @@ -795,14 +726,7 @@ export namespace LSPServer { let binary = Bun.which("astro-ls") const args: string[] = [] if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "@astrojs", - "language-server", - "bin", - "nodeServer.js", - ) + const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { @@ -880,9 +804,7 @@ export namespace LSPServer { .then(({ stdout }) => stdout.toString().trim()) const launcherJar = path.join(launcherDir, jarFileName) if (!(await fs.exists(launcherJar))) { - log.error( - `Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`, - ) + log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) return } const configFile = path.join( @@ -948,9 +870,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading lua-language-server from GitHub releases") - const releaseResponse = await fetch( - "https://api.github.com/repos/LuaLS/lua-language-server/releases/latest", - ) + const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch lua-language-server release info") return @@ -987,9 +907,7 @@ export namespace LSPServer { const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` if (!supportedCombos.includes(assetSuffix)) { - log.error( - `Platform ${platform} and architecture ${arch} is not supported by lua-language-server`, - ) + log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) return } @@ -1012,10 +930,7 @@ export namespace LSPServer { // Unlike zls which is a single self-contained binary, // lua-language-server needs supporting files (meta/, locale/, etc.) // Extract entire archive to dedicated directory to preserve all files - const installDir = path.join( - Global.Path.bin, - `lua-language-server-${lualsArch}-${lualsPlatform}`, - ) + const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) // Remove old installation if exists const stats = await fs.stat(installDir).catch(() => undefined) @@ -1040,11 +955,7 @@ export namespace LSPServer { await fs.rm(tempPath, { force: true }) // Binary is located in bin/ subdirectory within the extracted archive - bin = path.join( - installDir, - "bin", - "lua-language-server" + (platform === "win32" ? ".exe" : ""), - ) + bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) if (!(await Bun.file(bin).exists())) { log.error("Failed to extract lua-language-server binary") diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d6de4a59b..149cd76f6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -77,20 +77,43 @@ export namespace MCP { } }, async (state) => { - await Promise.all(Object.values(state.clients).map((client) => client.close())) + await Promise.all( + Object.values(state.clients).map((client) => + client.close().catch((error) => { + log.error("Failed to close MCP client", { + error, + }) + }), + ), + ) }, ) export async function add(name: string, mcp: Config.Mcp) { const s = await state() const result = await create(name, mcp) - if (!result) return + if (!result) { + const status = { + status: "failed" as const, + error: "unknown error", + } + s.status[name] = status + return { + status, + } + } if (!result.mcpClient) { s.status[name] = result.status - return + return { + status: s.status, + } } s.clients[name] = result.mcpClient s.status[name] = result.status + + return { + status: s.status, + } } async function create(key: string, mcp: Config.Mcp) { @@ -199,9 +222,20 @@ export namespace MCP { } } - const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {}) + const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => { + log.error("failed to get tools from client", { key, error: err }) + return undefined + }) if (!result) { - await mcpClient.close() + await mcpClient.close().catch((error) => { + log.error("Failed to close MCP client", { + error, + }) + }) + status = { + status: "failed", + error: "Failed to get tools", + } return { mcpClient: undefined, status: { @@ -211,6 +245,7 @@ export namespace MCP { } } + log.info("create() successfully created client", { key, toolCount: Object.keys(result).length }) return { mcpClient, status, @@ -228,7 +263,8 @@ export namespace MCP { export async function tools() { const result: Record<string, Tool> = {} const s = await state() - for (const [clientName, client] of Object.entries(await clients())) { + const clientsSnapshot = await clients() + for (const [clientName, client] of Object.entries(clientsSnapshot)) { const tools = await client.tools().catch((e) => { log.error("failed to get tools", { clientName, error: e.message }) const failedStatus = { diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index c6e25c5cc..91d52065f 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -20,7 +20,7 @@ export namespace Patch { workdir?: string } - export type Hunk = + export type Hunk = | { type: "add"; path: string; contents: string } | { type: "delete"; path: string } | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } @@ -71,60 +71,63 @@ export namespace Patch { } // Parser implementation - function parsePatchHeader(lines: string[], startIdx: number): { filePath: string; movePath?: string; nextIdx: number } | null { + function parsePatchHeader( + lines: string[], + startIdx: number, + ): { filePath: string; movePath?: string; nextIdx: number } | null { const line = lines[startIdx] - + if (line.startsWith("*** Add File:")) { const filePath = line.split(":", 2)[1]?.trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } - + if (line.startsWith("*** Delete File:")) { const filePath = line.split(":", 2)[1]?.trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } - + if (line.startsWith("*** Update File:")) { const filePath = line.split(":", 2)[1]?.trim() let movePath: string | undefined let nextIdx = startIdx + 1 - + // Check for move directive if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { movePath = lines[nextIdx].split(":", 2)[1]?.trim() nextIdx++ } - + return filePath ? { filePath, movePath, nextIdx } : null } - + return null } function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { const chunks: UpdateFileChunk[] = [] let i = startIdx - + while (i < lines.length && !lines[i].startsWith("***")) { if (lines[i].startsWith("@@")) { // Parse context line const contextLine = lines[i].substring(2).trim() i++ - + const oldLines: string[] = [] const newLines: string[] = [] let isEndOfFile = false - + // Parse change lines while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { const changeLine = lines[i] - + if (changeLine === "*** End of File") { isEndOfFile = true i++ break } - + if (changeLine.startsWith(" ")) { // Keep line - appears in both old and new const content = changeLine.substring(1) @@ -137,10 +140,10 @@ export namespace Patch { // Add line - only in new newLines.push(changeLine.substring(1)) } - + i++ } - + chunks.push({ old_lines: oldLines, new_lines: newLines, @@ -151,26 +154,26 @@ export namespace Patch { i++ } } - + return { chunks, nextIdx: i } } function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { let content = "" let i = startIdx - + while (i < lines.length && !lines[i].startsWith("***")) { if (lines[i].startsWith("+")) { content += lines[i].substring(1) + "\n" } i++ } - + // Remove trailing newline if (content.endsWith("\n")) { content = content.slice(0, -1) } - + return { content, nextIdx: i } } @@ -178,28 +181,28 @@ export namespace Patch { const lines = patchText.split("\n") const hunks: Hunk[] = [] let i = 0 - + // Look for Begin/End patch markers const beginMarker = "*** Begin Patch" const endMarker = "*** End Patch" - - const beginIdx = lines.findIndex(line => line.trim() === beginMarker) - const endIdx = lines.findIndex(line => line.trim() === endMarker) - + + const beginIdx = lines.findIndex((line) => line.trim() === beginMarker) + const endIdx = lines.findIndex((line) => line.trim() === endMarker) + if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { throw new Error("Invalid patch format: missing Begin/End markers") } - + // Parse content between markers i = beginIdx + 1 - + while (i < endIdx) { const header = parsePatchHeader(lines, i) if (!header) { i++ continue } - + if (lines[i].startsWith("*** Add File:")) { const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) hunks.push({ @@ -227,18 +230,19 @@ export namespace Patch { i++ } } - + return { hunks } } // Apply patch functionality - export function maybeParseApplyPatch(argv: string[]): + export function maybeParseApplyPatch( + argv: string[], + ): | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } | { type: MaybeApplyPatch.PatchParseError; error: Error } | { type: MaybeApplyPatch.NotApplyPatch } { - const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] - + // Direct invocation: apply_patch <patch> if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { try { @@ -257,13 +261,13 @@ export namespace Patch { } } } - + // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { // Simple extraction - in real implementation would need proper bash parsing const script = argv[2] const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) - + if (heredocMatch) { const patchContent = heredocMatch[2] try { @@ -283,7 +287,7 @@ export namespace Patch { } } } - + return { type: MaybeApplyPatch.NotApplyPatch } } @@ -301,37 +305,41 @@ export namespace Patch { } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`) } - + let originalLines = originalContent.split("\n") - + // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { originalLines.pop() } - + const replacements = computeReplacements(originalLines, filePath, chunks) let newLines = applyReplacements(originalLines, replacements) - + // Ensure trailing newline if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { newLines.push("") } - + const newContent = newLines.join("\n") - + // Generate unified diff const unifiedDiff = generateUnifiedDiff(originalContent, newContent) - + return { unified_diff: unifiedDiff, content: newContent, } } - function computeReplacements(originalLines: string[], filePath: string, chunks: UpdateFileChunk[]): Array<[number, number, string[]]> { + function computeReplacements( + originalLines: string[], + filePath: string, + chunks: UpdateFileChunk[], + ): Array<[number, number, string[]]> { const replacements: Array<[number, number, string[]]> = [] let lineIndex = 0 - + for (const chunk of chunks) { // Handle context-based seeking if (chunk.change_context) { @@ -341,21 +349,22 @@ export namespace Patch { } lineIndex = contextIdx + 1 } - + // Handle pure addition (no old lines) if (chunk.old_lines.length === 0) { - const insertionIdx = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" - ? originalLines.length - 1 - : originalLines.length + const insertionIdx = + originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length replacements.push([insertionIdx, 0, chunk.new_lines]) continue } - + // Try to match old lines in the file let pattern = chunk.old_lines let newSlice = chunk.new_lines let found = seekSequence(originalLines, pattern, lineIndex) - + // Retry without trailing empty line if not found if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { pattern = pattern.slice(0, -1) @@ -364,79 +373,77 @@ export namespace Patch { } found = seekSequence(originalLines, pattern, lineIndex) } - + if (found !== -1) { replacements.push([found, pattern.length, newSlice]) lineIndex = found + pattern.length } else { - throw new Error( - `Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}` - ) + throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) } } - + // Sort replacements by index to apply in order replacements.sort((a, b) => a[0] - b[0]) - + return replacements } function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { // Apply replacements in reverse order to avoid index shifting const result = [...lines] - + for (let i = replacements.length - 1; i >= 0; i--) { const [startIdx, oldLen, newSegment] = replacements[i] - + // Remove old lines result.splice(startIdx, oldLen) - + // Insert new lines for (let j = 0; j < newSegment.length; j++) { result.splice(startIdx + j, 0, newSegment[j]) } } - + return result } function seekSequence(lines: string[], pattern: string[], startIndex: number): number { if (pattern.length === 0) return -1 - + // Simple substring search implementation for (let i = startIndex; i <= lines.length - pattern.length; i++) { let matches = true - + for (let j = 0; j < pattern.length; j++) { if (lines[i + j] !== pattern[j]) { matches = false break } } - + if (matches) { return i } } - + return -1 } function generateUnifiedDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") - + // Simple diff generation - in a real implementation you'd use a proper diff algorithm let diff = "@@ -1 +1 @@\n" - + // Find changes (simplified approach) const maxLen = Math.max(oldLines.length, newLines.length) let hasChanges = false - + for (let i = 0; i < maxLen; i++) { const oldLine = oldLines[i] || "" const newLine = newLines[i] || "" - + if (oldLine !== newLine) { if (oldLine) diff += `-${oldLine}\n` if (newLine) diff += `+${newLine}\n` @@ -445,7 +452,7 @@ export namespace Patch { diff += ` ${oldLine}\n` } } - + return hasChanges ? diff : "" } @@ -454,11 +461,11 @@ export namespace Patch { if (hunks.length === 0) { throw new Error("No files were modified.") } - + const added: string[] = [] const modified: string[] = [] const deleted: string[] = [] - + for (const hunk of hunks) { switch (hunk.type) { case "add": @@ -467,28 +474,28 @@ export namespace Patch { if (addDir !== "." && addDir !== "/") { await fs.mkdir(addDir, { recursive: true }) } - + await fs.writeFile(hunk.path, hunk.contents, "utf-8") added.push(hunk.path) log.info(`Added file: ${hunk.path}`) break - + case "delete": await fs.unlink(hunk.path) deleted.push(hunk.path) log.info(`Deleted file: ${hunk.path}`) break - + case "update": const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) - + if (hunk.move_path) { // Handle file move const moveDir = path.dirname(hunk.move_path) if (moveDir !== "." && moveDir !== "/") { await fs.mkdir(moveDir, { recursive: true }) } - + await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") await fs.unlink(hunk.path) modified.push(hunk.move_path) @@ -502,7 +509,7 @@ export namespace Patch { break } } - + return { added, modified, deleted } } @@ -513,7 +520,10 @@ export namespace Patch { } // Async version of maybeParseApplyPatchVerified - export async function maybeParseApplyPatchVerified(argv: string[], cwd: string): Promise< + export async function maybeParseApplyPatchVerified( + argv: string[], + cwd: string, + ): Promise< | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } | { type: MaybeApplyPatchVerified.NotApplyPatch } @@ -530,18 +540,21 @@ export namespace Patch { // Not a patch, continue } } - + const result = maybeParseApplyPatch(argv) - + switch (result.type) { case MaybeApplyPatch.Body: const { args } = result const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd const changes = new Map<string, ApplyPatchFileChange>() - + for (const hunk of args.hunks) { - const resolvedPath = path.resolve(effectiveCwd, hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path) - + const resolvedPath = path.resolve( + effectiveCwd, + hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, + ) + switch (hunk.type) { case "add": changes.set(resolvedPath, { @@ -549,7 +562,7 @@ export namespace Patch { content: hunk.contents, }) break - + case "delete": // For delete, we need to read the current content const deletePath = path.resolve(effectiveCwd, hunk.path) @@ -566,7 +579,7 @@ export namespace Patch { } } break - + case "update": const updatePath = path.resolve(effectiveCwd, hunk.path) try { @@ -586,7 +599,7 @@ export namespace Patch { break } } - + return { type: MaybeApplyPatchVerified.Body, action: { @@ -595,15 +608,15 @@ export namespace Patch { cwd: effectiveCwd, }, } - + case MaybeApplyPatch.PatchParseError: return { type: MaybeApplyPatchVerified.CorrectnessError, error: result.error, } - + case MaybeApplyPatch.NotApplyPatch: return { type: MaybeApplyPatchVerified.NotApplyPatch } } } -}
\ No newline at end of file +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index eb541049d..3a4a9901b 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -81,6 +81,10 @@ export namespace Permission { }, ) + export function pending() { + return state().pending + } + export async function ask(input: { type: Info["type"] title: Info["title"] @@ -166,7 +170,11 @@ export namespace Permission { for (const item of Object.values(items)) { const itemKeys = toKeys(item.info.pattern, item.info.type) if (covered(itemKeys, approved[input.sessionID])) { - respond({ sessionID: item.info.sessionID, permissionID: item.info.id, response: input.response }) + respond({ + sessionID: item.info.sessionID, + permissionID: item.info.id, + response: input.response, + }) } } } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 27451d225..e801d3f78 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -9,9 +9,11 @@ import { Project } from "./project" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" +import { Log } from "@/util/log" export async function InstanceBootstrap() { if (Flag.OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP) return + Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() Share.init() Format.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ce9a57d17..39625e087 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -2,6 +2,7 @@ import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" +import { iife } from "@/util/iife" interface Context { directory: string @@ -9,28 +10,29 @@ interface Context { project: Project.Info } const context = Context.create<Context>("instance") -const cache = new Map<string, Context>() +const cache = new Map<string, Promise<Context>>() export const Instance = { - async provide<R>(input: { - directory: string - init?: () => Promise<any> - fn: () => R - }): Promise<R> { + async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { let existing = cache.get(input.directory) if (!existing) { - const project = await Project.fromDirectory(input.directory) - existing = { - directory: input.directory, - worktree: project.worktree, - project, - } + Log.Default.info("creating instance", { directory: input.directory }) + existing = iife(async () => { + const project = await Project.fromDirectory(input.directory) + const ctx = { + directory: input.directory, + worktree: project.worktree, + project, + } + await context.provide(ctx, async () => { + await input.init?.() + }) + return ctx + }) + cache.set(input.directory, existing) } - return context.provide(existing, async () => { - if (!cache.has(input.directory)) { - cache.set(input.directory, existing) - await input.init?.() - } + const ctx = await existing + return context.provide(ctx, async () => { return input.fn() }) }, @@ -52,7 +54,7 @@ export const Instance = { }, async disposeAll() { for (const [_key, value] of cache) { - await context.provide(value, async () => { + await context.provide(await value, async () => { await Instance.dispose() }) } diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 908ae316b..5846bf856 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -9,11 +9,7 @@ export namespace State { const log = Log.create({ service: "state" }) const recordsByKey = new Map<string, Map<any, Entry>>() - export function create<S>( - root: () => string, - init: () => S, - dispose?: (state: Awaited<S>) => Promise<void>, - ) { + export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) { return () => { const key = root() let entries = recordsByKey.get(key) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3be091df8..889e856e8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -78,18 +78,12 @@ export namespace Provider { } }, "amazon-bedrock": async () => { - if ( - !process.env["AWS_PROFILE"] && - !process.env["AWS_ACCESS_KEY_ID"] && - !process.env["AWS_BEARER_TOKEN_BEDROCK"] - ) + if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"] && !process.env["AWS_BEARER_TOKEN_BEDROCK"]) return { autoload: false } const region = process.env["AWS_REGION"] ?? "us-east-1" - const { fromNodeProviderChain } = await import( - await BunProc.install("@aws-sdk/credential-providers") - ) + const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers")) return { autoload: true, options: { @@ -118,19 +112,16 @@ export namespace Provider { case "eu": { const regionRequiresPrefix = [ "eu-west-1", + "eu-west-2", "eu-west-3", "eu-north-1", "eu-central-1", "eu-south-1", "eu-south-2", ].some((r) => region.includes(r)) - const modelRequiresPrefix = [ - "claude", - "nova-lite", - "nova-micro", - "llama3", - "pixtral", - ].some((m) => modelID.includes(m)) + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) => + modelID.includes(m), + ) if (regionRequiresPrefix && modelRequiresPrefix) { modelID = `${regionPrefix}.${modelID}` } @@ -140,15 +131,13 @@ export namespace Provider { const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) if ( isAustraliaRegion && - ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => - modelID.includes(m), - ) + ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m)) ) { regionPrefix = "au" modelID = `${regionPrefix}.${modelID}` } else { - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some( - (m) => modelID.includes(m), + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => + modelID.includes(m), ) if (modelRequiresPrefix) { regionPrefix = "apac" @@ -186,12 +175,8 @@ export namespace Provider { } }, "google-vertex": async () => { - const project = - process.env["GOOGLE_CLOUD_PROJECT"] ?? - process.env["GCP_PROJECT"] ?? - process.env["GCLOUD_PROJECT"] - const location = - process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "us-east5" + const project = process.env["GOOGLE_CLOUD_PROJECT"] ?? process.env["GCP_PROJECT"] ?? process.env["GCLOUD_PROJECT"] + const location = process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "us-east5" const autoload = Boolean(project) if (!autoload) return { autoload: false } return { @@ -207,12 +192,8 @@ export namespace Provider { } }, "google-vertex-anthropic": async () => { - const project = - process.env["GOOGLE_CLOUD_PROJECT"] ?? - process.env["GCP_PROJECT"] ?? - process.env["GCLOUD_PROJECT"] - const location = - process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "us-east5" + const project = process.env["GOOGLE_CLOUD_PROJECT"] ?? process.env["GCP_PROJECT"] ?? process.env["GCLOUD_PROJECT"] + const location = process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "us-east5" const autoload = Boolean(project) if (!autoload) return { autoload: false } return { @@ -230,6 +211,7 @@ export namespace Provider { } const state = Instance.state(async () => { + using _ = log.time("state") const config = await Config.get() const database = await ModelsDev.get() @@ -407,10 +389,7 @@ export namespace Provider { // Load for the main provider if auth exists if (auth) { - const options = await plugin.auth.loader( - () => Auth.get(providerID) as any, - database[plugin.auth.provider], - ) + const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) mergeProvider(plugin.auth.provider, options ?? {}, "custom") } @@ -441,14 +420,12 @@ export namespace Provider { // Filter out blacklisted models .filter( ([modelID]) => - modelID !== "gpt-5-chat-latest" && - !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), + modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), ) // Filter out experimental models .filter( ([, model]) => - ((!model.experimental && model.status !== "alpha") || - Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && + ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && model.status !== "deprecated", ), ) @@ -495,9 +472,7 @@ export namespace Provider { // In addition, Bun's dynamic import logic does not support subpath imports, // so we patch the import path to load directly from `dist`. const modPath = - provider.id === "google-vertex-anthropic" - ? `${installedPath}/dist/anthropic/index.mjs` - : installedPath + provider.id === "google-vertex-anthropic" ? `${installedPath}/dist/anthropic/index.mjs` : installedPath const mod = await import(modPath) if (options["timeout"] !== undefined && options["timeout"] !== null) { // Preserve custom fetch if it exists, wrap it with timeout logic @@ -596,14 +571,7 @@ export namespace Provider { const provider = await state().then((state) => state.providers[providerID]) if (!provider) return - let priority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-2.5-flash", - "gpt-5-nano", - ] + let priority = ["claude-haiku-4-5", "claude-haiku-4.5", "3-5-haiku", "3.5-haiku", "gemini-2.5-flash", "gpt-5-nano"] // claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen if (providerID === "github-copilot") { priority = priority.filter((m) => m !== "claude-haiku-4.5") diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6212edff8..e578d806f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -3,21 +3,67 @@ import { unique } from "remeda" import type { JSONSchema } from "zod/v4/core" export namespace ProviderTransform { - function normalizeToolCallIds(msgs: ModelMessage[]): ModelMessage[] { - return msgs.map((msg) => { - if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { - msg.content = msg.content.map((part) => { - if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { - return { - ...part, - toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"), + function normalizeMessages(msgs: ModelMessage[], providerID: string, modelID: string): ModelMessage[] { + if (modelID.includes("claude")) { + return msgs.map((msg) => { + if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { + return { + ...part, + toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"), + } } - } - return part - }) + return part + }) + } + return msg + }) + } + if (providerID === "mistral" || modelID.toLowerCase().includes("mistral")) { + const result: ModelMessage[] = [] + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + const prevMsg = msgs[i - 1] + const nextMsg = msgs[i + 1] + + if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) { + // Mistral requires alphanumeric tool call IDs with exactly 9 characters + const normalizedId = part.toolCallId + .replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters + .substring(0, 9) // Take first 9 characters + .padEnd(9, "0") // Pad with zeros if less than 9 characters + + return { + ...part, + toolCallId: normalizedId, + } + } + return part + }) + } + + result.push(msg) + + // Fix message sequence: tool messages cannot be followed by user messages + if (msg.role === "tool" && nextMsg?.role === "user") { + result.push({ + role: "assistant", + content: [ + { + type: "text", + text: "Done.", + }, + ], + }) + } } - return msg - }) + return result + } + + return msgs } function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] { @@ -63,9 +109,7 @@ export namespace ProviderTransform { } export function message(msgs: ModelMessage[], providerID: string, modelID: string) { - if (modelID.includes("claude")) { - msgs = normalizeToolCallIds(msgs) - } + msgs = normalizeMessages(msgs, providerID, modelID) if (providerID === "anthropic" || modelID.includes("anthropic") || modelID.includes("claude")) { msgs = applyCaching(msgs, providerID) } @@ -92,12 +136,12 @@ export namespace ProviderTransform { } if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) { - if (!modelID.includes("codex") && !modelID.includes("gpt-5-pro")) { - result["reasoningEffort"] = "medium" + if (modelID.includes("codex")) { + result["store"] = false } - if (providerID !== "azure") { - result["textVerbosity"] = modelID.includes("codex") ? "medium" : "low" + if (!modelID.includes("codex") && !modelID.includes("gpt-5-pro")) { + result["reasoningEffort"] = "medium" } if (providerID === "opencode") { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 59e066e15..106693b22 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,12 +1,6 @@ import { Log } from "../util/log" import { Bus } from "../bus" -import { - describeRoute, - generateSpecs, - validator, - resolver, - openAPIRouteHandler, -} from "hono-openapi" +import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { Hono } from "hono" import { cors } from "hono/cors" import { stream, streamSSE } from "hono/streaming" @@ -114,12 +108,13 @@ export namespace Server { path: c.req.path, }) } - const start = Date.now() + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, + }) await next() if (!skipLogging) { - log.info("response", { - duration: Date.now() - start, - }) + timer.stop() } }) .use(async (c, next) => { @@ -257,9 +252,7 @@ export namespace Server { id: t.id, description: t.description, // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def - ? zodToJsonSchema(t.parameters as any) - : t.parameters, + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, })), ) }, @@ -753,8 +746,18 @@ export namespace Server { id: z.string().meta({ description: "Session ID" }), }), ), + validator( + "query", + z.object({ + limit: z.coerce.number().optional(), + }), + ), async (c) => { - const messages = await Session.messages(c.req.valid("param").id) + const query = c.req.valid("query") + const messages = await Session.messages({ + sessionID: c.req.valid("param").id, + limit: query.limit, + }) return c.json(messages) }, ) @@ -817,7 +820,7 @@ export namespace Server { ), async (c) => { const params = c.req.valid("param") - const message = await Session.getMessage({ + const message = await MessageV2.get({ sessionID: params.id, messageID: params.messageID, }) @@ -1073,13 +1076,11 @@ export namespace Server { }, }), async (c) => { + using _ = log.time("providers") const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info)) return c.json({ providers: Object.values(providers), - default: mapValues( - providers, - (item) => Provider.sort(Object.values(item.models))[0].id, - ), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), }) }, ) @@ -1349,6 +1350,36 @@ export namespace Server { return c.json(await MCP.status()) }, ) + .post( + "/mcp", + describeRoute({ + description: "Add MCP server dynamically", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) .get( "/lsp", describeRoute({ @@ -1643,10 +1674,7 @@ export namespace Server { ), async (c) => { const evt = c.req.valid("json") - await Bus.publish( - Object.values(TuiEvent).find((def) => def.type === evt.type)!, - evt.properties, - ) + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) return c.json(true) }, ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index cc6351675..ff988845d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,4 +1,4 @@ -import { streamText, type ModelMessage, LoadAPIKeyError, type StreamTextResult, type Tool as AITool } from "ai" +import { streamText, type ModelMessage, type StreamTextResult, type Tool as AITool } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" @@ -50,7 +50,7 @@ export namespace SessionCompaction { export async function prune(input: { sessionID: string }) { if (Flag.OPENCODE_DISABLE_PRUNE) return log.info("pruning") - const msgs = await Session.messages(input.sessionID) + const msgs = await Session.messages({ sessionID: input.sessionID }) let total = 0 let pruned = 0 const toPrune = [] @@ -100,7 +100,7 @@ export namespace SessionCompaction { draft.time.compacting = undefined }) }) - const toSummarize = await Session.messages(input.sessionID).then(MessageV2.filterCompacted) + const toSummarize = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) const model = await Provider.getModel(input.providerID, input.modelID) const system = [ ...SystemPrompt.summarize(model.providerID), @@ -113,7 +113,6 @@ export namespace SessionCompaction { role: "assistant", parentID: toSummarize.findLast((m) => m.info.role === "user")?.info.id!, sessionID: input.sessionID, - system, mode: "build", path: { cwd: Instance.directory, @@ -252,7 +251,7 @@ export namespace SessionCompaction { } } - const parts = await Session.getParts(msg.id) + const parts = await MessageV2.parts(msg.id) return { info: msg, parts, @@ -268,15 +267,24 @@ export namespace SessionCompaction { max: maxRetries, }) if (result.shouldRetry) { + const start = Date.now() for (let retry = 1; retry < maxRetries; retry++) { - const lastRetryPart = result.parts.findLast((p) => p.type === "retry") + const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry") if (lastRetryPart) { - const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry) + const delayMs = SessionRetry.getBoundedDelay({ + error: lastRetryPart.error, + attempt: retry, + startTime: start, + }) + if (!delayMs) { + break + } log.info("retrying with backoff", { attempt: retry, delayMs, + elapsed: Date.now() - start, }) const stop = await SessionRetry.sleep(delayMs, signal) @@ -318,7 +326,7 @@ export namespace SessionCompaction { if ( !msg.error || (MessageV2.AbortedError.isInstance(msg.error) && - result.parts.some((part) => part.type === "text" && part.text.length > 0)) + result.parts.some((part): part is MessageV2.TextPart => part.type === "text" && part.text.length > 0)) ) { msg.summary = true Bus.publish(Event.Compacted, { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f8a2ba918..d0bdfb9e1 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -44,6 +44,7 @@ export namespace Session { .object({ additions: z.number(), deletions: z.number(), + files: z.number(), diffs: Snapshot.FileDiff.array().optional(), }) .optional(), @@ -102,6 +103,13 @@ export namespace Session { info: Info, }), ), + Diff: Bus.event( + "session.diff", + z.object({ + sessionID: z.string(), + diff: Snapshot.FileDiff.array(), + }), + ), Error: Bus.event( "session.error", z.object({ @@ -136,7 +144,7 @@ export namespace Session { const session = await createNext({ directory: Instance.directory, }) - const msgs = await messages(input.sessionID) + const msgs = await messages({ sessionID: input.sessionID }) for (const msg of msgs) { if (input.messageID && msg.info.id >= input.messageID) break const cloned = await updateMessage({ @@ -164,12 +172,7 @@ export namespace Session { }) }) - export async function createNext(input: { - id?: string - title?: string - parentID?: string - directory: string - }) { + export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) { const result: Info = { id: Identifier.descending("session", input.id), version: Installation.VERSION, @@ -229,7 +232,7 @@ export namespace Session { }) await Storage.write(["share", id], share) await Share.sync("session/info/" + id, session) - for (const msg of await messages(id)) { + for (const msg of await messages({ sessionID: id })) { await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info) for (const part of msg.parts) { await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part) @@ -265,42 +268,22 @@ export namespace Session { return diffs ?? [] }) - export const messages = fn(Identifier.schema("session"), async (sessionID) => { - const result = [] as MessageV2.WithParts[] - for (const p of await Storage.list(["message", sessionID])) { - const read = await Storage.read<MessageV2.Info>(p) - result.push({ - info: read, - parts: await getParts(read.id), - }) - } - result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1)) - return result - }) - - export const getMessage = fn( + export const messages = fn( z.object({ sessionID: Identifier.schema("session"), - messageID: Identifier.schema("message"), + limit: z.number().optional(), }), async (input) => { - return { - info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]), - parts: await getParts(input.messageID), + const result = [] as MessageV2.WithParts[] + for await (const msg of MessageV2.stream(input.sessionID)) { + if (input.limit && result.length >= input.limit) break + result.push(msg) } + result.reverse() + return result }, ) - export const getParts = fn(Identifier.schema("message"), async (messageID) => { - const result = [] as MessageV2.Part[] - for (const item of await Storage.list(["part", messageID])) { - const read = await Storage.read<MessageV2.Part>(item) - result.push(read) - } - result.sort((a, b) => (a.id > b.id ? 1 : -1)) - return result - }) - export async function* list() { const project = Instance.project for (const item of await Storage.list(["session", project.id])) { @@ -412,9 +395,7 @@ export namespace Session { .add(new Decimal(tokens.input).mul(input.model.cost?.input ?? 0).div(1_000_000)) .add(new Decimal(tokens.output).mul(input.model.cost?.output ?? 0).div(1_000_000)) .add(new Decimal(tokens.cache.read).mul(input.model.cost?.cache_read ?? 0).div(1_000_000)) - .add( - new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000), - ) + .add(new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000)) .toNumber(), tokens, } diff --git a/packages/opencode/src/session/lock.ts b/packages/opencode/src/session/lock.ts index ed024edab..22eb8187c 100644 --- a/packages/opencode/src/session/lock.ts +++ b/packages/opencode/src/session/lock.ts @@ -50,7 +50,10 @@ export namespace SessionLock { export function acquire(input: { sessionID: string }) { const lock = get(input.sessionID) if (lock) { - throw new LockedError({ sessionID: input.sessionID, message: `Session ${input.sessionID} is locked` }) + throw new LockedError({ + sessionID: input.sessionID, + message: `Session ${input.sessionID} is locked`, + }) } const controller = new AbortController() state().locks.set(input.sessionID, { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3b28afe0f..5435b9e24 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -6,6 +6,8 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" +import { fn } from "@/util/fn" +import { Storage } from "@/storage/storage" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) @@ -313,7 +315,6 @@ export namespace MessageV2 { APIError.Schema, ]) .optional(), - system: z.string().array(), parentID: z.string(), modelID: z.string(), providerID: z.string(), @@ -397,7 +398,6 @@ export namespace MessageV2 { tokens: v1.metadata.assistant!.tokens, modelID: v1.metadata.assistant!.modelID, providerID: v1.metadata.assistant!.providerID, - system: v1.metadata.assistant!.system, mode: "build", error: v1.metadata.error, } @@ -636,10 +636,47 @@ export namespace MessageV2 { return convertToModelMessages(result) } - export function filterCompacted(msgs: { info: MessageV2.Info; parts: MessageV2.Part[] }[]) { - const i = msgs.findLastIndex((m) => m.info.role === "assistant" && !!m.info.summary) - if (i === -1) return msgs.slice() - return msgs.slice(i) + export const stream = fn(Identifier.schema("session"), async function* (sessionID) { + const list = await Array.fromAsync(await Storage.list(["message", sessionID])) + for (let i = list.length - 1; i >= 0; i--) { + yield await get({ + sessionID, + messageID: list[i][2], + }) + } + }) + + export const parts = fn(Identifier.schema("message"), async (messageID) => { + const result = [] as MessageV2.Part[] + for (const item of await Storage.list(["part", messageID])) { + const read = await Storage.read<MessageV2.Part>(item) + result.push(read) + } + result.sort((a, b) => (a.id > b.id ? 1 : -1)) + return result + }) + + export const get = fn( + z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message"), + }), + async (input) => { + return { + info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]), + parts: await parts(input.messageID), + } + }, + ) + + export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) { + const result = [] as MessageV2.WithParts[] + for await (const msg of stream) { + result.push(msg) + if (msg.info.role === "assistant" && msg.info.summary === true) break + } + result.reverse() + return result } export function fromError(e: unknown, ctx: { providerID: string }) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5cbfb8b5a..10a9727a0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -51,6 +51,7 @@ import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { Config } from "@/config/config" +import { NamedError } from "@/util/error" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -205,6 +206,8 @@ export namespace SessionPrompt { const params = await Plugin.trigger( "chat.params", { + sessionID: input.sessionID, + agent: agent.name, model: model.info, provider: await Provider.getProvider(model.providerID), message: userMsg, @@ -300,11 +303,7 @@ export namespace SessionPrompt { OUTPUT_TOKEN_MAX, ), abortSignal: abort.signal, - providerOptions: ProviderTransform.providerOptions( - model.npm, - model.providerID, - params.options, - ), + providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), stopWhen: stepCountIs(1), temperature: params.temperature, topP: params.topP, @@ -339,11 +338,7 @@ export namespace SessionPrompt { async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message( - args.params.prompt, - model.providerID, - model.modelID, - ) + args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) } return args.params }, @@ -360,15 +355,24 @@ export namespace SessionPrompt { max: maxRetries, }) if (result.shouldRetry) { + const start = Date.now() for (let retry = 1; retry < maxRetries; retry++) { - const lastRetryPart = result.parts.findLast((p) => p.type === "retry") + const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry") if (lastRetryPart) { - const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry) + const delayMs = SessionRetry.getBoundedDelay({ + error: lastRetryPart.error, + attempt: retry, + startTime: start, + }) + if (!delayMs) { + break + } log.info("retrying with backoff", { attempt: retry, delayMs, + elapsed: Date.now() - start, }) const stop = await SessionRetry.sleep(delayMs, abort.signal) @@ -433,7 +437,7 @@ export namespace SessionPrompt { providerID: string signal: AbortSignal }) { - let msgs = await Session.messages(input.sessionID).then(MessageV2.filterCompacted) + let msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant") if ( lastAssistant?.info.role === "assistant" && @@ -526,11 +530,7 @@ export namespace SessionPrompt { ) for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) { if (Wildcard.all(item.id, enabledTools) === false) continue - const schema = ProviderTransform.schema( - input.providerID, - input.modelID, - z.toJSONSchema(item.parameters), - ) + const schema = ProviderTransform.schema(input.providerID, input.modelID, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, description: item.description, @@ -735,17 +735,8 @@ export namespace SessionPrompt { } } const args = { filePath: filepath, offset, limit } - const result = await ReadTool.init().then((t) => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - metadata: async () => {}, - }), - ) - return [ + + const pieces: MessageV2.Part[] = [ { id: Identifier.ascending("part"), messageID: info.id, @@ -754,21 +745,55 @@ export namespace SessionPrompt { synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, - { - id: Identifier.ascending("part"), - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { - ...part, - id: part.id ?? Identifier.ascending("part"), - messageID: info.id, - sessionID: input.sessionID, - }, ] + + await ReadTool.init() + .then(async (t) => { + const result = await t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + metadata: async () => {}, + }) + pieces.push( + { + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }, + { + ...part, + id: part.id ?? Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }, + ) + }) + .catch((error) => { + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : error.toString() + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message, + }).toObject(), + }) + pieces.push({ + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) + }) + + return pieces } if (part.mime === "application/x-directory") { @@ -825,9 +850,7 @@ export namespace SessionPrompt { messageID: info.id, sessionID: input.sessionID, type: "file", - url: - `data:${part.mime};base64,` + - Buffer.from(await file.bytes()).toString("base64"), + url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"), mime: part.mime, filename: part.filename!, source: part.source, @@ -870,7 +893,12 @@ export namespace SessionPrompt { await Plugin.trigger( "chat.message", - {}, + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID: input.messageID, + }, { message: info, parts, @@ -901,9 +929,7 @@ export namespace SessionPrompt { synthetic: true, }) } - const wasPlan = input.messages.some( - (msg) => msg.info.role === "assistant" && msg.info.mode === "plan", - ) + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan") if (wasPlan && input.agent.name === "build") { userMessage.parts.push({ id: Identifier.ascending("part"), @@ -935,7 +961,6 @@ export namespace SessionPrompt { id: Identifier.ascending("message"), parentID, role: "assistant", - system: input.system, mode: input.agent, path: { cwd: Instance.directory, @@ -983,10 +1008,7 @@ export namespace SessionPrompt { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process( - stream: StreamTextResult<Record<string, AITool>, never>, - retries: { count: number; max: number }, - ) { + async process(stream: StreamTextResult<Record<string, AITool>, never>, retries: { count: number; max: number }) { log.info("process") if (!assistantMsg) throw new Error("call next() first before processing") let shouldRetry = false @@ -996,9 +1018,6 @@ export namespace SessionPrompt { for await (const value of stream.fullStream) { input.abort.throwIfAborted() - log.info("part", { - type: value.type, - }) switch (value.type) { case "start": break @@ -1084,7 +1103,7 @@ export namespace SessionPrompt { }) toolcalls[value.toolCallId] = part as MessageV2.ToolPart - const parts = await Session.getParts(assistantMsg.id) + const parts = await MessageV2.parts(assistantMsg.id) const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) if ( lastThree.length === DOOM_LOOP_THRESHOLD && @@ -1096,18 +1115,21 @@ export namespace SessionPrompt { JSON.stringify(p.state.input) === JSON.stringify(value.input), ) ) { - await Permission.ask({ - type: "doom-loop", - pattern: value.toolName, - sessionID: assistantMsg.sessionID, - messageID: assistantMsg.id, - callID: value.toolCallId, - title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, - metadata: { - tool: value.toolName, - input: value.input, - }, - }) + const permission = await Agent.get(input.agent).then((x) => x.permission) + if (permission.doom_loop === "ask") { + await Permission.ask({ + type: "doom_loop", + pattern: value.toolName, + sessionID: assistantMsg.sessionID, + messageID: assistantMsg.id, + callID: value.toolCallId, + title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, + metadata: { + tool: value.toolName, + input: value.input, + }, + }) + } } } break @@ -1145,10 +1167,7 @@ export namespace SessionPrompt { status: "error", input: value.input, error: (value.error as any).toString(), - metadata: - value.error instanceof Permission.RejectedError - ? value.error.metadata - : undefined, + metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, time: { start: match.state.time.start, end: Date.now(), @@ -1272,11 +1291,7 @@ export namespace SessionPrompt { error: e, }) const error = MessageV2.fromError(e, { providerID: input.providerID }) - if ( - retries.count < retries.max && - MessageV2.APIError.isInstance(error) && - error.data.isRetryable - ) { + if (retries.count < retries.max && MessageV2.APIError.isInstance(error) && error.data.isRetryable) { shouldRetry = true await Session.updatePart({ id: Identifier.ascending("part"), @@ -1297,13 +1312,9 @@ export namespace SessionPrompt { }) } } - const p = await Session.getParts(assistantMsg.id) + const p = await MessageV2.parts(assistantMsg.id) for (const part of p) { - if ( - part.type === "tool" && - part.state.status !== "completed" && - part.state.status !== "error" - ) { + if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { await Session.updatePart({ ...part, state: { @@ -1389,7 +1400,6 @@ export namespace SessionPrompt { id: Identifier.ascending("message"), sessionID: input.sessionID, parentID: userMsg.id, - system: [], mode: input.agent, cost: 0, path: { @@ -1686,7 +1696,6 @@ export namespace SessionPrompt { id: Identifier.ascending("message"), sessionID: input.sessionID, parentID: userMsg.id, - system: [], mode: agentName, cost: 0, path: { @@ -1800,13 +1809,11 @@ export namespace SessionPrompt { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return const isFirst = - input.history.filter( - (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), - ).length === 1 + input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) + .length === 1 if (!isFirst) return const small = - (await Provider.getSmallModel(input.providerID)) ?? - (await Provider.getModel(input.providerID, input.modelID)) + (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) const options = { ...ProviderTransform.options(small.providerID, small.modelID, input.session.id), ...small.info.options, diff --git a/packages/opencode/src/session/prompt/polaris.txt b/packages/opencode/src/session/prompt/polaris.txt new file mode 100644 index 000000000..f90761890 --- /dev/null +++ b/packages/opencode/src/session/prompt/polaris.txt @@ -0,0 +1,107 @@ +You are OpenCode, the best coding agent on the planet. + +You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. + +IMPORTANT: Do not guess arbitrary URLs. Only provide URLs you are confident are correct and directly helpful for programming (for example, well-known official documentation). Prefer URLs provided by the user in their messages or local files. + +If the user asks for help or wants to give feedback inform them of the following: +- ctrl+p to list available actions +- To give feedback, users should report the issue at + https://github.com/sst/opencode + +When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs. + +When the user asks in second person (eg. "are you able...", "can you do..."), treat it as a request to help. Briefly confirm your capability and, when appropriate, immediately start performing the requested task or provide a concrete, useful answer instead of replying with only "yes" or "no". + +# Tone and style +- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. +- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- Do not create new files unless necessary for achieving your goal or explicitly requested. Prefer editing an existing file when possible. This includes markdown files. + +# Professional objectivity +Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. + +# Task Management +You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools frequently for multi-step or non-trivial tasks to give the user visibility into your progress. +These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. + +Prefer marking todos as completed soon after you finish each task, rather than delaying without reason. + +Examples: + +<example> +user: Run the build and fix any type errors +assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: +- Run the build +- Fix any type errors + +I'm now going to run the build using Bash. + +Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. + +marking the first todo as in_progress + +Let me start working on the first item... + +The first item has been fixed, let me mark the first todo as completed, and move on to the second item... +.. +.. +</example> +In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. + +<example> +user: Help me write a new feature that allows users to track their usage metrics and export them to various formats +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. +Adding the following todos to the todo list: +1. Research existing metrics tracking in the codebase +2. Design the metrics collection system +3. Implement core metrics tracking functionality +4. Create export functionality for different formats + +Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. + +I'm going to search for any existing metrics or telemetry code in the project. + +I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... + +[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +</example> + + +# Doing tasks +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: +- +- Use the TodoWrite tool to plan the task if required + +- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. + + +# Tool usage policy +- When doing file search, prefer to use the Task tool in order to reduce context usage. +- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. + +- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. +- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. +- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. +- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- Generally use the Task tool for broader or multi-file exploration; direct reads and searches are fine for specific, simple queries. +<example> +user: Where are errors from the client handled? +assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] +</example> +<example> +user: What is the codebase structure? +assistant: [Uses the Task tool] +</example> + +Prefer using the TodoWrite tool to plan and track tasks when there are multiple steps or files involved. + +# Code References + +When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +<example> +user: Where are errors from the client handled? +assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. +</example> diff --git a/packages/opencode/src/session/prompt/title.txt b/packages/opencode/src/session/prompt/title.txt index 997a960bc..91e50d56c 100644 --- a/packages/opencode/src/session/prompt/title.txt +++ b/packages/opencode/src/session/prompt/title.txt @@ -12,6 +12,7 @@ Output: Single line, ≤50 chars, no explanations. - Never assume tech stack - Never use tools - NEVER respond to message content—only extract title +- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT </rules> <examples> diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 30f2035b4..68f33d959 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -1,8 +1,10 @@ +import { iife } from "@/util/iife" import { MessageV2 } from "./message-v2" export namespace SessionRetry { export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 + export const RETRY_MAX_DELAY = 600_000 // 10 minutes export async function sleep(ms: number, signal: AbortSignal): Promise<void> { return new Promise((resolve, reject) => { @@ -18,40 +20,57 @@ export namespace SessionRetry { }) } - export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number): number { - const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) - const headers = error.data.responseHeaders - if (!headers) return base - - const retryAfterMs = headers["retry-after-ms"] - if (retryAfterMs) { - const parsed = Number.parseFloat(retryAfterMs) - const normalized = normalizeDelay({ base, candidate: parsed }) - if (normalized != null) return normalized - } - - const retryAfter = headers["retry-after"] - if (!retryAfter) return base - - const seconds = Number.parseFloat(retryAfter) - if (!Number.isNaN(seconds)) { - const normalized = normalizeDelay({ base, candidate: seconds * 1000 }) - if (normalized != null) return normalized - return base - } - - const dateMs = Date.parse(retryAfter) - Date.now() - const normalized = normalizeDelay({ base, candidate: dateMs }) - if (normalized != null) return normalized - - return base + export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number) { + const delay = iife(() => { + const headers = error.data.responseHeaders + if (headers) { + const retryAfterMs = headers["retry-after-ms"] + if (retryAfterMs) { + const parsedMs = Number.parseFloat(retryAfterMs) + if (!Number.isNaN(parsedMs)) { + return parsedMs + } + } + + const retryAfter = headers["retry-after"] + if (retryAfter) { + const parsedSeconds = Number.parseFloat(retryAfter) + if (!Number.isNaN(parsedSeconds)) { + // convert seconds to milliseconds + return Math.ceil(parsedSeconds * 1000) + } + // Try parsing as HTTP date format + const parsed = Date.parse(retryAfter) - Date.now() + if (!Number.isNaN(parsed) && parsed > 0) { + return Math.ceil(parsed) + } + } + } + + return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) + }) + + // dont retry if wait is too far from now + if (delay > RETRY_MAX_DELAY) return undefined + + return delay } - function normalizeDelay(input: { base: number; candidate: number }): number | undefined { - if (Number.isNaN(input.candidate)) return undefined - if (input.candidate < 0) return undefined - if (input.candidate < 60_000) return input.candidate - if (input.candidate < input.base) return input.candidate - return undefined + export function getBoundedDelay(input: { + error: MessageV2.APIError + attempt: number + startTime: number + maxDuration?: number + }) { + const elapsed = Date.now() - input.startTime + const maxDuration = input.maxDuration ?? RETRY_MAX_DELAY + const remaining = maxDuration - elapsed + + if (remaining <= 0) return undefined + + const delay = getRetryDelayInMs(input.error, input.attempt) + if (!delay) return undefined + + return Math.min(delay, remaining) } } diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index a88b5f08f..dbf81edc7 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -25,7 +25,7 @@ export namespace SessionRevert { sessionID: input.sessionID, }) - const all = await Session.messages(input.sessionID) + const all = await Session.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = await Session.get(input.sessionID) @@ -86,7 +86,7 @@ export namespace SessionRevert { export async function cleanup(session: Session.Info) { if (!session.revert) return const sessionID = session.id - let msgs = await Session.messages(sessionID) + let msgs = await Session.messages({ sessionID }) const messageID = session.revert.messageID const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) msgs = preserve diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 9795a3069..3e290c6c0 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import path from "path" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" +import { Bus } from "@/bus" export namespace SessionSummary { const log = Log.create({ service: "session.summary" }) @@ -22,7 +23,7 @@ export namespace SessionSummary { messageID: z.string(), }), async (input) => { - const all = await Session.messages(input.sessionID) + const all = await Session.messages({ sessionID: input.sessionID }) await Promise.all([ summarizeSession({ sessionID: input.sessionID, messages: all }), summarizeMessage({ messageID: input.messageID, messages: all }), @@ -47,16 +48,19 @@ export namespace SessionSummary { draft.summary = { additions: diffs.reduce((sum, x) => sum + x.additions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, } }) await Storage.write(["session_diff", input.sessionID], diffs) + Bus.publish(Session.Event.Diff, { + sessionID: input.sessionID, + diff: diffs, + }) } async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) { const messages = input.messages.filter( - (m) => - m.info.id === input.messageID || - (m.info.role === "assistant" && m.info.parentID === input.messageID), + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) const msgWithParts = messages.find((m) => m.info.id === input.messageID)! const userMsg = msgWithParts.info as MessageV2.User @@ -67,14 +71,11 @@ export namespace SessionSummary { } await Session.updateMessage(userMsg) - const assistantMsg = messages.find((m) => m.info.role === "assistant")! - .info as MessageV2.Assistant + const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant const small = await Provider.getSmallModel(assistantMsg.providerID) if (!small) return - const textPart = msgWithParts.parts.find( - (p) => p.type === "text" && !p.synthetic, - ) as MessageV2.TextPart + const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart if (textPart && !userMsg.summary?.title) { const result = await generateText({ maxOutputTokens: small.info.reasoning ? 1500 : 20, @@ -107,8 +108,7 @@ export namespace SessionSummary { if ( messages.some( (m) => - m.info.role === "assistant" && - m.parts.some((p) => p.type === "step-finish" && p.reason !== "tool-calls"), + m.info.role === "assistant" && m.parts.some((p) => p.type === "step-finish" && p.reason !== "tool-calls"), ) ) { let summary = messages @@ -145,17 +145,7 @@ export namespace SessionSummary { messageID: Identifier.schema("message").optional(), }), async (input) => { - let all = await Session.messages(input.sessionID) - if (input.messageID) - all = all.filter( - (x) => - x.info.id === input.messageID || - (x.info.role === "assistant" && x.info.parentID === input.messageID), - ) - - return computeDiff({ - messages: all, - }) + return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]) ?? [] }, ) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 3173dcac5..7d44bbda4 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -9,6 +9,7 @@ import os from "os" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt" +import PROMPT_POLARIS from "./prompt/polaris.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" @@ -27,6 +28,7 @@ export namespace SystemPrompt { if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST] if (modelID.includes("gemini-")) return [PROMPT_GEMINI] if (modelID.includes("claude")) return [PROMPT_ANTHROPIC] + if (modelID.includes("polaris-alpha")) return [PROMPT_POLARIS] return [PROMPT_ANTHROPIC_WITHOUT_TODO] } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 98b316804..cf051defb 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -143,15 +143,16 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise<FileDiff[]> { const git = gitdir() const result: FileDiff[] = [] - for await (const line of $`git --git-dir=${git} diff --numstat ${from} ${to} -- .` + for await (const line of $`git --git-dir=${git} diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) .nothrow() .lines()) { if (!line) continue const [additions, deletions, file] = line.split("\t") - const before = await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text() - const after = await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text() + const isBinaryFile = additions === "-" && deletions === "-" + const before = isBinaryFile ? "" : await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text() + const after = isBinaryFile ? "" : await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text() result.push({ file, before, diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 66ce20d95..cff7211cc 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -85,9 +85,7 @@ export namespace Storage { const session = await Bun.file(sessionFile).json() await Bun.write(dest, JSON.stringify(session)) log.info(`migrating messages for session ${session.id}`) - for await (const msgFile of new Bun.Glob( - `storage/session/message/${session.id}/*.json`, - ).scan({ + for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({ cwd: fullProjectDir, absolute: true, })) { @@ -100,12 +98,12 @@ export namespace Storage { await Bun.write(dest, JSON.stringify(message)) log.info(`migrating parts for message ${message.id}`) - for await (const partFile of new Bun.Glob( - `storage/session/part/${session.id}/${message.id}/*.json`, - ).scan({ - cwd: fullProjectDir, - absolute: true, - })) { + for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan( + { + cwd: fullProjectDir, + absolute: true, + }, + )) { const dest = path.join(dir, "part", message.id, path.basename(partFile)) const part = await Bun.file(partFile).json() log.info("copying", { @@ -128,9 +126,7 @@ export namespace Storage { if (!session.projectID) continue if (!session.summary?.diffs) continue const { diffs } = session.summary - await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write( - JSON.stringify(diffs), - ) + await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs)) await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write( JSON.stringify({ ...session, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index febd253a0..a3ccfc397 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -51,9 +51,7 @@ export const BashTool = Tool.define("bash", { }), async execute(params, ctx) { if (params.timeout !== undefined && params.timeout < 0) { - throw new Error( - `Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`, - ) + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) const tree = await parser().then((p) => p.parse(params.command)) @@ -101,10 +99,7 @@ export const BashTool = Tool.define("bash", { // always allow cd if it passes above check if (command[0] !== "cd") { - const action = Wildcard.allStructured( - { head: command[0], tail: command.slice(1) }, - permissions, - ) + const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions) if (action === "deny") { throw new Error( `The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7429c44b8..96c62b86a 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -35,12 +35,27 @@ export const EditTool = Tool.define("edit", { throw new Error("oldString and newString must be different") } + const agent = await Agent.get(ctx.agent) + const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filePath)) { - throw new Error(`File ${filePath} is not in the current working directory`) + const parentDir = path.dirname(filePath) + if (agent.permission.external_directory === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Edit file outside working directory: ${filePath}`, + metadata: { + filepath: filePath, + parentDir, + }, + }) + } } - const agent = await Agent.get(ctx.agent) let diff = "" let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 118e0840c..0571cd353 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -54,7 +54,21 @@ export const PatchTool = Tool.define("patch", { const filePath = path.resolve(Instance.directory, hunk.path) if (!Filesystem.contains(Instance.directory, filePath)) { - throw new Error(`File ${filePath} is not in the current working directory`) + const parentDir = path.dirname(filePath) + if (agent.permission.external_directory === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Patch file outside working directory: ${filePath}`, + metadata: { + filepath: filePath, + parentDir, + }, + }) + } } switch (hunk.type) { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bc89dae2c..4d8e15bfb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,6 +9,8 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" +import { Permission } from "../permission" +import { Agent } from "@/agent/agent" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -26,9 +28,24 @@ export const ReadTool = Tool.define("read", { filepath = path.join(process.cwd(), filepath) } const title = path.relative(Instance.worktree, filepath) + const agent = await Agent.get(ctx.agent) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { - throw new Error(`File ${filepath} is not in the current working directory`) + const parentDir = path.dirname(filepath) + if (agent.permission.external_directory === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Access file outside working directory: ${filepath}`, + metadata: { + filepath, + parentDir, + }, + }) + } } const file = Bun.file(filepath) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4ea70f289..6234a4e61 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -3,7 +3,6 @@ import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ListTool } from "./ls" -import { PatchTool } from "./patch" import { ReadTool } from "./read" import { TaskTool } from "./task" import { TodoWriteTool, TodoReadTool } from "./todo" @@ -25,7 +24,12 @@ export namespace ToolRegistry { const glob = new Bun.Glob("tool/*.{js,ts}") for (const dir of await Config.directories()) { - for await (const match of glob.scan({ cwd: dir, absolute: true, followSymlinks: true, dot: true })) { + for await (const match of glob.scan({ + cwd: dir, + absolute: true, + followSymlinks: true, + dot: true, + })) { const namespace = path.basename(match, path.extname(match)) const mod = await import(match) for (const [id, def] of Object.entries<ToolDefinition>(mod)) { @@ -82,7 +86,6 @@ export namespace ToolRegistry { GlobTool, GrepTool, ListTool, - PatchTool, ReadTool, WriteTool, TodoWriteTool, @@ -113,11 +116,9 @@ export namespace ToolRegistry { agent: Agent.Info, ): Promise<Record<string, boolean>> { const result: Record<string, boolean> = {} - result["patch"] = false if (agent.permission.edit === "deny") { result["edit"] = false - result["patch"] = false result["write"] = false } if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 342645c30..845a0e8c8 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async () => { parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, }) - const msg = await Session.getMessage({ sessionID: ctx.sessionID, messageID: ctx.messageID }) + const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") ctx.metadata({ @@ -89,7 +89,7 @@ export const TaskTool = Tool.define("task", async () => { }) unsub() let all - all = await Session.messages(session.id) + all = await Session.messages({ sessionID: session.id }) all = all.filter((x) => x.info.role === "assistant") all = all.flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[]) return { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a4b00100f..58a0c177e 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -18,16 +18,31 @@ export const WriteTool = Tool.define("write", { filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), }), async execute(params, ctx) { + const agent = await Agent.get(ctx.agent) + const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filepath)) { - throw new Error(`File ${filepath} is not in the current working directory`) + const parentDir = path.dirname(filepath) + if (agent.permission.external_directory === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Write file outside working directory: ${filepath}`, + metadata: { + filepath, + parentDir, + }, + }) + } } const file = Bun.file(filepath) const exists = await file.exists() if (exists) await FileTime.assert(ctx.sessionID, filepath) - const agent = await Agent.get(ctx.agent) if (agent.permission.edit === "ask") await Permission.ask({ type: "write", diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 96619416f..5beaf9aab 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -64,6 +64,9 @@ export namespace Keybind { case "leader": info.leader = true break + case "esc": + info.name = "escape" + break default: info.name = part break diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 463069562..209f73032 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -50,6 +50,7 @@ export namespace Log { export function file() { return logpath } + let write = (msg: any) => Bun.stderr.write(msg) export async function init(options: Options) { if (options.level) level = options.level @@ -62,10 +63,10 @@ export namespace Log { const logfile = Bun.file(logpath) await fs.truncate(logpath).catch(() => {}) const writer = logfile.writer() - process.stderr.write = (msg) => { - writer.write(msg) + write = async (msg: any) => { + const num = writer.write(msg) writer.flush() - return true + return num } } @@ -123,22 +124,22 @@ export namespace Log { const result: Logger = { debug(message?: any, extra?: Record<string, any>) { if (shouldLog("DEBUG")) { - process.stderr.write("DEBUG " + build(message, extra)) + write("DEBUG " + build(message, extra)) } }, info(message?: any, extra?: Record<string, any>) { if (shouldLog("INFO")) { - process.stderr.write("INFO " + build(message, extra)) + write("INFO " + build(message, extra)) } }, error(message?: any, extra?: Record<string, any>) { if (shouldLog("ERROR")) { - process.stderr.write("ERROR " + build(message, extra)) + write("ERROR " + build(message, extra)) } }, warn(message?: any, extra?: Record<string, any>) { if (shouldLog("WARN")) { - process.stderr.write("WARN " + build(message, extra)) + write("WARN " + build(message, extra)) } }, tag(key: string, value: string) { diff --git a/packages/opencode/sst-env.d.ts b/packages/opencode/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/opencode/sst-env.d.ts +++ b/packages/opencode/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 75b41fc00..967972842 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -381,11 +381,7 @@ test("resolves scoped npm plugins in config", async () => { await Bun.write( path.join(dir, "opencode.json"), - JSON.stringify( - { $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, - null, - 2, - ), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), ) }, }) diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js new file mode 100644 index 000000000..39e578801 --- /dev/null +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -0,0 +1,77 @@ +// Simple JSON-RPC 2.0 LSP-like fake server over stdio +// Implements a minimal LSP handshake and triggers a request upon notification + +const net = require("net") + +let nextId = 1 + +function encode(message) { + const json = JSON.stringify(message) + const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n` + return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]) +} + +function decodeFrames(buffer) { + const results = [] + let idx + while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) { + const header = buffer.slice(0, idx).toString("utf8") + const m = /Content-Length:\s*(\d+)/i.exec(header) + const len = m ? parseInt(m[1], 10) : 0 + const bodyStart = idx + 4 + const bodyEnd = bodyStart + len + if (buffer.length < bodyEnd) break + const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") + results.push(body) + buffer = buffer.slice(bodyEnd) + } + return { messages: results, rest: buffer } +} + +let readBuffer = Buffer.alloc(0) + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]) + const { messages, rest } = decodeFrames(readBuffer) + readBuffer = rest + for (const m of messages) handle(m) +}) + +function send(msg) { + process.stdout.write(encode(msg)) +} + +function sendRequest(method, params) { + const id = nextId++ + send({ jsonrpc: "2.0", id, method, params }) + return id +} + +function handle(raw) { + let data + try { + data = JSON.parse(raw) + } catch { + return + } + if (data.method === "initialize") { + send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) + return + } + if (data.method === "initialized") { + return + } + if (data.method === "workspace/didChangeConfiguration") { + return + } + if (data.method === "test/trigger") { + const method = data.params && data.params.method + if (method) sendRequest(method, {}) + return + } + if (typeof data.id !== "undefined") { + // Respond OK to any request from client to keep transport flowing + send({ jsonrpc: "2.0", id: data.id, result: null }) + return + } +} diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts new file mode 100644 index 000000000..4d7014019 --- /dev/null +++ b/packages/opencode/test/ide/ide.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, afterEach } from "bun:test" +import { Ide } from "../../src/ide" + +describe("ide", () => { + const original = structuredClone(process.env) + + afterEach(() => { + Object.keys(process.env).forEach((key) => { + delete process.env[key] + }) + Object.assign(process.env, original) + }) + + test("should detect Visual Studio Code", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = "/path/to/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("Visual Studio Code") + }) + + test("should detect Visual Studio Code Insiders", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("Visual Studio Code - Insiders") + }) + + test("should detect Cursor", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = "/path/to/Cursor.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("Cursor") + }) + + test("should detect VSCodium", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = "/path/to/VSCodium.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("VSCodium") + }) + + test("should detect Windsurf", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = "/path/to/Windsurf.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("Windsurf") + }) + + test("should return unknown when TERM_PROGRAM is not vscode", () => { + process.env["TERM_PROGRAM"] = "iTerm2" + process.env["GIT_ASKPASS"] = + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions/git/dist/askpass.sh" + + expect(Ide.ide()).toBe("unknown") + }) + + test("should return unknown when GIT_ASKPASS does not contain IDE name", () => { + process.env["TERM_PROGRAM"] = "vscode" + process.env["GIT_ASKPASS"] = "/path/to/unknown/askpass.sh" + + expect(Ide.ide()).toBe("unknown") + }) + + test("should recognize vscode-insiders OPENCODE_CALLER", () => { + process.env["OPENCODE_CALLER"] = "vscode-insiders" + + expect(Ide.alreadyInstalled()).toBe(true) + }) + + test("should recognize vscode OPENCODE_CALLER", () => { + process.env["OPENCODE_CALLER"] = "vscode" + + expect(Ide.alreadyInstalled()).toBe(true) + }) + + test("should return false for unknown OPENCODE_CALLER", () => { + process.env["OPENCODE_CALLER"] = "unknown" + + expect(Ide.alreadyInstalled()).toBe(false) + }) +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts new file mode 100644 index 000000000..c2ba3ac5b --- /dev/null +++ b/packages/opencode/test/lsp/client.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import { LSPClient } from "../../src/lsp/client" +import { LSPServer } from "../../src/lsp/server" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" + +// Minimal fake LSP server that speaks JSON-RPC over stdio +function spawnFakeServer() { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +describe("LSPClient interop", () => { + beforeEach(async () => { + await Log.init({ print: true }) + }) + + test("handles workspace/workspaceFolders request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "workspace/workspaceFolders", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) + + test("handles client/registerCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/registerCapability", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) + + test("handles client/unregisterCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/unregisterCapability", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) +}) diff --git a/packages/opencode/test/patch/patch.test.ts b/packages/opencode/test/patch/patch.test.ts index 51076c34f..020253bfe 100644 --- a/packages/opencode/test/patch/patch.test.ts +++ b/packages/opencode/test/patch/patch.test.ts @@ -6,23 +6,23 @@ import { tmpdir } from "os" describe("Patch namespace", () => { let tempDir: string - + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(tmpdir(), "patch-test-")) }) - + afterEach(async () => { // Clean up temp directory await fs.rm(tempDir, { recursive: true, force: true }) }) - + describe("parsePatch", () => { test("should parse simple add file patch", () => { const patchText = `*** Begin Patch *** Add File: test.txt +Hello World *** End Patch` - + const result = Patch.parsePatch(patchText) expect(result.hunks).toHaveLength(1) expect(result.hunks[0]).toEqual({ @@ -31,19 +31,19 @@ describe("Patch namespace", () => { contents: "Hello World", }) }) - + test("should parse delete file patch", () => { const patchText = `*** Begin Patch *** Delete File: old.txt *** End Patch` - + const result = Patch.parsePatch(patchText) expect(result.hunks).toHaveLength(1) const hunk = result.hunks[0] expect(hunk.type).toBe("delete") expect(hunk.path).toBe("old.txt") }) - + test("should parse patch with multiple hunks", () => { const patchText = `*** Begin Patch *** Add File: new.txt @@ -54,13 +54,13 @@ describe("Patch namespace", () => { -new line +updated line *** End Patch` - + const result = Patch.parsePatch(patchText) expect(result.hunks).toHaveLength(2) expect(result.hunks[0].type).toBe("add") expect(result.hunks[1].type).toBe("update") }) - + test("should parse file move operation", () => { const patchText = `*** Begin Patch *** Update File: old-name.txt @@ -69,7 +69,7 @@ describe("Patch namespace", () => { -Old content +New content *** End Patch` - + const result = Patch.parsePatch(patchText) expect(result.hunks).toHaveLength(1) const hunk = result.hunks[0] @@ -79,21 +79,21 @@ describe("Patch namespace", () => { expect(hunk.move_path).toBe("new-name.txt") } }) - + test("should throw error for invalid patch format", () => { const invalidPatch = `This is not a valid patch` - + expect(() => Patch.parsePatch(invalidPatch)).toThrow("Invalid patch format") }) }) - + describe("maybeParseApplyPatch", () => { test("should parse direct apply_patch command", () => { const patchText = `*** Begin Patch *** Add File: test.txt +Content *** End Patch` - + const result = Patch.maybeParseApplyPatch(["apply_patch", patchText]) expect(result.type).toBe(Patch.MaybeApplyPatch.Body) if (result.type === Patch.MaybeApplyPatch.Body) { @@ -101,17 +101,17 @@ describe("Patch namespace", () => { expect(result.args.hunks).toHaveLength(1) } }) - + test("should parse applypatch command", () => { const patchText = `*** Begin Patch *** Add File: test.txt +Content *** End Patch` - + const result = Patch.maybeParseApplyPatch(["applypatch", patchText]) expect(result.type).toBe(Patch.MaybeApplyPatch.Body) }) - + test("should handle bash heredoc format", () => { const script = `apply_patch <<'PATCH' *** Begin Patch @@ -119,20 +119,20 @@ describe("Patch namespace", () => { +Content *** End Patch PATCH` - + const result = Patch.maybeParseApplyPatch(["bash", "-lc", script]) expect(result.type).toBe(Patch.MaybeApplyPatch.Body) if (result.type === Patch.MaybeApplyPatch.Body) { expect(result.args.hunks).toHaveLength(1) } }) - + test("should return NotApplyPatch for non-patch commands", () => { const result = Patch.maybeParseApplyPatch(["echo", "hello"]) expect(result.type).toBe(Patch.MaybeApplyPatch.NotApplyPatch) }) }) - + describe("applyPatch", () => { test("should add a new file", async () => { const patchText = `*** Begin Patch @@ -140,36 +140,39 @@ PATCH` +Hello World +This is a new file *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.added).toHaveLength(1) expect(result.modified).toHaveLength(0) expect(result.deleted).toHaveLength(0) - + const content = await fs.readFile(result.added[0], "utf-8") expect(content).toBe("Hello World\nThis is a new file") }) - + test("should delete an existing file", async () => { const filePath = path.join(tempDir, "to-delete.txt") await fs.writeFile(filePath, "This file will be deleted") - + const patchText = `*** Begin Patch *** Delete File: ${filePath} *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.deleted).toHaveLength(1) expect(result.deleted[0]).toBe(filePath) - - const exists = await fs.access(filePath).then(() => true).catch(() => false) + + const exists = await fs + .access(filePath) + .then(() => true) + .catch(() => false) expect(exists).toBe(false) }) - + test("should update an existing file", async () => { const filePath = path.join(tempDir, "to-update.txt") await fs.writeFile(filePath, "line 1\nline 2\nline 3\n") - + const patchText = `*** Begin Patch *** Update File: ${filePath} @@ @@ -178,20 +181,20 @@ PATCH` +line 2 updated line 3 *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.modified).toHaveLength(1) expect(result.modified[0]).toBe(filePath) - + const content = await fs.readFile(filePath, "utf-8") expect(content).toBe("line 1\nline 2 updated\nline 3\n") }) - + test("should move and update a file", async () => { const oldPath = path.join(tempDir, "old-name.txt") const newPath = path.join(tempDir, "new-name.txt") await fs.writeFile(oldPath, "old content\n") - + const patchText = `*** Begin Patch *** Update File: ${oldPath} *** Move to: ${newPath} @@ -199,26 +202,29 @@ PATCH` -old content +new content *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.modified).toHaveLength(1) expect(result.modified[0]).toBe(newPath) - - const oldExists = await fs.access(oldPath).then(() => true).catch(() => false) + + const oldExists = await fs + .access(oldPath) + .then(() => true) + .catch(() => false) expect(oldExists).toBe(false) - + const newContent = await fs.readFile(newPath, "utf-8") expect(newContent).toBe("new content\n") }) - + test("should handle multiple operations in one patch", async () => { const file1 = path.join(tempDir, "file1.txt") const file2 = path.join(tempDir, "file2.txt") const file3 = path.join(tempDir, "file3.txt") - + await fs.writeFile(file1, "content 1") await fs.writeFile(file2, "content 2") - + const patchText = `*** Begin Patch *** Add File: ${file3} +new file content @@ -228,95 +234,98 @@ PATCH` +updated content 1 *** Delete File: ${file2} *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.added).toHaveLength(1) expect(result.modified).toHaveLength(1) expect(result.deleted).toHaveLength(1) }) - + test("should create parent directories when adding files", async () => { const nestedPath = path.join(tempDir, "deep", "nested", "file.txt") - + const patchText = `*** Begin Patch *** Add File: ${nestedPath} +Deep nested content *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.added).toHaveLength(1) expect(result.added[0]).toBe(nestedPath) - - const exists = await fs.access(nestedPath).then(() => true).catch(() => false) + + const exists = await fs + .access(nestedPath) + .then(() => true) + .catch(() => false) expect(exists).toBe(true) }) }) - + describe("error handling", () => { test("should throw error when updating non-existent file", async () => { const nonExistent = path.join(tempDir, "does-not-exist.txt") - + const patchText = `*** Begin Patch *** Update File: ${nonExistent} @@ -old line +new line *** End Patch` - + await expect(Patch.applyPatch(patchText)).rejects.toThrow() }) - + test("should throw error when deleting non-existent file", async () => { const nonExistent = path.join(tempDir, "does-not-exist.txt") - + const patchText = `*** Begin Patch *** Delete File: ${nonExistent} *** End Patch` - + await expect(Patch.applyPatch(patchText)).rejects.toThrow() }) }) - + describe("edge cases", () => { test("should handle empty files", async () => { const emptyFile = path.join(tempDir, "empty.txt") await fs.writeFile(emptyFile, "") - + const patchText = `*** Begin Patch *** Update File: ${emptyFile} @@ +First line *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.modified).toHaveLength(1) - + const content = await fs.readFile(emptyFile, "utf-8") expect(content).toBe("First line\n") }) - + test("should handle files with no trailing newline", async () => { const filePath = path.join(tempDir, "no-newline.txt") await fs.writeFile(filePath, "no newline") - + const patchText = `*** Begin Patch *** Update File: ${filePath} @@ -no newline +has newline now *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.modified).toHaveLength(1) - + const content = await fs.readFile(filePath, "utf-8") expect(content).toBe("has newline now\n") }) - + test("should handle multiple update chunks in single file", async () => { const filePath = path.join(tempDir, "multi-chunk.txt") await fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n") - + const patchText = `*** Begin Patch *** Update File: ${filePath} @@ @@ -328,12 +337,12 @@ PATCH` -line 4 +LINE 4 *** End Patch` - + const result = await Patch.applyPatch(patchText) expect(result.modified).toHaveLength(1) - + const content = await fs.readFile(filePath, "utf-8") expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n") }) }) -})
\ No newline at end of file +}) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index edce412c2..27148e2af 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -13,8 +13,8 @@ function apiError(headers?: Record<string, string>): MessageV2.APIError { describe("session.retry.getRetryDelayInMs", () => { test("doubles delay on each attempt when headers missing", () => { const error = apiError() - const delays = Array.from({ length: 7 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1)) - expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000]) + const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1)) + expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000, 256000, 512000, undefined]) }) test("prefers retry-after-ms when shorter than exponential", () => { @@ -27,11 +27,6 @@ describe("session.retry.getRetryDelayInMs", () => { expect(SessionRetry.getRetryDelayInMs(error, 3)).toBe(30000) }) - test("falls back to exponential when server delay is long", () => { - const error = apiError({ "retry-after": "120" }) - expect(SessionRetry.getRetryDelayInMs(error, 2)).toBe(4000) - }) - test("accepts http-date retry-after values", () => { const date = new Date(Date.now() + 20000).toUTCString() const error = apiError({ "retry-after": date }) @@ -44,4 +39,134 @@ describe("session.retry.getRetryDelayInMs", () => { const error = apiError({ "retry-after": "not-a-number" }) expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) }) + + test("ignores malformed date retry hints", () => { + const error = apiError({ "retry-after": "Invalid Date String" }) + expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + }) + + test("ignores past date retry hints", () => { + const pastDate = new Date(Date.now() - 5000).toUTCString() + const error = apiError({ "retry-after": pastDate }) + expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + }) + + test("returns undefined when delay exceeds 10 minutes", () => { + const error = apiError() + expect(SessionRetry.getRetryDelayInMs(error, 10)).toBeUndefined() + }) + + test("returns undefined when retry-after exceeds 10 minutes", () => { + const error = apiError({ "retry-after": "50" }) + expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(50000) + + const longError = apiError({ "retry-after-ms": "700000" }) + expect(SessionRetry.getRetryDelayInMs(longError, 1)).toBeUndefined() + }) +}) + +describe("session.retry.getBoundedDelay", () => { + test("returns full delay when under time budget", () => { + const error = apiError() + const startTime = Date.now() + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBe(2000) + }) + + test("returns remaining time when delay exceeds budget", () => { + const error = apiError() + const startTime = Date.now() - 598_000 // 598 seconds elapsed, 2 seconds remaining + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBeGreaterThanOrEqual(1900) + expect(delay).toBeLessThanOrEqual(2100) + }) + + test("returns undefined when time budget exhausted", () => { + const error = apiError() + const startTime = Date.now() - 600_000 // exactly 10 minutes elapsed + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBeUndefined() + }) + + test("returns undefined when time budget exceeded", () => { + const error = apiError() + const startTime = Date.now() - 700_000 // 11+ minutes elapsed + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBeUndefined() + }) + + test("respects custom maxDuration", () => { + const error = apiError() + const startTime = Date.now() - 58_000 // 58 seconds elapsed + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + maxDuration: 60_000, // 1 minute max + }) + expect(delay).toBeGreaterThanOrEqual(1900) + expect(delay).toBeLessThanOrEqual(2100) + }) + + test("caps exponential backoff to remaining time", () => { + const error = apiError() + const startTime = Date.now() - 595_000 // 595 seconds elapsed, 5 seconds remaining + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 5, // would normally be 32 seconds + startTime, + }) + expect(delay).toBeGreaterThanOrEqual(4900) + expect(delay).toBeLessThanOrEqual(5100) + }) + + test("respects server retry-after within budget", () => { + const error = apiError({ "retry-after": "30" }) + const startTime = Date.now() - 550_000 // 550 seconds elapsed, 50 seconds remaining + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBe(30000) + }) + + test("caps server retry-after to remaining time", () => { + const error = apiError({ "retry-after": "30" }) + const startTime = Date.now() - 590_000 // 590 seconds elapsed, 10 seconds remaining + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 1, + startTime, + }) + expect(delay).toBeGreaterThanOrEqual(9900) + expect(delay).toBeLessThanOrEqual(10100) + }) + + test("returns undefined when getRetryDelayInMs returns undefined", () => { + const error = apiError() + const startTime = Date.now() + const delay = SessionRetry.getBoundedDelay({ + error, + attempt: 10, // exceeds RETRY_MAX_DELAY + startTime, + }) + expect(delay).toBeUndefined() + }) }) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts index a34d7718d..dcdefabb8 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -3,6 +3,7 @@ import path from "path" import { PatchTool } from "../../src/tool/patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import { Permission } from "../../src/permission" import * as fs from "fs/promises" const ctx = { @@ -21,9 +22,7 @@ describe("tool.patch", () => { await Instance.provide({ directory: "/tmp", fn: async () => { - await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow( - "patchText is required", - ) + expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") }, }) }) @@ -32,9 +31,7 @@ describe("tool.patch", () => { await Instance.provide({ directory: "/tmp", fn: async () => { - await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow( - "Failed to parse patch", - ) + expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch") }, }) }) @@ -46,14 +43,12 @@ describe("tool.patch", () => { const emptyPatch = `*** Begin Patch *** End Patch` - await expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow( - "No file changes found in patch", - ) + expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch") }, }) }) - test("should reject files outside working directory", async () => { + test("should ask permission for files outside working directory", async () => { await Instance.provide({ directory: "/tmp", fn: async () => { @@ -61,10 +56,10 @@ describe("tool.patch", () => { *** Add File: /etc/passwd +malicious content *** End Patch` - - await expect(patchTool.execute({ patchText: maliciousPatch }, ctx)).rejects.toThrow( - "is not in the current working directory", - ) + patchTool.execute({ patchText: maliciousPatch }, ctx) + // TODO: this sucks + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(Permission.pending()[ctx.sessionID]).toBeDefined() }, }) }) @@ -117,9 +112,7 @@ describe("tool.patch", () => { // Verify file was created with correct content const filePath = path.join(fixture.path, "config.js") const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe( - 'const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"', - ) + expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"') }, }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0ad4afe63..2bcc7c308 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.23", + "version": "1.0.55", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index f103749bd..0601e5877 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -143,12 +143,15 @@ export interface Hooks { /** * Called when a new message is received */ - "chat.message"?: (input: {}, output: { message: UserMessage; parts: Part[] }) => Promise<void> + "chat.message"?: ( + input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string }; messageID?: string }, + output: { message: UserMessage; parts: Part[] }, + ) => Promise<void> /** * Modify parameters sent to LLM */ "chat.params"?: ( - input: { model: Model; provider: Provider; message: UserMessage }, + input: { sessionID: string; agent: string; model: Model; provider: Provider; message: UserMessage }, output: { temperature: number; topP: number; options: Record<string, any> }, ) => Promise<void> "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void> diff --git a/packages/plugin/sst-env.d.ts b/packages/plugin/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/plugin/sst-env.d.ts +++ b/packages/plugin/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 695a45143..141d2b750 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -10,19 +10,14 @@ if (!expectedBunVersion) { } if (process.versions.bun !== expectedBunVersion) { - throw new Error( - `This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`, - ) + throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`) } -const CHANNEL = - process.env["OPENCODE_CHANNEL"] ?? - (await $`git branch --show-current`.text().then((x) => x.trim())) +const CHANNEL = process.env["OPENCODE_CHANNEL"] ?? (await $`git branch --show-current`.text().then((x) => x.trim())) const IS_PREVIEW = CHANNEL !== "latest" const VERSION = await (async () => { if (process.env["OPENCODE_VERSION"]) return process.env["OPENCODE_VERSION"] - if (IS_PREVIEW) - return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` + if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) diff --git a/packages/script/sst-env.d.ts b/packages/script/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/script/sst-env.d.ts +++ b/packages/script/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/sdk/go/.github/workflows/ci.yml b/packages/sdk/go/.github/workflows/ci.yml index 4bf1e907c..0f5d45dc2 100644 --- a/packages/sdk/go/.github/workflows/ci.yml +++ b/packages/sdk/go/.github/workflows/ci.yml @@ -2,15 +2,15 @@ name: CI on: push: branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + - "generated" + - "codegen/**" + - "integrated/**" + - "stl-preview-head/**" + - "stl-preview-base/**" pull_request: branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' + - "stl-preview-head/**" + - "stl-preview-base/**" jobs: lint: diff --git a/packages/sdk/go/.release-please-manifest.json b/packages/sdk/go/.release-please-manifest.json index 4ad3fef33..5e39b9417 100644 --- a/packages/sdk/go/.release-please-manifest.json +++ b/packages/sdk/go/.release-please-manifest.json @@ -1,3 +1,3 @@ { ".": "0.18.0" -}
\ No newline at end of file +} diff --git a/packages/sdk/go/CHANGELOG.md b/packages/sdk/go/CHANGELOG.md index 498a78029..937fbfdd0 100644 --- a/packages/sdk/go/CHANGELOG.md +++ b/packages/sdk/go/CHANGELOG.md @@ -6,7 +6,7 @@ Full Changelog: [v0.17.0...v0.18.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([0a7f5e7](https://github.com/sst/opencode-sdk-go/commit/0a7f5e710911506512a132ba39e0593c412beb77)) +- **api:** api update ([0a7f5e7](https://github.com/sst/opencode-sdk-go/commit/0a7f5e710911506512a132ba39e0593c412beb77)) ## 0.17.0 (2025-10-07) @@ -14,7 +14,7 @@ Full Changelog: [v0.16.2...v0.17.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([84a3df5](https://github.com/sst/opencode-sdk-go/commit/84a3df50a7ff3d87e5593e4f29dfb5d561f71cc3)) +- **api:** api update ([84a3df5](https://github.com/sst/opencode-sdk-go/commit/84a3df50a7ff3d87e5593e4f29dfb5d561f71cc3)) ## 0.16.2 (2025-09-26) @@ -22,7 +22,7 @@ Full Changelog: [v0.16.1...v0.16.2](https://github.com/sst/opencode-sdk-go/compa ### Bug Fixes -* bugfix for setting JSON keys with special characters ([ac9a36f](https://github.com/sst/opencode-sdk-go/commit/ac9a36feb1c185ebf766d76909d0b86ac805e8a6)) +- bugfix for setting JSON keys with special characters ([ac9a36f](https://github.com/sst/opencode-sdk-go/commit/ac9a36feb1c185ebf766d76909d0b86ac805e8a6)) ## 0.16.1 (2025-09-20) @@ -30,14 +30,13 @@ Full Changelog: [v0.16.0...v0.16.1](https://github.com/sst/opencode-sdk-go/compa ### Bug Fixes -* use slices.Concat instead of sometimes modifying r.Options ([12e8b40](https://github.com/sst/opencode-sdk-go/commit/12e8b40809071095b0abb9b8031686353c8ac149)) - +- use slices.Concat instead of sometimes modifying r.Options ([12e8b40](https://github.com/sst/opencode-sdk-go/commit/12e8b40809071095b0abb9b8031686353c8ac149)) ### Chores -* bump minimum go version to 1.22 ([1a61c5c](https://github.com/sst/opencode-sdk-go/commit/1a61c5cc7e8f68cc1b0c219738cab530cb6aa3a2)) -* do not install brew dependencies in ./scripts/bootstrap by default ([f6d3eaf](https://github.com/sst/opencode-sdk-go/commit/f6d3eafffc20e124bbfae6ac5ddc1b1122ad3e27)) -* update more docs for 1.22 ([a3d0b0f](https://github.com/sst/opencode-sdk-go/commit/a3d0b0f26ed92ce1a6433f5bcf37a6436d268ba5)) +- bump minimum go version to 1.22 ([1a61c5c](https://github.com/sst/opencode-sdk-go/commit/1a61c5cc7e8f68cc1b0c219738cab530cb6aa3a2)) +- do not install brew dependencies in ./scripts/bootstrap by default ([f6d3eaf](https://github.com/sst/opencode-sdk-go/commit/f6d3eafffc20e124bbfae6ac5ddc1b1122ad3e27)) +- update more docs for 1.22 ([a3d0b0f](https://github.com/sst/opencode-sdk-go/commit/a3d0b0f26ed92ce1a6433f5bcf37a6436d268ba5)) ## 0.16.0 (2025-09-17) @@ -45,7 +44,7 @@ Full Changelog: [v0.15.0...v0.16.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([46e978e](https://github.com/sst/opencode-sdk-go/commit/46e978e43aee733d5c1c09dc5be6d8ac2a752427)) +- **api:** api update ([46e978e](https://github.com/sst/opencode-sdk-go/commit/46e978e43aee733d5c1c09dc5be6d8ac2a752427)) ## 0.15.0 (2025-09-16) @@ -53,7 +52,7 @@ Full Changelog: [v0.14.0...v0.15.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([397048f](https://github.com/sst/opencode-sdk-go/commit/397048faca7a1de7a028edd2254a0ad7797b151f)) +- **api:** api update ([397048f](https://github.com/sst/opencode-sdk-go/commit/397048faca7a1de7a028edd2254a0ad7797b151f)) ## 0.14.0 (2025-09-14) @@ -61,7 +60,7 @@ Full Changelog: [v0.13.0...v0.14.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([dad0bc3](https://github.com/sst/opencode-sdk-go/commit/dad0bc3da99f20a0d002a6b94e049fb70f8e6a77)) +- **api:** api update ([dad0bc3](https://github.com/sst/opencode-sdk-go/commit/dad0bc3da99f20a0d002a6b94e049fb70f8e6a77)) ## 0.13.0 (2025-09-14) @@ -69,7 +68,7 @@ Full Changelog: [v0.12.0...v0.13.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([80da4fb](https://github.com/sst/opencode-sdk-go/commit/80da4fb4ea9c6afb51a7e7135d9f5560ce6f2a6c)) +- **api:** api update ([80da4fb](https://github.com/sst/opencode-sdk-go/commit/80da4fb4ea9c6afb51a7e7135d9f5560ce6f2a6c)) ## 0.12.0 (2025-09-14) @@ -77,7 +76,7 @@ Full Changelog: [v0.11.0...v0.12.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([7e3808b](https://github.com/sst/opencode-sdk-go/commit/7e3808ba349dc653174b32b48a1120c18d2975c2)) +- **api:** api update ([7e3808b](https://github.com/sst/opencode-sdk-go/commit/7e3808ba349dc653174b32b48a1120c18d2975c2)) ## 0.11.0 (2025-09-14) @@ -85,7 +84,7 @@ Full Changelog: [v0.10.0...v0.11.0](https://github.com/sst/opencode-sdk-go/compa ### Features -* **api:** api update ([a3d37f5](https://github.com/sst/opencode-sdk-go/commit/a3d37f5671545866547d351fc21b49809cc8b3c2)) +- **api:** api update ([a3d37f5](https://github.com/sst/opencode-sdk-go/commit/a3d37f5671545866547d351fc21b49809cc8b3c2)) ## 0.10.0 (2025-09-11) @@ -93,7 +92,7 @@ Full Changelog: [v0.9.0...v0.10.0](https://github.com/sst/opencode-sdk-go/compar ### Features -* **api:** api update ([0dc01f6](https://github.com/sst/opencode-sdk-go/commit/0dc01f6695c9b8400a4dc92166c5002bb120cf50)) +- **api:** api update ([0dc01f6](https://github.com/sst/opencode-sdk-go/commit/0dc01f6695c9b8400a4dc92166c5002bb120cf50)) ## 0.9.0 (2025-09-10) @@ -101,7 +100,7 @@ Full Changelog: [v0.8.0...v0.9.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([2d3a28d](https://github.com/sst/opencode-sdk-go/commit/2d3a28df5657845aa4d73087e1737d1fc8c3ce1c)) +- **api:** api update ([2d3a28d](https://github.com/sst/opencode-sdk-go/commit/2d3a28df5657845aa4d73087e1737d1fc8c3ce1c)) ## 0.8.0 (2025-09-01) @@ -109,7 +108,7 @@ Full Changelog: [v0.7.0...v0.8.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([ae87a71](https://github.com/sst/opencode-sdk-go/commit/ae87a71949994590ace8285a39f0991ef34b664d)) +- **api:** api update ([ae87a71](https://github.com/sst/opencode-sdk-go/commit/ae87a71949994590ace8285a39f0991ef34b664d)) ## 0.7.0 (2025-09-01) @@ -117,7 +116,7 @@ Full Changelog: [v0.6.0...v0.7.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([64bb1b1](https://github.com/sst/opencode-sdk-go/commit/64bb1b1ee0cbe153abc6fb7bd9703b47911724d4)) +- **api:** api update ([64bb1b1](https://github.com/sst/opencode-sdk-go/commit/64bb1b1ee0cbe153abc6fb7bd9703b47911724d4)) ## 0.6.0 (2025-09-01) @@ -125,7 +124,7 @@ Full Changelog: [v0.5.0...v0.6.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([928e384](https://github.com/sst/opencode-sdk-go/commit/928e3843355f96899f046f002b84372281dad0c8)) +- **api:** api update ([928e384](https://github.com/sst/opencode-sdk-go/commit/928e3843355f96899f046f002b84372281dad0c8)) ## 0.5.0 (2025-08-31) @@ -133,7 +132,7 @@ Full Changelog: [v0.4.0...v0.5.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([44b281d](https://github.com/sst/opencode-sdk-go/commit/44b281d0bb39c5022a984ac9d0fca1529ccc0604)) +- **api:** api update ([44b281d](https://github.com/sst/opencode-sdk-go/commit/44b281d0bb39c5022a984ac9d0fca1529ccc0604)) ## 0.4.0 (2025-08-31) @@ -141,7 +140,7 @@ Full Changelog: [v0.3.0...v0.4.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([fa9d6ec](https://github.com/sst/opencode-sdk-go/commit/fa9d6ec6472e62f4f6605d0a71a7aa8bf8a24559)) +- **api:** api update ([fa9d6ec](https://github.com/sst/opencode-sdk-go/commit/fa9d6ec6472e62f4f6605d0a71a7aa8bf8a24559)) ## 0.3.0 (2025-08-31) @@ -149,7 +148,7 @@ Full Changelog: [v0.2.0...v0.3.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([aae1c06](https://github.com/sst/opencode-sdk-go/commit/aae1c06bb5a93a1cd9c589846a84b3f16246f5da)) +- **api:** api update ([aae1c06](https://github.com/sst/opencode-sdk-go/commit/aae1c06bb5a93a1cd9c589846a84b3f16246f5da)) ## 0.2.0 (2025-08-31) @@ -157,7 +156,7 @@ Full Changelog: [v0.1.0...v0.2.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([1472790](https://github.com/sst/opencode-sdk-go/commit/1472790542515f47bd46e2a9e28d8afea024cf9c)) +- **api:** api update ([1472790](https://github.com/sst/opencode-sdk-go/commit/1472790542515f47bd46e2a9e28d8afea024cf9c)) ## 0.1.0 (2025-08-31) @@ -165,61 +164,59 @@ Full Changelog: [v0.0.1...v0.1.0](https://github.com/sst/opencode-sdk-go/compare ### Features -* **api:** api update ([3f03ddd](https://github.com/sst/opencode-sdk-go/commit/3f03dddd5ec0de98f99ce48679077dcae9ceffd6)) -* **api:** api update ([e9f79c4](https://github.com/sst/opencode-sdk-go/commit/e9f79c4792b21ef64ab0431ffd76f5a71e04d182)) -* **api:** api update ([139a686](https://github.com/sst/opencode-sdk-go/commit/139a6862d2f0ab0c8ea791663d736868be3e96e6)) -* **api:** api update ([2ed0800](https://github.com/sst/opencode-sdk-go/commit/2ed0800b2c5b99877e9f7fde669a6c005fad6b77)) -* **api:** api update ([88a87a4](https://github.com/sst/opencode-sdk-go/commit/88a87a458f56ce0c18b502c73da933f614f56e8b)) -* **api:** api update ([0e5d65b](https://github.com/sst/opencode-sdk-go/commit/0e5d65b571e7b30dc6347e6730098878ebba3a42)) -* **api:** api update ([ba381f1](https://github.com/sst/opencode-sdk-go/commit/ba381f1e07aad24e9824df7d53befae2a644f69f)) -* **api:** api update ([3f429f5](https://github.com/sst/opencode-sdk-go/commit/3f429f5b4be5607433ef5fdc0d5bf67fe590d039)) -* **api:** api update ([9f34787](https://github.com/sst/opencode-sdk-go/commit/9f347876b35b7f898060c1a5f71c322e95978e3e)) -* **api:** api update ([379c8e0](https://github.com/sst/opencode-sdk-go/commit/379c8e00197e13aebaf2f2d61277b125f1f90011)) -* **api:** api update ([550511c](https://github.com/sst/opencode-sdk-go/commit/550511c4c5b5055ac8ff22b7b11731331bd9d088)) -* **api:** api update ([547f0c2](https://github.com/sst/opencode-sdk-go/commit/547f0c262f2df1ce83eaa7267d68be64bb29b841)) -* **api:** api update ([b7b0720](https://github.com/sst/opencode-sdk-go/commit/b7b07204bff314da24b1819c128835a43ef64065)) -* **api:** api update ([7250ffc](https://github.com/sst/opencode-sdk-go/commit/7250ffcba262b916c958ddecc2a42927982db39f)) -* **api:** api update ([17fbab7](https://github.com/sst/opencode-sdk-go/commit/17fbab73111a3eae488737c69b12370bc69c65f7)) -* **api:** api update ([1270b5c](https://github.com/sst/opencode-sdk-go/commit/1270b5cd81e6ac769dcd92ade6d877891bf51bd5)) -* **api:** api update ([a238d4a](https://github.com/sst/opencode-sdk-go/commit/a238d4abd6ed7d15f3547d27a4b6ecf4aec8431e)) -* **api:** api update ([7475655](https://github.com/sst/opencode-sdk-go/commit/7475655aca577fe4f807c2f02f92171f6a358e9c)) -* **api:** api update ([429d258](https://github.com/sst/opencode-sdk-go/commit/429d258bb56e9cdeb1528be3944bf5537ac26a96)) -* **api:** api update ([f250915](https://github.com/sst/opencode-sdk-go/commit/f2509157eaf1b453e741ee9482127cad2e3ace25)) -* **api:** api update ([5efc987](https://github.com/sst/opencode-sdk-go/commit/5efc987353801d1e772c20edf162b1c75da32743)) -* **api:** api update ([98a8350](https://github.com/sst/opencode-sdk-go/commit/98a83504f7cfc361e83314c3e79a4e9ff53f0560)) -* **api:** api update ([6da8bf8](https://github.com/sst/opencode-sdk-go/commit/6da8bf8bfe91d45991fb580753d77c5534fc0b1b)) -* **api:** api update ([f8c7148](https://github.com/sst/opencode-sdk-go/commit/f8c7148ae56143823186e2675a78e82676154956)) -* **api:** manual updates ([7cf038f](https://github.com/sst/opencode-sdk-go/commit/7cf038ffae5da1b77e1cef11b5fa166a53b467f2)) -* **api:** update via SDK Studio ([068a0eb](https://github.com/sst/opencode-sdk-go/commit/068a0eb025010da0c8d86fa1bb496a39dbedcef9)) -* **api:** update via SDK Studio ([ca651ed](https://github.com/sst/opencode-sdk-go/commit/ca651edaf71d1f3678f929287474f5bc4f1aad10)) -* **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775)) -* **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf)) -* **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e)) -* **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52)) -* **api:** update via SDK Studio ([51315fa](https://github.com/sst/opencode-sdk-go/commit/51315fa2eae424743ea79701e67d44447c44144d)) -* **api:** update via SDK Studio ([af07955](https://github.com/sst/opencode-sdk-go/commit/af0795543240aefaf04fc7663a348825541c79ed)) -* **api:** update via SDK Studio ([5e3468a](https://github.com/sst/opencode-sdk-go/commit/5e3468a0aaa5ed3b13e019c3a24e0ba9147d1675)) -* **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397)) -* **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844)) -* **client:** expand max streaming buffer size ([76303e5](https://github.com/sst/opencode-sdk-go/commit/76303e51067e78e732af26ced9d83b8bad7655c3)) -* **client:** support optional json html escaping ([449748f](https://github.com/sst/opencode-sdk-go/commit/449748f35a1d8cb6f91dc36d25bf9489f4f371bd)) - +- **api:** api update ([3f03ddd](https://github.com/sst/opencode-sdk-go/commit/3f03dddd5ec0de98f99ce48679077dcae9ceffd6)) +- **api:** api update ([e9f79c4](https://github.com/sst/opencode-sdk-go/commit/e9f79c4792b21ef64ab0431ffd76f5a71e04d182)) +- **api:** api update ([139a686](https://github.com/sst/opencode-sdk-go/commit/139a6862d2f0ab0c8ea791663d736868be3e96e6)) +- **api:** api update ([2ed0800](https://github.com/sst/opencode-sdk-go/commit/2ed0800b2c5b99877e9f7fde669a6c005fad6b77)) +- **api:** api update ([88a87a4](https://github.com/sst/opencode-sdk-go/commit/88a87a458f56ce0c18b502c73da933f614f56e8b)) +- **api:** api update ([0e5d65b](https://github.com/sst/opencode-sdk-go/commit/0e5d65b571e7b30dc6347e6730098878ebba3a42)) +- **api:** api update ([ba381f1](https://github.com/sst/opencode-sdk-go/commit/ba381f1e07aad24e9824df7d53befae2a644f69f)) +- **api:** api update ([3f429f5](https://github.com/sst/opencode-sdk-go/commit/3f429f5b4be5607433ef5fdc0d5bf67fe590d039)) +- **api:** api update ([9f34787](https://github.com/sst/opencode-sdk-go/commit/9f347876b35b7f898060c1a5f71c322e95978e3e)) +- **api:** api update ([379c8e0](https://github.com/sst/opencode-sdk-go/commit/379c8e00197e13aebaf2f2d61277b125f1f90011)) +- **api:** api update ([550511c](https://github.com/sst/opencode-sdk-go/commit/550511c4c5b5055ac8ff22b7b11731331bd9d088)) +- **api:** api update ([547f0c2](https://github.com/sst/opencode-sdk-go/commit/547f0c262f2df1ce83eaa7267d68be64bb29b841)) +- **api:** api update ([b7b0720](https://github.com/sst/opencode-sdk-go/commit/b7b07204bff314da24b1819c128835a43ef64065)) +- **api:** api update ([7250ffc](https://github.com/sst/opencode-sdk-go/commit/7250ffcba262b916c958ddecc2a42927982db39f)) +- **api:** api update ([17fbab7](https://github.com/sst/opencode-sdk-go/commit/17fbab73111a3eae488737c69b12370bc69c65f7)) +- **api:** api update ([1270b5c](https://github.com/sst/opencode-sdk-go/commit/1270b5cd81e6ac769dcd92ade6d877891bf51bd5)) +- **api:** api update ([a238d4a](https://github.com/sst/opencode-sdk-go/commit/a238d4abd6ed7d15f3547d27a4b6ecf4aec8431e)) +- **api:** api update ([7475655](https://github.com/sst/opencode-sdk-go/commit/7475655aca577fe4f807c2f02f92171f6a358e9c)) +- **api:** api update ([429d258](https://github.com/sst/opencode-sdk-go/commit/429d258bb56e9cdeb1528be3944bf5537ac26a96)) +- **api:** api update ([f250915](https://github.com/sst/opencode-sdk-go/commit/f2509157eaf1b453e741ee9482127cad2e3ace25)) +- **api:** api update ([5efc987](https://github.com/sst/opencode-sdk-go/commit/5efc987353801d1e772c20edf162b1c75da32743)) +- **api:** api update ([98a8350](https://github.com/sst/opencode-sdk-go/commit/98a83504f7cfc361e83314c3e79a4e9ff53f0560)) +- **api:** api update ([6da8bf8](https://github.com/sst/opencode-sdk-go/commit/6da8bf8bfe91d45991fb580753d77c5534fc0b1b)) +- **api:** api update ([f8c7148](https://github.com/sst/opencode-sdk-go/commit/f8c7148ae56143823186e2675a78e82676154956)) +- **api:** manual updates ([7cf038f](https://github.com/sst/opencode-sdk-go/commit/7cf038ffae5da1b77e1cef11b5fa166a53b467f2)) +- **api:** update via SDK Studio ([068a0eb](https://github.com/sst/opencode-sdk-go/commit/068a0eb025010da0c8d86fa1bb496a39dbedcef9)) +- **api:** update via SDK Studio ([ca651ed](https://github.com/sst/opencode-sdk-go/commit/ca651edaf71d1f3678f929287474f5bc4f1aad10)) +- **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775)) +- **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf)) +- **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e)) +- **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52)) +- **api:** update via SDK Studio ([51315fa](https://github.com/sst/opencode-sdk-go/commit/51315fa2eae424743ea79701e67d44447c44144d)) +- **api:** update via SDK Studio ([af07955](https://github.com/sst/opencode-sdk-go/commit/af0795543240aefaf04fc7663a348825541c79ed)) +- **api:** update via SDK Studio ([5e3468a](https://github.com/sst/opencode-sdk-go/commit/5e3468a0aaa5ed3b13e019c3a24e0ba9147d1675)) +- **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397)) +- **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844)) +- **client:** expand max streaming buffer size ([76303e5](https://github.com/sst/opencode-sdk-go/commit/76303e51067e78e732af26ced9d83b8bad7655c3)) +- **client:** support optional json html escaping ([449748f](https://github.com/sst/opencode-sdk-go/commit/449748f35a1d8cb6f91dc36d25bf9489f4f371bd)) ### Bug Fixes -* **client:** process custom base url ahead of time ([9b360d6](https://github.com/sst/opencode-sdk-go/commit/9b360d642cf6f302104308af5622e17099899e5f)) -* **client:** resolve lint errors in streaming tests ([4d36cb0](https://github.com/sst/opencode-sdk-go/commit/4d36cb09fc9d436734d5dab1c499acaa88568ff7)) -* close body before retrying ([4da3f7f](https://github.com/sst/opencode-sdk-go/commit/4da3f7f372bad222a189ba3eabcfde3373166ae5)) -* don't try to deserialize as json when ResponseBodyInto is []byte ([595291f](https://github.com/sst/opencode-sdk-go/commit/595291f6dba6af472f160b9f8e3d145002f43a4a)) - +- **client:** process custom base url ahead of time ([9b360d6](https://github.com/sst/opencode-sdk-go/commit/9b360d642cf6f302104308af5622e17099899e5f)) +- **client:** resolve lint errors in streaming tests ([4d36cb0](https://github.com/sst/opencode-sdk-go/commit/4d36cb09fc9d436734d5dab1c499acaa88568ff7)) +- close body before retrying ([4da3f7f](https://github.com/sst/opencode-sdk-go/commit/4da3f7f372bad222a189ba3eabcfde3373166ae5)) +- don't try to deserialize as json when ResponseBodyInto is []byte ([595291f](https://github.com/sst/opencode-sdk-go/commit/595291f6dba6af472f160b9f8e3d145002f43a4a)) ### Chores -* **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d)) -* **internal:** codegen related update ([6a22ce6](https://github.com/sst/opencode-sdk-go/commit/6a22ce6df155f5003e80b8a75686a9e513a5568a)) -* **internal:** fix lint script for tests ([391c482](https://github.com/sst/opencode-sdk-go/commit/391c482148ed0a77c4ad52807abeb2d540b56797)) -* **internal:** update comment in script ([b7f1c3e](https://github.com/sst/opencode-sdk-go/commit/b7f1c3e16935c71e243004b8f321d661cd8e9474)) -* lint tests ([616796b](https://github.com/sst/opencode-sdk-go/commit/616796b761704bde6be5c6c2428f28c79c7f05ff)) -* lint tests in subpackages ([50c82ff](https://github.com/sst/opencode-sdk-go/commit/50c82ff0757c973834b68adc22566b70f767b611)) -* sync repo ([2f34d5d](https://github.com/sst/opencode-sdk-go/commit/2f34d5d53e56e9cdc3df99be7ee7efc83dd977a3)) -* update @stainless-api/prism-cli to v5.15.0 ([2f24852](https://github.com/sst/opencode-sdk-go/commit/2f2485216d4f4891d1fbfbc23ff8410c2f35152a)) +- **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d)) +- **internal:** codegen related update ([6a22ce6](https://github.com/sst/opencode-sdk-go/commit/6a22ce6df155f5003e80b8a75686a9e513a5568a)) +- **internal:** fix lint script for tests ([391c482](https://github.com/sst/opencode-sdk-go/commit/391c482148ed0a77c4ad52807abeb2d540b56797)) +- **internal:** update comment in script ([b7f1c3e](https://github.com/sst/opencode-sdk-go/commit/b7f1c3e16935c71e243004b8f321d661cd8e9474)) +- lint tests ([616796b](https://github.com/sst/opencode-sdk-go/commit/616796b761704bde6be5c6c2428f28c79c7f05ff)) +- lint tests in subpackages ([50c82ff](https://github.com/sst/opencode-sdk-go/commit/50c82ff0757c973834b68adc22566b70f767b611)) +- sync repo ([2f34d5d](https://github.com/sst/opencode-sdk-go/commit/2f34d5d53e56e9cdc3df99be7ee7efc83dd977a3)) +- update @stainless-api/prism-cli to v5.15.0 ([2f24852](https://github.com/sst/opencode-sdk-go/commit/2f2485216d4f4891d1fbfbc23ff8410c2f35152a)) diff --git a/packages/sdk/go/release-please-config.json b/packages/sdk/go/release-please-config.json index a38198eca..32960ce27 100644 --- a/packages/sdk/go/release-please-config.json +++ b/packages/sdk/go/release-please-config.json @@ -60,8 +60,5 @@ } ], "release-type": "go", - "extra-files": [ - "internal/version.go", - "README.md" - ] -}
\ No newline at end of file + "extra-files": ["internal/version.go", "README.md"] +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index d4de9ca17..e7760678a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.23", + "version": "1.0.55", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/gen/client.gen.ts b/packages/sdk/js/src/gen/client.gen.ts index 77ef579a6..e7cdb292c 100644 --- a/packages/sdk/js/src/gen/client.gen.ts +++ b/packages/sdk/js/src/gen/client.gen.ts @@ -1,12 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { ClientOptions } from "./types.gen.js" -import { - type Config, - type ClientOptions as DefaultClientOptions, - createClient, - createConfig, -} from "./client/index.js" +import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from "./client/index.js" /** * The `createClientConfig()` function will be called on client initialization diff --git a/packages/sdk/js/src/gen/client/client.gen.ts b/packages/sdk/js/src/gen/client/client.gen.ts index 3a2499efb..34a8d0bec 100644 --- a/packages/sdk/js/src/gen/client/client.gen.ts +++ b/packages/sdk/js/src/gen/client/client.gen.ts @@ -107,9 +107,7 @@ export const createClient = (config: Config = {}): Client => { } const parseAs = - (opts.parseAs === "auto" - ? getParseAs(response.headers.get("Content-Type")) - : opts.parseAs) ?? "json" + (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" let data: any switch (parseAs) { diff --git a/packages/sdk/js/src/gen/client/types.gen.ts b/packages/sdk/js/src/gen/client/types.gen.ts index 1761dacbf..db8e544cf 100644 --- a/packages/sdk/js/src/gen/client/types.gen.ts +++ b/packages/sdk/js/src/gen/client/types.gen.ts @@ -1,10 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Auth } from "../core/auth.gen.js" -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from "../core/serverSentEvents.gen.js" +import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" import type { Middleware } from "./utils.gen.js" @@ -65,11 +62,7 @@ export interface RequestOptions< }>, Pick< ServerSentEventsOptions<TData>, - | "onSseError" - | "onSseEvent" - | "sseDefaultRetryDelay" - | "sseMaxRetryAttempts" - | "sseMaxRetryDelay" + "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" > { /** * Any body that you want to add to your request. @@ -209,10 +202,7 @@ export type Options< ThrowOnError extends boolean = boolean, TResponse = unknown, TResponseStyle extends ResponseStyle = "fields", -> = OmitKeys< - RequestOptions<TResponse, TResponseStyle, ThrowOnError>, - "body" | "path" | "query" | "url" -> & +> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> & Omit<TData, "url"> export type OptionsLegacyParser< @@ -221,8 +211,7 @@ export type OptionsLegacyParser< TResponseStyle extends ResponseStyle = "fields", > = TData extends { body?: any } ? TData extends { headers?: any } - ? OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & - TData + ? OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & TData : OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body" | "url"> & TData & Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "headers"> diff --git a/packages/sdk/js/src/gen/client/utils.gen.ts b/packages/sdk/js/src/gen/client/utils.gen.ts index 53ffbad1b..209bfbe8e 100644 --- a/packages/sdk/js/src/gen/client/utils.gen.ts +++ b/packages/sdk/js/src/gen/client/utils.gen.ts @@ -3,19 +3,11 @@ import { getAuthToken } from "../core/auth.gen.js" import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" import { jsonBodySerializer } from "../core/bodySerializer.gen.js" -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from "../core/pathSerializer.gen.js" +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" import { getUrl } from "../core/utils.gen.js" import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" -export const createQuerySerializer = <T = unknown>({ - allowReserved, - array, - object, -}: QuerySerializerOptions = {}) => { +export const createQuerySerializer = <T = unknown>({ allowReserved, array, object }: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { const search: string[] = [] if (queryParams && typeof queryParams === "object") { @@ -85,9 +77,7 @@ export const getParseAs = (contentType: string | null): Exclude<Config["parseAs" return "formData" } - if ( - ["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type)) - ) { + if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { return "blob" } @@ -107,11 +97,7 @@ const checkForExistence = ( if (!name) { return false } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get("Cookie")?.includes(`${name}=`) - ) { + if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { return true } return false @@ -176,9 +162,7 @@ export const mergeConfigs = (a: Config, b: Config): Config => { return config } -export const mergeHeaders = ( - ...headers: Array<Required<Config>["headers"] | undefined> -): Headers => { +export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => { const mergedHeaders = new Headers() for (const header of headers) { if (!header || typeof header !== "object") { @@ -197,10 +181,7 @@ export const mergeHeaders = ( } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === "object" ? JSON.stringify(value) : (value as string), - ) + mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) } } } @@ -216,11 +197,7 @@ type ErrInterceptor<Err, Res, Req, Options> = ( type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req> -type ResInterceptor<Res, Req, Options> = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise<Res> +type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res> class Interceptors<Interceptor> { _fns: (Interceptor | null)[] diff --git a/packages/sdk/js/src/gen/core/bodySerializer.gen.ts b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts index 83e3f56d7..066061605 100644 --- a/packages/sdk/js/src/gen/core/bodySerializer.gen.ts +++ b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts @@ -31,9 +31,7 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: } export const formDataBodySerializer = { - bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( - body: T, - ): FormData => { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => { const data = new FormData() Object.entries(body).forEach(([key, value]) => { diff --git a/packages/sdk/js/src/gen/core/pathSerializer.gen.ts b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts index f13528357..96be3bc5a 100644 --- a/packages/sdk/js/src/gen/core/pathSerializer.gen.ts +++ b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts @@ -74,9 +74,9 @@ export const serializeArrayParam = ({ value: unknown[] }) => { if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)) + const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( + separatorArrayNoExplode(style), + ) switch (style) { case "label": return `.${joinedValues}` @@ -106,11 +106,7 @@ export const serializeArrayParam = ({ return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues } -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam) => { +export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { if (value === undefined || value === null) { return "" } diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts index 183855da1..8f7fac549 100644 --- a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -60,11 +60,7 @@ export interface StreamEvent<TData = unknown> { } export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = { - stream: AsyncGenerator< - TData extends Record<string, unknown> ? TData[keyof TData] : TData, - TReturn, - TNext - > + stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext> } export const createSseClient = <TData = unknown>({ diff --git a/packages/sdk/js/src/gen/core/types.gen.ts b/packages/sdk/js/src/gen/core/types.gen.ts index b60604fee..16408b2d0 100644 --- a/packages/sdk/js/src/gen/core/types.gen.ts +++ b/packages/sdk/js/src/gen/core/types.gen.ts @@ -1,11 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Auth, AuthToken } from "./auth.gen.js" -import type { - BodySerializer, - QuerySerializer, - QuerySerializerOptions, -} from "./bodySerializer.gen.js" +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> { /** @@ -45,10 +41,7 @@ export interface Config { */ headers?: | RequestInit["headers"] - | Record< - string, - string | number | boolean | (string | number | boolean)[] | null | undefined | unknown - > + | Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown> /** * The request method. * diff --git a/packages/sdk/js/src/gen/core/utils.gen.ts b/packages/sdk/js/src/gen/core/utils.gen.ts index 62e02972c..be18c608a 100644 --- a/packages/sdk/js/src/gen/core/utils.gen.ts +++ b/packages/sdk/js/src/gen/core/utils.gen.ts @@ -73,9 +73,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { continue } - const replaceValue = encodeURIComponent( - style === "label" ? `.${value as string}` : (value as string), - ) + const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) url = url.replace(match, replaceValue) } } diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 1a54da8fa..6987eb471 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -106,6 +106,9 @@ import type { AppAgentsResponses, McpStatusData, McpStatusResponses, + McpAddData, + McpAddResponses, + McpAddErrors, LspStatusData, LspStatusResponses, FormatterStatusData, @@ -145,10 +148,10 @@ import type { } from "./types.gen.js" import { client as _heyApiClient } from "./client.gen.js" -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, -> = ClientOptions<TData, ThrowOnError> & { +export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions< + TData, + ThrowOnError +> & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a @@ -176,9 +179,7 @@ class Project extends _HeyApiClient { /** * List all projects */ - public list<ThrowOnError extends boolean = false>( - options?: Options<ProjectListData, ThrowOnError>, - ) { + public list<ThrowOnError extends boolean = false>(options?: Options<ProjectListData, ThrowOnError>) { return (options?.client ?? this._client).get<ProjectListResponses, unknown, ThrowOnError>({ url: "/project", ...options, @@ -188,9 +189,7 @@ class Project extends _HeyApiClient { /** * Get the current project */ - public current<ThrowOnError extends boolean = false>( - options?: Options<ProjectCurrentData, ThrowOnError>, - ) { + public current<ThrowOnError extends boolean = false>(options?: Options<ProjectCurrentData, ThrowOnError>) { return (options?.client ?? this._client).get<ProjectCurrentResponses, unknown, ThrowOnError>({ url: "/project/current", ...options, @@ -212,14 +211,8 @@ class Config extends _HeyApiClient { /** * Update config */ - public update<ThrowOnError extends boolean = false>( - options?: Options<ConfigUpdateData, ThrowOnError>, - ) { - return (options?.client ?? this._client).patch< - ConfigUpdateResponses, - ConfigUpdateErrors, - ThrowOnError - >({ + public update<ThrowOnError extends boolean = false>(options?: Options<ConfigUpdateData, ThrowOnError>) { + return (options?.client ?? this._client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({ url: "/config", ...options, headers: { @@ -232,9 +225,7 @@ class Config extends _HeyApiClient { /** * List all providers */ - public providers<ThrowOnError extends boolean = false>( - options?: Options<ConfigProvidersData, ThrowOnError>, - ) { + public providers<ThrowOnError extends boolean = false>(options?: Options<ConfigProvidersData, ThrowOnError>) { return (options?.client ?? this._client).get<ConfigProvidersResponses, unknown, ThrowOnError>({ url: "/config/providers", ...options, @@ -280,9 +271,7 @@ class Session extends _HeyApiClient { /** * List all sessions */ - public list<ThrowOnError extends boolean = false>( - options?: Options<SessionListData, ThrowOnError>, - ) { + public list<ThrowOnError extends boolean = false>(options?: Options<SessionListData, ThrowOnError>) { return (options?.client ?? this._client).get<SessionListResponses, unknown, ThrowOnError>({ url: "/session", ...options, @@ -292,14 +281,8 @@ class Session extends _HeyApiClient { /** * Create a new session */ - public create<ThrowOnError extends boolean = false>( - options?: Options<SessionCreateData, ThrowOnError>, - ) { - return (options?.client ?? this._client).post< - SessionCreateResponses, - SessionCreateErrors, - ThrowOnError - >({ + public create<ThrowOnError extends boolean = false>(options?: Options<SessionCreateData, ThrowOnError>) { + return (options?.client ?? this._client).post<SessionCreateResponses, SessionCreateErrors, ThrowOnError>({ url: "/session", ...options, headers: { @@ -312,14 +295,8 @@ class Session extends _HeyApiClient { /** * Delete a session and all its data */ - public delete<ThrowOnError extends boolean = false>( - options: Options<SessionDeleteData, ThrowOnError>, - ) { - return (options.client ?? this._client).delete< - SessionDeleteResponses, - SessionDeleteErrors, - ThrowOnError - >({ + public delete<ThrowOnError extends boolean = false>(options: Options<SessionDeleteData, ThrowOnError>) { + return (options.client ?? this._client).delete<SessionDeleteResponses, SessionDeleteErrors, ThrowOnError>({ url: "/session/{id}", ...options, }) @@ -329,11 +306,7 @@ class Session extends _HeyApiClient { * Get session */ public get<ThrowOnError extends boolean = false>(options: Options<SessionGetData, ThrowOnError>) { - return (options.client ?? this._client).get< - SessionGetResponses, - SessionGetErrors, - ThrowOnError - >({ + return (options.client ?? this._client).get<SessionGetResponses, SessionGetErrors, ThrowOnError>({ url: "/session/{id}", ...options, }) @@ -342,14 +315,8 @@ class Session extends _HeyApiClient { /** * Update session properties */ - public update<ThrowOnError extends boolean = false>( - options: Options<SessionUpdateData, ThrowOnError>, - ) { - return (options.client ?? this._client).patch< - SessionUpdateResponses, - SessionUpdateErrors, - ThrowOnError - >({ + public update<ThrowOnError extends boolean = false>(options: Options<SessionUpdateData, ThrowOnError>) { + return (options.client ?? this._client).patch<SessionUpdateResponses, SessionUpdateErrors, ThrowOnError>({ url: "/session/{id}", ...options, headers: { @@ -362,14 +329,8 @@ class Session extends _HeyApiClient { /** * Get a session's children */ - public children<ThrowOnError extends boolean = false>( - options: Options<SessionChildrenData, ThrowOnError>, - ) { - return (options.client ?? this._client).get< - SessionChildrenResponses, - SessionChildrenErrors, - ThrowOnError - >({ + public children<ThrowOnError extends boolean = false>(options: Options<SessionChildrenData, ThrowOnError>) { + return (options.client ?? this._client).get<SessionChildrenResponses, SessionChildrenErrors, ThrowOnError>({ url: "/session/{id}/children", ...options, }) @@ -378,14 +339,8 @@ class Session extends _HeyApiClient { /** * Get the todo list for a session */ - public todo<ThrowOnError extends boolean = false>( - options: Options<SessionTodoData, ThrowOnError>, - ) { - return (options.client ?? this._client).get< - SessionTodoResponses, - SessionTodoErrors, - ThrowOnError - >({ + public todo<ThrowOnError extends boolean = false>(options: Options<SessionTodoData, ThrowOnError>) { + return (options.client ?? this._client).get<SessionTodoResponses, SessionTodoErrors, ThrowOnError>({ url: "/session/{id}/todo", ...options, }) @@ -394,14 +349,8 @@ class Session extends _HeyApiClient { /** * Analyze the app and create an AGENTS.md file */ - public init<ThrowOnError extends boolean = false>( - options: Options<SessionInitData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionInitResponses, - SessionInitErrors, - ThrowOnError - >({ + public init<ThrowOnError extends boolean = false>(options: Options<SessionInitData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionInitResponses, SessionInitErrors, ThrowOnError>({ url: "/session/{id}/init", ...options, headers: { @@ -414,9 +363,7 @@ class Session extends _HeyApiClient { /** * Fork an existing session at a specific message */ - public fork<ThrowOnError extends boolean = false>( - options: Options<SessionForkData, ThrowOnError>, - ) { + public fork<ThrowOnError extends boolean = false>(options: Options<SessionForkData, ThrowOnError>) { return (options.client ?? this._client).post<SessionForkResponses, unknown, ThrowOnError>({ url: "/session/{id}/fork", ...options, @@ -430,14 +377,8 @@ class Session extends _HeyApiClient { /** * Abort a session */ - public abort<ThrowOnError extends boolean = false>( - options: Options<SessionAbortData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionAbortResponses, - SessionAbortErrors, - ThrowOnError - >({ + public abort<ThrowOnError extends boolean = false>(options: Options<SessionAbortData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionAbortResponses, SessionAbortErrors, ThrowOnError>({ url: "/session/{id}/abort", ...options, }) @@ -446,14 +387,8 @@ class Session extends _HeyApiClient { /** * Unshare the session */ - public unshare<ThrowOnError extends boolean = false>( - options: Options<SessionUnshareData, ThrowOnError>, - ) { - return (options.client ?? this._client).delete< - SessionUnshareResponses, - SessionUnshareErrors, - ThrowOnError - >({ + public unshare<ThrowOnError extends boolean = false>(options: Options<SessionUnshareData, ThrowOnError>) { + return (options.client ?? this._client).delete<SessionUnshareResponses, SessionUnshareErrors, ThrowOnError>({ url: "/session/{id}/share", ...options, }) @@ -462,14 +397,8 @@ class Session extends _HeyApiClient { /** * Share a session */ - public share<ThrowOnError extends boolean = false>( - options: Options<SessionShareData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionShareResponses, - SessionShareErrors, - ThrowOnError - >({ + public share<ThrowOnError extends boolean = false>(options: Options<SessionShareData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionShareResponses, SessionShareErrors, ThrowOnError>({ url: "/session/{id}/share", ...options, }) @@ -478,14 +407,8 @@ class Session extends _HeyApiClient { /** * Get the diff for this session */ - public diff<ThrowOnError extends boolean = false>( - options: Options<SessionDiffData, ThrowOnError>, - ) { - return (options.client ?? this._client).get< - SessionDiffResponses, - SessionDiffErrors, - ThrowOnError - >({ + public diff<ThrowOnError extends boolean = false>(options: Options<SessionDiffData, ThrowOnError>) { + return (options.client ?? this._client).get<SessionDiffResponses, SessionDiffErrors, ThrowOnError>({ url: "/session/{id}/diff", ...options, }) @@ -494,14 +417,8 @@ class Session extends _HeyApiClient { /** * Summarize the session */ - public summarize<ThrowOnError extends boolean = false>( - options: Options<SessionSummarizeData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionSummarizeResponses, - SessionSummarizeErrors, - ThrowOnError - >({ + public summarize<ThrowOnError extends boolean = false>(options: Options<SessionSummarizeData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionSummarizeResponses, SessionSummarizeErrors, ThrowOnError>({ url: "/session/{id}/summarize", ...options, headers: { @@ -514,14 +431,8 @@ class Session extends _HeyApiClient { /** * List messages for a session */ - public messages<ThrowOnError extends boolean = false>( - options: Options<SessionMessagesData, ThrowOnError>, - ) { - return (options.client ?? this._client).get< - SessionMessagesResponses, - SessionMessagesErrors, - ThrowOnError - >({ + public messages<ThrowOnError extends boolean = false>(options: Options<SessionMessagesData, ThrowOnError>) { + return (options.client ?? this._client).get<SessionMessagesResponses, SessionMessagesErrors, ThrowOnError>({ url: "/session/{id}/message", ...options, }) @@ -530,14 +441,8 @@ class Session extends _HeyApiClient { /** * Create and send a new message to a session */ - public prompt<ThrowOnError extends boolean = false>( - options: Options<SessionPromptData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionPromptResponses, - SessionPromptErrors, - ThrowOnError - >({ + public prompt<ThrowOnError extends boolean = false>(options: Options<SessionPromptData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionPromptResponses, SessionPromptErrors, ThrowOnError>({ url: "/session/{id}/message", ...options, headers: { @@ -550,14 +455,8 @@ class Session extends _HeyApiClient { /** * Get a message from a session */ - public message<ThrowOnError extends boolean = false>( - options: Options<SessionMessageData, ThrowOnError>, - ) { - return (options.client ?? this._client).get< - SessionMessageResponses, - SessionMessageErrors, - ThrowOnError - >({ + public message<ThrowOnError extends boolean = false>(options: Options<SessionMessageData, ThrowOnError>) { + return (options.client ?? this._client).get<SessionMessageResponses, SessionMessageErrors, ThrowOnError>({ url: "/session/{id}/message/{messageID}", ...options, }) @@ -566,14 +465,8 @@ class Session extends _HeyApiClient { /** * Send a new command to a session */ - public command<ThrowOnError extends boolean = false>( - options: Options<SessionCommandData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionCommandResponses, - SessionCommandErrors, - ThrowOnError - >({ + public command<ThrowOnError extends boolean = false>(options: Options<SessionCommandData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionCommandResponses, SessionCommandErrors, ThrowOnError>({ url: "/session/{id}/command", ...options, headers: { @@ -586,14 +479,8 @@ class Session extends _HeyApiClient { /** * Run a shell command */ - public shell<ThrowOnError extends boolean = false>( - options: Options<SessionShellData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionShellResponses, - SessionShellErrors, - ThrowOnError - >({ + public shell<ThrowOnError extends boolean = false>(options: Options<SessionShellData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionShellResponses, SessionShellErrors, ThrowOnError>({ url: "/session/{id}/shell", ...options, headers: { @@ -606,14 +493,8 @@ class Session extends _HeyApiClient { /** * Revert a message */ - public revert<ThrowOnError extends boolean = false>( - options: Options<SessionRevertData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionRevertResponses, - SessionRevertErrors, - ThrowOnError - >({ + public revert<ThrowOnError extends boolean = false>(options: Options<SessionRevertData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionRevertResponses, SessionRevertErrors, ThrowOnError>({ url: "/session/{id}/revert", ...options, headers: { @@ -626,14 +507,8 @@ class Session extends _HeyApiClient { /** * Restore all reverted messages */ - public unrevert<ThrowOnError extends boolean = false>( - options: Options<SessionUnrevertData, ThrowOnError>, - ) { - return (options.client ?? this._client).post< - SessionUnrevertResponses, - SessionUnrevertErrors, - ThrowOnError - >({ + public unrevert<ThrowOnError extends boolean = false>(options: Options<SessionUnrevertData, ThrowOnError>) { + return (options.client ?? this._client).post<SessionUnrevertResponses, SessionUnrevertErrors, ThrowOnError>({ url: "/session/{id}/unrevert", ...options, }) @@ -644,9 +519,7 @@ class Command extends _HeyApiClient { /** * List all commands */ - public list<ThrowOnError extends boolean = false>( - options?: Options<CommandListData, ThrowOnError>, - ) { + public list<ThrowOnError extends boolean = false>(options?: Options<CommandListData, ThrowOnError>) { return (options?.client ?? this._client).get<CommandListResponses, unknown, ThrowOnError>({ url: "/command", ...options, @@ -668,9 +541,7 @@ class Find extends _HeyApiClient { /** * Find files */ - public files<ThrowOnError extends boolean = false>( - options: Options<FindFilesData, ThrowOnError>, - ) { + public files<ThrowOnError extends boolean = false>(options: Options<FindFilesData, ThrowOnError>) { return (options.client ?? this._client).get<FindFilesResponses, unknown, ThrowOnError>({ url: "/find/file", ...options, @@ -680,9 +551,7 @@ class Find extends _HeyApiClient { /** * Find workspace symbols */ - public symbols<ThrowOnError extends boolean = false>( - options: Options<FindSymbolsData, ThrowOnError>, - ) { + public symbols<ThrowOnError extends boolean = false>(options: Options<FindSymbolsData, ThrowOnError>) { return (options.client ?? this._client).get<FindSymbolsResponses, unknown, ThrowOnError>({ url: "/find/symbol", ...options, @@ -714,9 +583,7 @@ class File extends _HeyApiClient { /** * Get file status */ - public status<ThrowOnError extends boolean = false>( - options?: Options<FileStatusData, ThrowOnError>, - ) { + public status<ThrowOnError extends boolean = false>(options?: Options<FileStatusData, ThrowOnError>) { return (options?.client ?? this._client).get<FileStatusResponses, unknown, ThrowOnError>({ url: "/file/status", ...options, @@ -742,9 +609,7 @@ class App extends _HeyApiClient { /** * List all agents */ - public agents<ThrowOnError extends boolean = false>( - options?: Options<AppAgentsData, ThrowOnError>, - ) { + public agents<ThrowOnError extends boolean = false>(options?: Options<AppAgentsData, ThrowOnError>) { return (options?.client ?? this._client).get<AppAgentsResponses, unknown, ThrowOnError>({ url: "/agent", ...options, @@ -756,23 +621,33 @@ class Mcp extends _HeyApiClient { /** * Get MCP server status */ - public status<ThrowOnError extends boolean = false>( - options?: Options<McpStatusData, ThrowOnError>, - ) { + public status<ThrowOnError extends boolean = false>(options?: Options<McpStatusData, ThrowOnError>) { return (options?.client ?? this._client).get<McpStatusResponses, unknown, ThrowOnError>({ url: "/mcp", ...options, }) } + + /** + * Add MCP server dynamically + */ + public add<ThrowOnError extends boolean = false>(options?: Options<McpAddData, ThrowOnError>) { + return (options?.client ?? this._client).post<McpAddResponses, McpAddErrors, ThrowOnError>({ + url: "/mcp", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } } class Lsp extends _HeyApiClient { /** * Get LSP server status */ - public status<ThrowOnError extends boolean = false>( - options?: Options<LspStatusData, ThrowOnError>, - ) { + public status<ThrowOnError extends boolean = false>(options?: Options<LspStatusData, ThrowOnError>) { return (options?.client ?? this._client).get<LspStatusResponses, unknown, ThrowOnError>({ url: "/lsp", ...options, @@ -784,9 +659,7 @@ class Formatter extends _HeyApiClient { /** * Get formatter status */ - public status<ThrowOnError extends boolean = false>( - options?: Options<FormatterStatusData, ThrowOnError>, - ) { + public status<ThrowOnError extends boolean = false>(options?: Options<FormatterStatusData, ThrowOnError>) { return (options?.client ?? this._client).get<FormatterStatusResponses, unknown, ThrowOnError>({ url: "/formatter", ...options, @@ -798,9 +671,7 @@ class Control extends _HeyApiClient { /** * Get the next TUI request from the queue */ - public next<ThrowOnError extends boolean = false>( - options?: Options<TuiControlNextData, ThrowOnError>, - ) { + public next<ThrowOnError extends boolean = false>(options?: Options<TuiControlNextData, ThrowOnError>) { return (options?.client ?? this._client).get<TuiControlNextResponses, unknown, ThrowOnError>({ url: "/tui/control/next", ...options, @@ -810,14 +681,8 @@ class Control extends _HeyApiClient { /** * Submit a response to the TUI request queue */ - public response<ThrowOnError extends boolean = false>( - options?: Options<TuiControlResponseData, ThrowOnError>, - ) { - return (options?.client ?? this._client).post< - TuiControlResponseResponses, - unknown, - ThrowOnError - >({ + public response<ThrowOnError extends boolean = false>(options?: Options<TuiControlResponseData, ThrowOnError>) { + return (options?.client ?? this._client).post<TuiControlResponseResponses, unknown, ThrowOnError>({ url: "/tui/control/response", ...options, headers: { @@ -832,14 +697,8 @@ class Tui extends _HeyApiClient { /** * Append prompt to the TUI */ - public appendPrompt<ThrowOnError extends boolean = false>( - options?: Options<TuiAppendPromptData, ThrowOnError>, - ) { - return (options?.client ?? this._client).post< - TuiAppendPromptResponses, - TuiAppendPromptErrors, - ThrowOnError - >({ + public appendPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiAppendPromptData, ThrowOnError>) { + return (options?.client ?? this._client).post<TuiAppendPromptResponses, TuiAppendPromptErrors, ThrowOnError>({ url: "/tui/append-prompt", ...options, headers: { @@ -852,9 +711,7 @@ class Tui extends _HeyApiClient { /** * Open the help dialog */ - public openHelp<ThrowOnError extends boolean = false>( - options?: Options<TuiOpenHelpData, ThrowOnError>, - ) { + public openHelp<ThrowOnError extends boolean = false>(options?: Options<TuiOpenHelpData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({ url: "/tui/open-help", ...options, @@ -864,9 +721,7 @@ class Tui extends _HeyApiClient { /** * Open the session dialog */ - public openSessions<ThrowOnError extends boolean = false>( - options?: Options<TuiOpenSessionsData, ThrowOnError>, - ) { + public openSessions<ThrowOnError extends boolean = false>(options?: Options<TuiOpenSessionsData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({ url: "/tui/open-sessions", ...options, @@ -876,9 +731,7 @@ class Tui extends _HeyApiClient { /** * Open the theme dialog */ - public openThemes<ThrowOnError extends boolean = false>( - options?: Options<TuiOpenThemesData, ThrowOnError>, - ) { + public openThemes<ThrowOnError extends boolean = false>(options?: Options<TuiOpenThemesData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({ url: "/tui/open-themes", ...options, @@ -888,9 +741,7 @@ class Tui extends _HeyApiClient { /** * Open the model dialog */ - public openModels<ThrowOnError extends boolean = false>( - options?: Options<TuiOpenModelsData, ThrowOnError>, - ) { + public openModels<ThrowOnError extends boolean = false>(options?: Options<TuiOpenModelsData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({ url: "/tui/open-models", ...options, @@ -900,9 +751,7 @@ class Tui extends _HeyApiClient { /** * Submit the prompt */ - public submitPrompt<ThrowOnError extends boolean = false>( - options?: Options<TuiSubmitPromptData, ThrowOnError>, - ) { + public submitPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiSubmitPromptData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({ url: "/tui/submit-prompt", ...options, @@ -912,9 +761,7 @@ class Tui extends _HeyApiClient { /** * Clear the prompt */ - public clearPrompt<ThrowOnError extends boolean = false>( - options?: Options<TuiClearPromptData, ThrowOnError>, - ) { + public clearPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiClearPromptData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiClearPromptResponses, unknown, ThrowOnError>({ url: "/tui/clear-prompt", ...options, @@ -924,14 +771,8 @@ class Tui extends _HeyApiClient { /** * Execute a TUI command (e.g. agent_cycle) */ - public executeCommand<ThrowOnError extends boolean = false>( - options?: Options<TuiExecuteCommandData, ThrowOnError>, - ) { - return (options?.client ?? this._client).post< - TuiExecuteCommandResponses, - TuiExecuteCommandErrors, - ThrowOnError - >({ + public executeCommand<ThrowOnError extends boolean = false>(options?: Options<TuiExecuteCommandData, ThrowOnError>) { + return (options?.client ?? this._client).post<TuiExecuteCommandResponses, TuiExecuteCommandErrors, ThrowOnError>({ url: "/tui/execute-command", ...options, headers: { @@ -944,9 +785,7 @@ class Tui extends _HeyApiClient { /** * Show a toast notification in the TUI */ - public showToast<ThrowOnError extends boolean = false>( - options?: Options<TuiShowToastData, ThrowOnError>, - ) { + public showToast<ThrowOnError extends boolean = false>(options?: Options<TuiShowToastData, ThrowOnError>) { return (options?.client ?? this._client).post<TuiShowToastResponses, unknown, ThrowOnError>({ url: "/tui/show-toast", ...options, @@ -960,14 +799,8 @@ class Tui extends _HeyApiClient { /** * Publish a TUI event */ - public publish<ThrowOnError extends boolean = false>( - options?: Options<TuiPublishData, ThrowOnError>, - ) { - return (options?.client ?? this._client).post< - TuiPublishResponses, - TuiPublishErrors, - ThrowOnError - >({ + public publish<ThrowOnError extends boolean = false>(options?: Options<TuiPublishData, ThrowOnError>) { + return (options?.client ?? this._client).post<TuiPublishResponses, TuiPublishErrors, ThrowOnError>({ url: "/tui/publish", ...options, headers: { @@ -999,14 +832,8 @@ class Event extends _HeyApiClient { /** * Get events */ - public subscribe<ThrowOnError extends boolean = false>( - options?: Options<EventSubscribeData, ThrowOnError>, - ) { - return (options?.client ?? this._client).get.sse< - EventSubscribeResponses, - unknown, - ThrowOnError - >({ + public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) { + return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({ url: "/event", ...options, }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 0c0135b4f..19f7a3a77 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -198,6 +198,8 @@ export type AgentConfig = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" } [key: string]: | unknown @@ -216,6 +218,8 @@ export type AgentConfig = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" } | undefined } @@ -463,6 +467,8 @@ export type Config = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" } tools?: { [key: string]: boolean @@ -533,6 +539,7 @@ export type Session = { summary?: { additions: number deletions: number + files: number diffs?: Array<FileDiff> } share?: { @@ -643,13 +650,7 @@ export type AssistantMessage = { created: number completed?: number } - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | ApiError - system: Array<string> + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError parentID: string modelID: string providerID: string @@ -1048,6 +1049,8 @@ export type Agent = { [key: string]: "ask" | "allow" | "deny" } webfetch?: "ask" | "allow" | "deny" + doom_loop?: "ask" | "allow" | "deny" + external_directory?: "ask" | "allow" | "deny" } model?: { modelID: string @@ -1253,14 +1256,6 @@ export type EventFileEdited = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventTodoUpdated = { type: "todo.updated" properties: { @@ -1307,16 +1302,19 @@ export type EventSessionDeleted = { } } +export type EventSessionDiff = { + type: "session.diff" + properties: { + sessionID: string + diff: Array<FileDiff> + } +} + export type EventSessionError = { type: "session.error" properties: { sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | ApiError + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError } } @@ -1327,6 +1325,14 @@ export type EventServerConnected = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type Event = | EventInstallationUpdated | EventLspClientDiagnostics @@ -1339,18 +1345,19 @@ export type Event = | EventPermissionUpdated | EventPermissionReplied | EventFileEdited - | EventFileWatcherUpdated | EventTodoUpdated | EventCommandExecuted | EventSessionIdle | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionDiff | EventSessionError | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventServerConnected + | EventFileWatcherUpdated export type ProjectListData = { body?: never @@ -1972,6 +1979,7 @@ export type SessionMessagesData = { } query?: { directory?: string + limit?: number } url: "/session/{id}/message" } @@ -2542,6 +2550,38 @@ export type McpStatusResponses = { export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + export type LspStatusData = { body?: never path?: never @@ -2834,8 +2874,7 @@ export type TuiControlResponseResponses = { 200: boolean } -export type TuiControlResponseResponse = - TuiControlResponseResponses[keyof TuiControlResponseResponses] +export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] export type AuthSetData = { body?: Auth diff --git a/packages/sdk/js/sst-env.d.ts b/packages/sdk/js/sst-env.d.ts index 9b9de7327..bd5588217 100644 --- a/packages/sdk/js/sst-env.d.ts +++ b/packages/sdk/js/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/sdk/python/README.md b/packages/sdk/python/README.md index a17c36ab3..5d54434af 100644 --- a/packages/sdk/python/README.md +++ b/packages/sdk/python/README.md @@ -2,50 +2,59 @@ This package provides a Python SDK for the Opencode API. It is generated using openapi-python-client (not Stainless). - Documentation + - Full docs: see `mkdocs` site under `packages/sdk/python/docs/` - Preview locally: + ```bash uv run --project packages/sdk/python mkdocs serve -f packages/sdk/python/mkdocs.yml ``` Badges + - PyPI: https://img.shields.io/pypi/v/opencode-ai?style=flat-square Requirements + - Python 3.8+ - uv (recommended) -> https://docs.astral.sh/uv/ - openapi-python-client (invoked via `uvx`) Install uv + ```bash # macOS/Linux curl -LsSf https://astral.sh/uv/install.sh | sh ``` Set up the environment (from this directory) + ```bash uv sync --dev ``` Generate client code (from CLI-generated spec) + ```bash # From repository root OR from this directory uv run python packages/sdk/python/scripts/generate.py --source cli ``` Alternatively, fetch spec from a running server + ```bash uv run python packages/sdk/python/scripts/generate.py --source server --server-url http://localhost:4096/doc ``` This will: -1) Produce an OpenAPI spec from the local CLI or a running server -2) Run openapi-python-client (via `uvx`) to generate client code -3) Copy the generated Python package into src/opencode_ai + +1. Produce an OpenAPI spec from the local CLI or a running server +2. Run openapi-python-client (via `uvx`) to generate client code +3. Copy the generated Python package into src/opencode_ai Usage (after generation) + ```python from opencode_ai import OpenCodeClient @@ -77,6 +86,7 @@ client = OpenCodeClient(retries=2, backoff_factor=0.1) ``` Notes + - We intentionally do not use Stainless for the Python SDK. - The generator targets OpenAPI 3.1 emitted by the opencode server at /doc. - See scripts/generate.py for details and customization points. diff --git a/packages/sdk/python/docs/generation.md b/packages/sdk/python/docs/generation.md index f949760a1..2151e3709 100644 --- a/packages/sdk/python/docs/generation.md +++ b/packages/sdk/python/docs/generation.md @@ -3,10 +3,12 @@ The SDK is generated from the Opencode server's OpenAPI 3.1 spec. Two source modes are supported: + - CLI (default): runs `bun dev generate` to emit the OpenAPI JSON - Server: fetches `http://localhost:4096/doc` from a running server Generator command + ```bash # From repo root uv run --project packages/sdk/python python packages/sdk/python/scripts/generate.py --source cli @@ -15,5 +17,6 @@ uv run --project packages/sdk/python python packages/sdk/python/scripts/generate ``` Post-generation + - The generator injects `extras.py` (OpenCodeClient) and patches `__init__.py` to export it - Code is formatted with `ruff` (imports) and `black` diff --git a/packages/sdk/python/docs/index.md b/packages/sdk/python/docs/index.md index bc7b550f9..e0cfe5d5d 100644 --- a/packages/sdk/python/docs/index.md +++ b/packages/sdk/python/docs/index.md @@ -3,6 +3,7 @@ The official Python client for the Opencode API, generated from the OpenAPI spec and extended with ergonomic helpers. Highlights + - Provider-agnostic client generated from OpenAPI 3.1 - Thin convenience wrapper (OpenCodeClient) for common tasks - Sync and async SSE streaming for live event feeds diff --git a/packages/sdk/python/docs/installation.md b/packages/sdk/python/docs/installation.md index f66e217ae..fe48f5189 100644 --- a/packages/sdk/python/docs/installation.md +++ b/packages/sdk/python/docs/installation.md @@ -1,26 +1,31 @@ # Installation Requirements + - Python 3.8+ - uv (recommended) -> https://docs.astral.sh/uv/ Install uv + ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` Project setup + ```bash # From repo root or this directory uv sync --dev --project packages/sdk/python ``` Using pip (alternative) + ```bash pip install opencode-ai ``` Preview docs locally + ```bash # From repo root uv run --project packages/sdk/python mkdocs serve -f packages/sdk/python/mkdocs.yml diff --git a/packages/sdk/python/docs/publishing.md b/packages/sdk/python/docs/publishing.md index c598baa88..c05a12a33 100644 --- a/packages/sdk/python/docs/publishing.md +++ b/packages/sdk/python/docs/publishing.md @@ -3,6 +3,7 @@ Automated publishing runs on GitHub Releases. Workflow + - Create a new Release (the tag value becomes the package version) - The `publish-python-sdk` workflow will: - Generate the SDK from OpenAPI (CLI path) @@ -10,9 +11,11 @@ Workflow - Build wheel/sdist and upload to PyPI Prerequisites + - Repository secret: `PYPI_API_TOKEN` Manual publish + ```bash # TestPyPI REPOSITORY=testpypi PYPI_TOKEN=$TEST_PYPI_API_TOKEN \ diff --git a/packages/sdk/python/docs/testing.md b/packages/sdk/python/docs/testing.md index 3119035d0..e2c777e31 100644 --- a/packages/sdk/python/docs/testing.md +++ b/packages/sdk/python/docs/testing.md @@ -11,5 +11,6 @@ uv run --project packages/sdk/python pytest -q ``` Notes + - Integration test starts a headless opencode server via Bun in a subprocess - SSE behavior is validated using real streaming from the server diff --git a/packages/sdk/python/mkdocs.yml b/packages/sdk/python/mkdocs.yml index 25de28fb3..565eb7701 100644 --- a/packages/sdk/python/mkdocs.yml +++ b/packages/sdk/python/mkdocs.yml @@ -3,7 +3,7 @@ site_description: Official Python SDK for the Opencode API site_url: https://opencode.ai repo_url: https://github.com/sst/opencode repo_name: sst/opencode -edit_uri: '' +edit_uri: "" theme: name: material features: diff --git a/packages/sdk/python/sst.pyi b/packages/sdk/python/sst.pyi index c64110c5b..b9353f7f7 100644 --- a/packages/sdk/python/sst.pyi +++ b/packages/sdk/python/sst.pyi @@ -25,10 +25,17 @@ class Resource: type: str url: str class AuthStorage: + namespaceId: str type: str class Bucket: name: str type: str + class CLOUDFLARE_API_TOKEN: + type: str + value: str + class CLOUDFLARE_DEFAULT_ACCOUNT_ID: + type: str + value: str class Console: type: str url: str @@ -60,6 +67,9 @@ class Resource: class GOOGLE_CLIENT_ID: type: str value: str + class GatewayKv: + namespaceId: str + type: str class HONEYCOMB_API_KEY: type: str value: str diff --git a/packages/slack/package.json b/packages/slack/package.json index f9392ef37..c4b62da86 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.23", + "version": "1.0.55", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 046f069e2..d07e3dfb4 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -81,7 +81,10 @@ app.message(async ({ message, say }) => { if (createResult.error) { console.error("❌ Failed to create session:", createResult.error) - await say({ text: "Sorry, I had trouble creating a session. Please try again.", thread_ts: thread }) + await say({ + text: "Sorry, I had trouble creating a session. Please try again.", + thread_ts: thread, + }) return } @@ -108,7 +111,10 @@ app.message(async ({ message, say }) => { if (result.error) { console.error("❌ Failed to send message:", result.error) - await say({ text: "Sorry, I had trouble processing your message. Please try again.", thread_ts: thread }) + await say({ + text: "Sorry, I had trouble processing your message. Please try again.", + thread_ts: thread, + }) return } diff --git a/packages/slack/sst-env.d.ts b/packages/slack/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/slack/sst-env.d.ts +++ b/packages/slack/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/ui/package.json b/packages/ui/package.json index 585a249b0..a80f3c2ff 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.23", + "version": "1.0.55", "type": "module", "exports": { ".": "./src/components/index.ts", diff --git a/packages/ui/src/assets/favicon/site.webmanifest b/packages/ui/src/assets/favicon/site.webmanifest index f7522f8f3..41290e840 100644 --- a/packages/ui/src/assets/favicon/site.webmanifest +++ b/packages/ui/src/assets/favicon/site.webmanifest @@ -18,4 +18,4 @@ "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" -}
\ No newline at end of file +} diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx index 535d38e3d..02f00b7be 100644 --- a/packages/ui/src/components/accordion.tsx +++ b/packages/ui/src/components/accordion.tsx @@ -1,9 +1,11 @@ import { Accordion as Kobalte } from "@kobalte/core/accordion" -import { splitProps } from "solid-js" +import { createSignal, splitProps } from "solid-js" import type { ComponentProps, ParentProps } from "solid-js" export interface AccordionProps extends ComponentProps<typeof Kobalte> {} -export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {} +export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> { + defaultOpen?: boolean +} export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Header> {} export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {} export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {} @@ -23,11 +25,14 @@ function AccordionRoot(props: AccordionProps) { } function AccordionItem(props: AccordionItemProps) { - const [split, rest] = splitProps(props, ["class", "classList"]) + const [split, rest] = splitProps(props, ["class", "classList", "defaultOpen"]) + const [open, setOpen] = createSignal(split.defaultOpen ?? false) return ( <Kobalte.Item {...rest} data-slot="accordion-item" + onOpenChange={setOpen} + open={open()} classList={{ ...(split.classList ?? {}), [split.class ?? ""]: !!split.class, diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 88511e011..80ded60fd 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -7,6 +7,7 @@ border-radius: 6px; text-decoration: none; user-select: none; + cursor: default; outline: none; &[data-variant="primary"] { @@ -66,27 +67,26 @@ color: var(--text-strong); &:hover:not(:disabled) { - background-color: var(--surface-hover); + background-color: var(--button-ghost-hover); } &:active:not(:disabled) { - border-color: var(--border-active); - background-color: var(--surface-active); + background-color: var(--button-ghost-hover-2); } &:focus:not(:disabled) { - border-color: var(--border-focus); - background-color: var(--surface-focus); + background-color: var(--button-ghost-hover); } } &[data-size="normal"] { + height: 24px; padding: 0 6px; &[data-icon] { - padding: 0 6px 0 4px; + padding: 0 8px 0 6px; } font-size: var(--font-size-small); line-height: var(--line-height-large); - gap: calc(var(--spacing) * 0.5); + gap: 6px; } &[data-size="large"] { @@ -99,11 +99,12 @@ gap: 8px; + /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 171.429% */ + line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index f1c7efada..06541fe4e 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -11,20 +11,21 @@ export type CodeProps<T = {}> = FileOptions<T> & { export function Code<T>(props: CodeProps<T>) { let container!: HTMLDivElement const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"]) - const file = () => local.file createEffect(() => { const instance = new File<T>({ - theme: { dark: "oc-1-dark", light: "oc-1-light" }, // or any Shiki theme + theme: "OpenCode", overflow: "wrap", // or 'scroll' themeType: "system", // 'system', 'light', or 'dark' + disableFileHeader: true, disableLineNumbers: false, // optional // lang: 'typescript', // optional - auto-detected from filename if not provided ...others, }) + container.innerHTML = "" instance.render({ - file: file(), + file: local.file, lineAnnotations: local.annotations, containerWrapper: container, }) diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx index e6c04f519..a8ab737f7 100644 --- a/packages/ui/src/components/diff-changes.tsx +++ b/packages/ui/src/components/diff-changes.tsx @@ -1,18 +1,21 @@ -import type { FileDiff } from "@opencode-ai/sdk" import { createMemo, For, Match, Show, Switch } from "solid-js" -export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "default" | "bars" }) { +export function DiffChanges(props: { + class?: string + changes: { additions: number; deletions: number } | { additions: number; deletions: number }[] + variant?: "default" | "bars" +}) { const variant = () => props.variant ?? "default" const additions = createMemo(() => - Array.isArray(props.diff) - ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) - : props.diff.additions, + Array.isArray(props.changes) + ? props.changes.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) + : props.changes.additions, ) const deletions = createMemo(() => - Array.isArray(props.diff) - ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) - : props.diff.deletions, + Array.isArray(props.changes) + ? props.changes.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) + : props.changes.deletions, ) const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0)) @@ -90,7 +93,7 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def return ( <Show when={variant() === "default" ? total() > 0 : true}> - <div data-component="diff-changes" data-variant={variant()}> + <div data-component="diff-changes" data-variant={variant()} classList={{ [props.class ?? ""]: true }}> <Switch> <Match when={variant() === "bars"}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none"> diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index f3ca74a88..a53d5b3f3 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -23,13 +23,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & { export function Diff<T>(props: DiffProps<T>) { let container!: HTMLDivElement - const [local, others] = splitProps(props, [ - "before", - "after", - "class", - "classList", - "annotations", - ]) + const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) // const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [ // { @@ -55,8 +49,7 @@ export function Diff<T>(props: DiffProps<T>) { // annotations and a container element to hold the diff createEffect(() => { const instance = new FileDiff<T>({ - // theme: "pierre-light", - theme: { dark: "oc-1-dark", light: "oc-1-light" }, + theme: "OpenCode", // When using the 'themes' prop, 'themeType' allows you to force 'dark' // or 'light' theme, or inherit from the OS ('system') theme. themeType: "system", @@ -154,6 +147,7 @@ export function Diff<T>(props: DiffProps<T>) { ...others, }) + container.innerHTML = "" instance.render({ oldFile: local.before, newFile: local.after, @@ -179,3572 +173,367 @@ export function Diff<T>(props: DiffProps<T>) { ) } -registerCustomTheme("oc-1-light", () => { +registerCustomTheme("OpenCode", () => { return Promise.resolve({ - name: "oc-1-light", - type: "light", + name: "OpenCode", colors: { "editor.background": "transparent", - "editor.foreground": "#070707", - foreground: "#070707", - focusBorder: "#008cff", - "selection.background": "#dfe7ff", - "editor.selectionBackground": "#008cff2e", - "editor.lineHighlightBackground": "#dfe7ff8c", - "editorCursor.foreground": "#008cff", - "editorLineNumber.foreground": "#84848A", - "editorLineNumber.activeForeground": "#6C6C71", - "editorIndentGuide.background": "#eeeeef", - "editorIndentGuide.activeBackground": "#dbdbdd", - "diffEditor.insertedTextBackground": "#00cab133", - "diffEditor.deletedTextBackground": "#ff2e3f33", - "sideBar.background": "#f8f8f8", - "sideBar.foreground": "#6C6C71", - "sideBar.border": "#eeeeef", - "sideBarTitle.foreground": "#070707", - "sideBarSectionHeader.background": "#f8f8f8", - "sideBarSectionHeader.foreground": "#6C6C71", - "sideBarSectionHeader.border": "#eeeeef", - "activityBar.background": "#f8f8f8", - "activityBar.foreground": "#070707", - "activityBar.border": "#eeeeef", - "activityBar.activeBorder": "#008cff", - "activityBarBadge.background": "#008cff", - "activityBarBadge.foreground": "#ffffff", - "titleBar.activeBackground": "#f8f8f8", - "titleBar.activeForeground": "#070707", - "titleBar.inactiveBackground": "#f8f8f8", - "titleBar.inactiveForeground": "#84848A", - "titleBar.border": "#eeeeef", - "list.activeSelectionBackground": "#dfe7ffcc", - "list.activeSelectionForeground": "#070707", - "list.inactiveSelectionBackground": "#dfe7ff73", - "list.hoverBackground": "#dfe7ff59", - "list.focusOutline": "#008cff", - "tab.activeBackground": "#ffffff", - "tab.activeForeground": "#070707", - "tab.activeBorderTop": "#008cff", - "tab.inactiveBackground": "#f8f8f8", - "tab.inactiveForeground": "#84848A", - "tab.border": "#eeeeef", - "editorGroupHeader.tabsBackground": "#f8f8f8", - "editorGroupHeader.tabsBorder": "#eeeeef", - "panel.background": "#f8f8f8", - "panel.border": "#eeeeef", - "panelTitle.activeBorder": "#008cff", - "panelTitle.activeForeground": "#070707", - "panelTitle.inactiveForeground": "#84848A", - "statusBar.background": "#f8f8f8", - "statusBar.foreground": "#6C6C71", - "statusBar.border": "#eeeeef", - "statusBar.noFolderBackground": "#f8f8f8", - "statusBar.debuggingBackground": "#ffca00", - "statusBar.debuggingForeground": "#ffffff", - "statusBarItem.remoteBackground": "#f8f8f8", - "statusBarItem.remoteForeground": "#6C6C71", - "input.background": "#f2f2f3", - "input.border": "#dbdbdd", - "input.foreground": "#070707", - "input.placeholderForeground": "#8E8E95", - "dropdown.background": "#f2f2f3", - "dropdown.border": "#dbdbdd", - "dropdown.foreground": "#070707", - "button.background": "#008cff", - "button.foreground": "#ffffff", - "button.hoverBackground": "#1a98ff", - "textLink.foreground": "#008cff", - "textLink.activeForeground": "#008cff", - "gitDecoration.addedResourceForeground": "#00cab1", - "gitDecoration.conflictingResourceForeground": "#ffca00", - "gitDecoration.modifiedResourceForeground": "#008cff", - "gitDecoration.deletedResourceForeground": "#ff2e3f", - "gitDecoration.untrackedResourceForeground": "#00cab1", - "gitDecoration.ignoredResourceForeground": "#84848A", - "terminal.titleForeground": "#6C6C71", - "terminal.titleInactiveForeground": "#84848A", - "terminal.background": "#f8f8f8", - "terminal.foreground": "#6C6C71", - "terminal.ansiBlack": "#1F1F21", - "terminal.ansiRed": "#ff2e3f", - "terminal.ansiGreen": "#0dbe4e", - "terminal.ansiYellow": "#ffca00", - "terminal.ansiBlue": "#008cff", - "terminal.ansiMagenta": "#c635e4", - "terminal.ansiCyan": "#08c0ef", - "terminal.ansiWhite": "#c6c6c8", - "terminal.ansiBrightBlack": "#1F1F21", - "terminal.ansiBrightRed": "#ff2e3f", - "terminal.ansiBrightGreen": "#0dbe4e", - "terminal.ansiBrightYellow": "#ffca00", - "terminal.ansiBrightBlue": "#008cff", - "terminal.ansiBrightMagenta": "#c635e4", - "terminal.ansiBrightCyan": "#08c0ef", - "terminal.ansiBrightWhite": "#c6c6c8", + "editor.foreground": "var(--text-base)", + "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", + "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + // "gitDecoration.conflictingResourceForeground": "#ffca00", + // "gitDecoration.modifiedResourceForeground": "#1a76d4", + // "gitDecoration.untrackedResourceForeground": "#00cab1", + // "gitDecoration.ignoredResourceForeground": "#84848A", + // "terminal.titleForeground": "#adadb1", + // "terminal.titleInactiveForeground": "#84848A", + // "terminal.background": "#141415", + // "terminal.foreground": "#adadb1", + // "terminal.ansiBlack": "#141415", + // "terminal.ansiRed": "#ff2e3f", + // "terminal.ansiGreen": "#0dbe4e", + // "terminal.ansiYellow": "#ffca00", + // "terminal.ansiBlue": "#008cff", + // "terminal.ansiMagenta": "#c635e4", + // "terminal.ansiCyan": "#08c0ef", + // "terminal.ansiWhite": "#c6c6c8", + // "terminal.ansiBrightBlack": "#141415", + // "terminal.ansiBrightRed": "#ff2e3f", + // "terminal.ansiBrightGreen": "#0dbe4e", + // "terminal.ansiBrightYellow": "#ffca00", + // "terminal.ansiBrightBlue": "#008cff", + // "terminal.ansiBrightMagenta": "#c635e4", + // "terminal.ansiBrightCyan": "#08c0ef", + // "terminal.ansiBrightWhite": "#c6c6c8", }, tokenColors: [ { - scope: ["comment", "punctuation.definition.comment"], + scope: ["comment", "punctuation.definition.comment", "string.comment"], settings: { - foreground: "#84848A", + foreground: "var(--syntax-comment)", }, }, { - scope: "comment markup.link", + scope: ["entity.other.attribute-name"], settings: { - foreground: "#84848A", + foreground: "var(--syntax-property)", // maybe attribute }, }, { - scope: ["string", "constant.other.symbol"], + scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], settings: { - foreground: "#199f43", + foreground: "var(--syntax-constant)", }, }, { - scope: ["punctuation.definition.string.begin", "punctuation.definition.string.end"], + scope: ["entity.name", "meta.export.default", "meta.definition.variable"], settings: { - foreground: "#199f43", - }, - }, - { - scope: ["constant.numeric", "constant.language.boolean"], - settings: { - foreground: "#1ca1c7", - }, - }, - { - scope: "constant", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "punctuation.definition.constant", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "constant.language", - settings: { - foreground: "#1ca1c7", - }, - }, - { - scope: "variable.other.constant", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.control", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["storage", "storage.type", "storage.modifier"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "token.storage", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: [ - "keyword.operator.new", - "keyword.operator.expression.instanceof", - "keyword.operator.expression.typeof", - "keyword.operator.expression.void", - "keyword.operator.expression.delete", - "keyword.operator.expression.in", - "keyword.operator.expression.of", - "keyword.operator.expression.keyof", - ], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.operator.delete", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["variable", "identifier", "meta.definition.variable"], - settings: { - foreground: "#d47628", - }, - }, - { - scope: [ - "variable.other.readwrite", - "meta.object-literal.key", - "support.variable.property", - "support.variable.object.process", - "support.variable.object.node", - ], - settings: { - foreground: "#d47628", - }, - }, - { - scope: "variable.language", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "variable.parameter.function", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "function.parameter", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "variable.parameter", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "variable.parameter.function.language.python", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "variable.parameter.function.python", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "support.function", - "entity.name.function", - "meta.function-call", - "meta.require", - "support.function.any-method", - "variable.function", - ], - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "keyword.other.special-method", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "entity.name.function", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "support.function.console", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: ["support.type", "entity.name.type", "entity.name.class", "storage.type"], - settings: { - foreground: "#c635e4", - }, - }, - { - scope: ["support.class", "entity.name.type.class"], - settings: { - foreground: "#c635e4", - }, - }, - { - scope: ["entity.name.class", "variable.other.class.js", "variable.other.class.ts"], - settings: { - foreground: "#c635e4", - }, - }, - { - scope: "entity.name.class.identifier.namespace.type", - settings: { - foreground: "#c635e4", - }, - }, - { - scope: "entity.name.type.namespace", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.other.inherited-class", - settings: { - foreground: "#c635e4", - }, - }, - { - scope: "entity.name.namespace", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword.operator", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["keyword.operator.logical", "keyword.operator.bitwise", "keyword.operator.channel"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: [ - "keyword.operator.arithmetic", - "keyword.operator.comparison", - "keyword.operator.relational", - "keyword.operator.increment", - "keyword.operator.decrement", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.assignment", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.assignment.compound", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: [ - "keyword.operator.assignment.compound.js", - "keyword.operator.assignment.compound.ts", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.ternary", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.operator.optional", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "punctuation", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.separator.delimiter", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.separator.key-value", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.terminator", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace.square", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace.round", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "function.brace", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["punctuation.definition.parameters", "punctuation.definition.typeparameters"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["punctuation.definition.block", "punctuation.definition.tag"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["meta.tag.tsx", "meta.tag.jsx", "meta.tag.js", "meta.tag.ts"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "keyword.operator.expression.import", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "keyword.operator.module", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "support.type.object.console", - settings: { - foreground: "#d47628", - }, - }, - { - scope: ["support.module.node", "support.type.object.module", "entity.name.type.module"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "support.constant.math", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "support.constant.property.math", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "support.constant.json", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "support.type.object.dom", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["support.variable.dom", "support.variable.property.dom"], - settings: { - foreground: "#d47628", - }, - }, - { - scope: "support.variable.property.process", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "meta.property.object", - settings: { - foreground: "#d47628", - }, - }, - { - scope: "variable.parameter.function.js", - settings: { - foreground: "#d47628", - }, - }, - { - scope: ["keyword.other.template.begin", "keyword.other.template.end"], - settings: { - foreground: "#199f43", - }, - }, - { - scope: ["keyword.other.substitution.begin", "keyword.other.substitution.end"], - settings: { - foreground: "#199f43", - }, - }, - { - scope: [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - ], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "meta.template.expression", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.section.embedded", - settings: { - foreground: "#d47628", - }, - }, - { - scope: "variable.interpolation", - settings: { - foreground: "#d47628", - }, - }, - { - scope: ["punctuation.section.embedded.begin", "punctuation.section.embedded.end"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "punctuation.quasi.element", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: [ - "support.type.primitive.ts", - "support.type.builtin.ts", - "support.type.primitive.tsx", - "support.type.builtin.tsx", - ], - settings: { - foreground: "#c635e4", - }, - }, - { - scope: "support.type.type.flowtype", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "support.type.primitive", - settings: { - foreground: "#c635e4", - }, - }, - { - scope: "support.variable.magic.python", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "variable.parameter.function.language.special.self.python", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "punctuation.separator.period.python", - "punctuation.separator.element.python", - "punctuation.parenthesis.begin.python", - "punctuation.parenthesis.end.python", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "punctuation.definition.arguments.begin.python", - "punctuation.definition.arguments.end.python", - "punctuation.separator.arguments.python", - "punctuation.definition.list.begin.python", - "punctuation.definition.list.end.python", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.type.python", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.logical.python", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "meta.function-call.generic.python", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "constant.character.format.placeholder.other.python", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "meta.function.decorator.python", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: ["support.token.decorator.python", "meta.function.decorator.identifier.python"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "storage.modifier.lifetime.rust", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.function.std.rust", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "entity.name.lifetime.rust", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "variable.language.rust", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "keyword.operator.misc.rust", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "keyword.operator.sigil.rust", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "support.constant.core.rust", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: ["meta.function.c", "meta.function.cpp"], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: [ - "punctuation.section.block.begin.bracket.curly.cpp", - "punctuation.section.block.end.bracket.curly.cpp", - "punctuation.terminator.statement.c", - "punctuation.section.block.begin.bracket.curly.c", - "punctuation.section.block.end.bracket.curly.c", - "punctuation.section.parens.begin.bracket.round.c", - "punctuation.section.parens.end.bracket.round.c", - "punctuation.section.parameters.begin.bracket.round.c", - "punctuation.section.parameters.end.bracket.round.c", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "keyword.operator.assignment.c", - "keyword.operator.comparison.c", - "keyword.operator.c", - "keyword.operator.increment.c", - "keyword.operator.decrement.c", - "keyword.operator.bitwise.shift.c", - ], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: [ - "keyword.operator.assignment.cpp", - "keyword.operator.comparison.cpp", - "keyword.operator.cpp", - "keyword.operator.increment.cpp", - "keyword.operator.decrement.cpp", - "keyword.operator.bitwise.shift.cpp", - ], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["punctuation.separator.c", "punctuation.separator.cpp"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["support.type.posix-reserved.c", "support.type.posix-reserved.cpp"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["keyword.operator.sizeof.c", "keyword.operator.sizeof.cpp"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "variable.c", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["storage.type.annotation.java", "storage.type.object.array.java"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "source.java", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: [ - "punctuation.section.block.begin.java", - "punctuation.section.block.end.java", - "punctuation.definition.method-parameters.begin.java", - "punctuation.definition.method-parameters.end.java", - "meta.method.identifier.java", - "punctuation.section.method.begin.java", - "punctuation.section.method.end.java", - "punctuation.terminator.java", - "punctuation.section.class.begin.java", - "punctuation.section.class.end.java", - "punctuation.section.inner-class.begin.java", - "punctuation.section.inner-class.end.java", - "meta.method-call.java", - "punctuation.section.class.begin.bracket.curly.java", - "punctuation.section.class.end.bracket.curly.java", - "punctuation.section.method.begin.bracket.curly.java", - "punctuation.section.method.end.bracket.curly.java", - "punctuation.separator.period.java", - "punctuation.bracket.angle.java", - "punctuation.definition.annotation.java", - "meta.method.body.java", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.method.java", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: ["storage.modifier.import.java", "storage.type.java", "storage.type.generic.java"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword.operator.instanceof.java", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "meta.definition.variable.name.java", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "token.variable.parameter.java", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "import.storage.java", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "token.package.keyword", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "token.package", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "token.storage.type.java", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword.operator.assignment.go", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: ["keyword.operator.arithmetic.go", "keyword.operator.address.go"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "entity.name.package.go", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "support.other.namespace.use.php", - "support.other.namespace.use-as.php", - "support.other.namespace.php", - "entity.other.alias.php", - "meta.interface.php", - ], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword.operator.error-control.php", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.operator.type.php", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["punctuation.section.array.begin.php", "punctuation.section.array.end.php"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "storage.type.php", - "meta.other.type.phpdoc.php", - "keyword.other.type.php", - "keyword.other.array.phpdoc.php", - ], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "meta.function-call.php", - "meta.function-call.object.php", - "meta.function-call.static.php", - ], - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: [ - "punctuation.definition.parameters.begin.bracket.round.php", - "punctuation.definition.parameters.end.bracket.round.php", - "punctuation.separator.delimiter.php", - "punctuation.section.scope.begin.php", - "punctuation.section.scope.end.php", - "punctuation.terminator.expression.php", - "punctuation.definition.arguments.begin.bracket.round.php", - "punctuation.definition.arguments.end.bracket.round.php", - "punctuation.definition.storage-type.begin.bracket.round.php", - "punctuation.definition.storage-type.end.bracket.round.php", - "punctuation.definition.array.begin.bracket.round.php", - "punctuation.definition.array.end.bracket.round.php", - "punctuation.definition.begin.bracket.round.php", - "punctuation.definition.end.bracket.round.php", - "punctuation.definition.begin.bracket.curly.php", - "punctuation.definition.end.bracket.curly.php", - "punctuation.definition.section.switch-block.end.bracket.curly.php", - "punctuation.definition.section.switch-block.start.bracket.curly.php", - "punctuation.definition.section.switch-block.begin.bracket.curly.php", - "punctuation.definition.section.switch-block.end.bracket.curly.php", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "support.constant.ext.php", - "support.constant.std.php", - "support.constant.core.php", - "support.constant.parser-token.php", - ], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: ["entity.name.goto-label.php", "support.other.php"], - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: [ - "keyword.operator.logical.php", - "keyword.operator.bitwise.php", - "keyword.operator.arithmetic.php", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.regexp.php", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.operator.comparison.php", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["keyword.operator.heredoc.php", "keyword.operator.nowdoc.php"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "variable.other.class.php", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "invalid.illegal.non-null-typehinted.php", - settings: { - foreground: "#f44747", - }, - }, - { - scope: "variable.other.generic-type.haskell", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "storage.type.haskell", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "storage.type.cs", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.name.variable.local.cs", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "entity.name.label.cs", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "entity.name.scope-resolution.function.call", - "entity.name.scope-resolution.function.definition", - ], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "punctuation.definition.delayed.unison", - "punctuation.definition.list.begin.unison", - "punctuation.definition.list.end.unison", - "punctuation.definition.ability.begin.unison", - "punctuation.definition.ability.end.unison", - "punctuation.operator.assignment.as.unison", - "punctuation.separator.pipe.unison", - "punctuation.separator.delimiter.unison", - "punctuation.definition.hash.unison", - ], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "support.constant.edge", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "support.type.prelude.elm", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.constant.elm", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.global.clojure", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "meta.symbol.clojure", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "constant.keyword.clojure", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["meta.arguments.coffee", "variable.parameter.function.coffee"], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "storage.modifier.import.groovy", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "meta.method.groovy", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "meta.definition.variable.name.groovy", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "meta.definition.class.inherited.classes.groovy", - settings: { - foreground: "#199f43", - }, - }, - { - scope: "support.variable.semantic.hlsl", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: [ - "support.type.texture.hlsl", - "support.type.sampler.hlsl", - "support.type.object.hlsl", - "support.type.object.rw.hlsl", - "support.type.fx.hlsl", - "support.type.object.hlsl", - ], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["text.variable", "text.bracketed"], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: ["support.type.swift", "support.type.vb.asp"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "meta.scope.prerequisites.makefile", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "source.makefile", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "source.ini", - settings: { - foreground: "#199f43", - }, - }, - { - scope: "constant.language.symbol.ruby", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["function.parameter.ruby", "function.parameter.cs"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "constant.language.symbol.elixir", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: - "text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: - "text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "entity.name.function.xi", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.name.class.xi", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "constant.character.character-class.regexp.xi", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "constant.regexp.xi", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "keyword.control.xi", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "invalid.xi", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "beginning.punctuation.definition.quote.markdown.xi", - settings: { - foreground: "#199f43", - }, - }, - { - scope: "beginning.punctuation.definition.list.markdown.xi", - settings: { - foreground: "#84848A", - }, - }, - { - scope: "constant.character.xi", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "accent.xi", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "wikiword.xi", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "constant.other.color.rgb-value.xi", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "punctuation.definition.tag.xi", - settings: { - foreground: "#84848A", - }, - }, - { - scope: ["support.constant.property-value.scss", "support.constant.property-value.css"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: ["keyword.operator.css", "keyword.operator.scss", "keyword.operator.less"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: [ - "support.constant.color.w3c-standard-color-name.css", - "support.constant.color.w3c-standard-color-name.scss", - ], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "punctuation.separator.list.comma.css", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.type.vendored.property-name.css", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.type.property-name.css", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.type.property-name", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.constant.property-value", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.constant.font-name", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.other.attribute-name.class.css", - settings: { - foreground: "#16a994", - fontStyle: "normal", - }, - }, - { - scope: "entity.other.attribute-name.id", - settings: { - foreground: "#7b43f8", - fontStyle: "normal", - }, - }, - { - scope: [ - "entity.other.attribute-name.pseudo-element", - "entity.other.attribute-name.pseudo-class", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "meta.selector", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "selector.sass", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "rgb-value", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "inline-color-decoration rgb-value", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "less rgb-value", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "control.elements", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "keyword.operator.less", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "entity.name.tag", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "entity.other.attribute-name", - settings: { - foreground: "#16a994", - fontStyle: "normal", - }, - }, - { - scope: "constant.character.entity", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "meta.tag", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "invalid.illegal.bad-ampersand.html", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "markup.heading", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: ["markup.heading punctuation.definition.heading", "entity.name.section"], - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "entity.name.section.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "punctuation.definition.heading.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "markup.heading.setext", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["markup.heading.setext.1.markdown", "markup.heading.setext.2.markdown"], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: ["markup.bold", "todo.bold"], - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "punctuation.definition.bold", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "punctuation.definition.bold.markdown", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: ["markup.italic", "punctuation.definition.italic", "todo.emphasis"], - settings: { - foreground: "#fc2b73", - fontStyle: "italic", - }, - }, - { - scope: "emphasis md", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "markup.italic.markdown", - settings: { - fontStyle: "italic", - }, - }, - { - scope: ["markup.underline.link.markdown", "markup.underline.link.image.markdown"], - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: ["string.other.link.title.markdown", "string.other.link.description.markdown"], - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "punctuation.definition.metadata.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: ["markup.inline.raw.markdown", "markup.inline.raw.string.markdown"], - settings: { - foreground: "#199f43", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "punctuation.definition.list.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "beginning.punctuation.definition.list.markdown", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: [ - "punctuation.definition.string.begin.markdown", - "punctuation.definition.string.end.markdown", - ], - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "markup.quote.markdown", - settings: { - foreground: "#84848A", - }, - }, - { - scope: "keyword.other.unit", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "markup.changed.diff", - settings: { - foreground: "#d5a910", + foreground: "var(--syntax-type)", }, }, { scope: [ - "meta.diff.header.from-file", - "meta.diff.header.to-file", - "punctuation.definition.from-file.diff", - "punctuation.definition.to-file.diff", + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.object.member", + "meta.embedded.expression", + "meta.template.expression", + "string.other.begin.yaml", + "string.other.end.yaml", ], settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "markup.inserted.diff", - settings: { - foreground: "#199f43", - }, - }, - { - scope: "markup.deleted.diff", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "string.regexp", - settings: { - foreground: "#17a5af", - }, - }, - { - scope: "constant.other.character-class.regexp", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "keyword.operator.quantifier.regexp", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "constant.character.escape", - settings: { - foreground: "#1ca1c7", - }, - }, - { - scope: "source.json meta.structure.dictionary.json > string.quoted.json", - settings: { - foreground: "#d52c36", + foreground: "var(--syntax-punctuation)", }, }, { - scope: - "source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string", + scope: ["entity.name.function", "support.type.primitive"], settings: { - foreground: "#d52c36", + foreground: "var(--syntax-primitive)", }, }, { - scope: [ - "source.json meta.structure.dictionary.json > value.json > string.quoted.json", - "source.json meta.structure.array.json > value.json > string.quoted.json", - "source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation", - "source.json meta.structure.array.json > value.json > string.quoted.json > punctuation", - ], - settings: { - foreground: "#199f43", - }, - }, - { - scope: [ - "source.json meta.structure.dictionary.json > constant.language.json", - "source.json meta.structure.array.json > constant.language.json", - ], + scope: ["support.class.component"], settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.type.property-name.json", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "support.type.property-name.json punctuation", - settings: { - foreground: "#d52c36", - }, - }, - { - scope: "punctuation.definition.block.sequence.item.yaml", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "block.scope.end", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "block.scope.begin", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "token.info-token", - settings: { - foreground: "#7b43f8", - }, - }, - { - scope: "token.warn-token", - settings: { - foreground: "#d5a910", - }, - }, - { - scope: "token.error-token", - settings: { - foreground: "#f44747", - }, - }, - { - scope: "token.debug-token", - settings: { - foreground: "#fc2b73", - }, - }, - { - scope: "invalid.illegal", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.broken", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.deprecated", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.unimplemented", - settings: { - foreground: "#ffffff", - }, - }, - ], - semanticTokenColors: { - comment: "#84848A", - string: "#199f43", - number: "#1ca1c7", - regexp: "#17a5af", - keyword: "#fc2b73", - variable: "#d47628", - parameter: "#79797F", - property: "#d47628", - function: "#7b43f8", - method: "#7b43f8", - type: "#c635e4", - class: "#c635e4", - namespace: "#d5a910", - enumMember: "#08c0ef", - "variable.constant": "#d5a910", - "variable.defaultLibrary": "#d5a910", - }, - } as unknown as ThemeRegistrationResolved) -}) - -registerCustomTheme("oc-1-dark", () => { - return Promise.resolve({ - name: "oc-1-dark", - type: "dark", - colors: { - "editor.background": "transparent", - "editor.foreground": "#fbfbfb", - foreground: "#fbfbfb", - focusBorder: "#1a76d4", - "selection.background": "#19253c", - "editor.selectionBackground": "#1a76d44d", - "editor.lineHighlightBackground": "#19253c8c", - "editorCursor.foreground": "#1a76d4", - "editorLineNumber.foreground": "#84848A", - "editorLineNumber.activeForeground": "#adadb1", - "editorIndentGuide.background": "#39393c", - "editorIndentGuide.activeBackground": "#2e2e30", - "diffEditor.insertedTextBackground": "#00cab11a", - "diffEditor.deletedTextBackground": "#ff2e3f1a", - "sideBar.background": "#141415", - "sideBar.foreground": "#adadb1", - "sideBar.border": "#070707", - "sideBarTitle.foreground": "#fbfbfb", - "sideBarSectionHeader.background": "#141415", - "sideBarSectionHeader.foreground": "#adadb1", - "sideBarSectionHeader.border": "#070707", - "activityBar.background": "#141415", - "activityBar.foreground": "#fbfbfb", - "activityBar.border": "#070707", - "activityBar.activeBorder": "#1a76d4", - "activityBarBadge.background": "#1a76d4", - "activityBarBadge.foreground": "#070707", - "titleBar.activeBackground": "#141415", - "titleBar.activeForeground": "#fbfbfb", - "titleBar.inactiveBackground": "#141415", - "titleBar.inactiveForeground": "#84848A", - "titleBar.border": "#070707", - "list.activeSelectionBackground": "#19253c99", - "list.activeSelectionForeground": "#fbfbfb", - "list.inactiveSelectionBackground": "#19253c73", - "list.hoverBackground": "#19253c59", - "list.focusOutline": "#1a76d4", - "tab.activeBackground": "#070707", - "tab.activeForeground": "#fbfbfb", - "tab.activeBorderTop": "#1a76d4", - "tab.inactiveBackground": "#141415", - "tab.inactiveForeground": "#84848A", - "tab.border": "#070707", - "editorGroupHeader.tabsBackground": "#141415", - "editorGroupHeader.tabsBorder": "#070707", - "panel.background": "#141415", - "panel.border": "#070707", - "panelTitle.activeBorder": "#1a76d4", - "panelTitle.activeForeground": "#fbfbfb", - "panelTitle.inactiveForeground": "#84848A", - "statusBar.background": "#141415", - "statusBar.foreground": "#adadb1", - "statusBar.border": "#070707", - "statusBar.noFolderBackground": "#141415", - "statusBar.debuggingBackground": "#ffca00", - "statusBar.debuggingForeground": "#070707", - "statusBarItem.remoteBackground": "#141415", - "statusBarItem.remoteForeground": "#adadb1", - "input.background": "#1F1F21", - "input.border": "#424245", - "input.foreground": "#fbfbfb", - "input.placeholderForeground": "#79797F", - "dropdown.background": "#1F1F21", - "dropdown.border": "#424245", - "dropdown.foreground": "#fbfbfb", - "button.background": "#1a76d4", - "button.foreground": "#070707", - "button.hoverBackground": "#186bc0", - "textLink.foreground": "#1a76d4", - "textLink.activeForeground": "#1a76d4", - "gitDecoration.addedResourceForeground": "#00cab1", - "gitDecoration.conflictingResourceForeground": "#ffca00", - "gitDecoration.modifiedResourceForeground": "#1a76d4", - "gitDecoration.deletedResourceForeground": "#ff2e3f", - "gitDecoration.untrackedResourceForeground": "#00cab1", - "gitDecoration.ignoredResourceForeground": "#84848A", - "terminal.titleForeground": "#adadb1", - "terminal.titleInactiveForeground": "#84848A", - "terminal.background": "#141415", - "terminal.foreground": "#adadb1", - "terminal.ansiBlack": "#141415", - "terminal.ansiRed": "#ff2e3f", - "terminal.ansiGreen": "#0dbe4e", - "terminal.ansiYellow": "#ffca00", - "terminal.ansiBlue": "#008cff", - "terminal.ansiMagenta": "#c635e4", - "terminal.ansiCyan": "#08c0ef", - "terminal.ansiWhite": "#c6c6c8", - "terminal.ansiBrightBlack": "#141415", - "terminal.ansiBrightRed": "#ff2e3f", - "terminal.ansiBrightGreen": "#0dbe4e", - "terminal.ansiBrightYellow": "#ffca00", - "terminal.ansiBrightBlue": "#008cff", - "terminal.ansiBrightMagenta": "#c635e4", - "terminal.ansiBrightCyan": "#08c0ef", - "terminal.ansiBrightWhite": "#c6c6c8", - }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment"], - settings: { - foreground: "#84848A", - }, - }, - { - scope: "comment markup.link", - settings: { - foreground: "#84848A", - }, - }, - { - scope: ["string", "constant.other.symbol"], - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: ["punctuation.definition.string.begin", "punctuation.definition.string.end"], - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: ["constant.numeric", "constant.language.boolean"], - settings: { - foreground: "#68cdf2", - }, - }, - { - scope: "constant", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "punctuation.definition.constant", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "constant.language", - settings: { - foreground: "#68cdf2", - }, - }, - { - scope: "variable.other.constant", - settings: { - foreground: "#ffca00", + foreground: "var(--syntax-type)", }, }, { scope: "keyword", settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.control", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["storage", "storage.type", "storage.modifier"], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "token.storage", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: [ - "keyword.operator.new", - "keyword.operator.expression.instanceof", - "keyword.operator.expression.typeof", - "keyword.operator.expression.void", - "keyword.operator.expression.delete", - "keyword.operator.expression.in", - "keyword.operator.expression.of", - "keyword.operator.expression.keyof", - ], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.operator.delete", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["variable", "identifier", "meta.definition.variable"], - settings: { - foreground: "#ffa359", - }, - }, - { - scope: [ - "variable.other.readwrite", - "meta.object-literal.key", - "support.variable.property", - "support.variable.object.process", - "support.variable.object.node", - ], - settings: { - foreground: "#ffa359", - }, - }, - { - scope: "variable.language", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "variable.parameter.function", - settings: { - foreground: "#adadb1", - }, - }, - { - scope: "function.parameter", - settings: { - foreground: "#adadb1", - }, - }, - { - scope: "variable.parameter", - settings: { - foreground: "#adadb1", - }, - }, - { - scope: "variable.parameter.function.language.python", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "variable.parameter.function.python", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: [ - "support.function", - "entity.name.function", - "meta.function-call", - "meta.require", - "support.function.any-method", - "variable.function", - ], - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "keyword.other.special-method", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "entity.name.function", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "support.function.console", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: ["support.type", "entity.name.type", "entity.name.class", "storage.type"], - settings: { - foreground: "#d568ea", - }, - }, - { - scope: ["support.class", "entity.name.type.class"], - settings: { - foreground: "#d568ea", - }, - }, - { - scope: ["entity.name.class", "variable.other.class.js", "variable.other.class.ts"], - settings: { - foreground: "#d568ea", - }, - }, - { - scope: "entity.name.class.identifier.namespace.type", - settings: { - foreground: "#d568ea", - }, - }, - { - scope: "entity.name.type.namespace", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "entity.other.inherited-class", - settings: { - foreground: "#d568ea", - }, - }, - { - scope: "entity.name.namespace", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "keyword.operator", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["keyword.operator.logical", "keyword.operator.bitwise", "keyword.operator.channel"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: [ - "keyword.operator.arithmetic", - "keyword.operator.comparison", - "keyword.operator.relational", - "keyword.operator.increment", - "keyword.operator.decrement", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.assignment", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.assignment.compound", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: [ - "keyword.operator.assignment.compound.js", - "keyword.operator.assignment.compound.ts", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.ternary", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.operator.optional", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "punctuation", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.separator.delimiter", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.separator.key-value", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.terminator", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace.square", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.brace.round", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "function.brace", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["punctuation.definition.parameters", "punctuation.definition.typeparameters"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["punctuation.definition.block", "punctuation.definition.tag"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["meta.tag.tsx", "meta.tag.jsx", "meta.tag.js", "meta.tag.ts"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "keyword.operator.expression.import", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "keyword.operator.module", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "support.type.object.console", - settings: { - foreground: "#ffa359", - }, - }, - { - scope: ["support.module.node", "support.type.object.module", "entity.name.type.module"], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "support.constant.math", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "support.constant.property.math", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "support.constant.json", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "support.type.object.dom", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["support.variable.dom", "support.variable.property.dom"], - settings: { - foreground: "#ffa359", - }, - }, - { - scope: "support.variable.property.process", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "meta.property.object", - settings: { - foreground: "#ffa359", - }, - }, - { - scope: "variable.parameter.function.js", - settings: { - foreground: "#ffa359", - }, - }, - { - scope: ["keyword.other.template.begin", "keyword.other.template.end"], - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: ["keyword.other.substitution.begin", "keyword.other.substitution.end"], - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - ], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "meta.template.expression", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "punctuation.section.embedded", - settings: { - foreground: "#ffa359", - }, - }, - { - scope: "variable.interpolation", - settings: { - foreground: "#ffa359", - }, - }, - { - scope: ["punctuation.section.embedded.begin", "punctuation.section.embedded.end"], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "punctuation.quasi.element", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: [ - "support.type.primitive.ts", - "support.type.builtin.ts", - "support.type.primitive.tsx", - "support.type.builtin.tsx", - ], - settings: { - foreground: "#d568ea", - }, - }, - { - scope: "support.type.type.flowtype", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "support.type.primitive", - settings: { - foreground: "#d568ea", - }, - }, - { - scope: "support.variable.magic.python", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "variable.parameter.function.language.special.self.python", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "punctuation.separator.period.python", - "punctuation.separator.element.python", - "punctuation.parenthesis.begin.python", - "punctuation.parenthesis.end.python", - ], - settings: { - foreground: "#79797F", + foreground: "var(--syntax-keyword)", }, }, { scope: [ - "punctuation.definition.arguments.begin.python", - "punctuation.definition.arguments.end.python", - "punctuation.separator.arguments.python", - "punctuation.definition.list.begin.python", - "punctuation.definition.list.end.python", + "keyword.operator", + "storage.type.function.arrow", + "punctuation.separator.key-value.css", + "entity.name.tag.yaml", + "punctuation.separator.key-value.mapping.yaml", ], settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.type.python", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.logical.python", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "meta.function-call.generic.python", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "constant.character.format.placeholder.other.python", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "meta.function.decorator.python", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: ["support.token.decorator.python", "meta.function.decorator.identifier.python"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "storage.modifier.lifetime.rust", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.function.std.rust", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "entity.name.lifetime.rust", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "variable.language.rust", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "keyword.operator.misc.rust", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "keyword.operator.sigil.rust", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "support.constant.core.rust", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: ["meta.function.c", "meta.function.cpp"], - settings: { - foreground: "#ff6762", + foreground: "var(--syntax-operator)", }, }, { - scope: [ - "punctuation.section.block.begin.bracket.curly.cpp", - "punctuation.section.block.end.bracket.curly.cpp", - "punctuation.terminator.statement.c", - "punctuation.section.block.begin.bracket.curly.c", - "punctuation.section.block.end.bracket.curly.c", - "punctuation.section.parens.begin.bracket.round.c", - "punctuation.section.parens.end.bracket.round.c", - "punctuation.section.parameters.begin.bracket.round.c", - "punctuation.section.parameters.end.bracket.round.c", - ], + scope: ["storage", "storage.type"], settings: { - foreground: "#79797F", + foreground: "var(--syntax-keyword)", }, }, { - scope: [ - "keyword.operator.assignment.c", - "keyword.operator.comparison.c", - "keyword.operator.c", - "keyword.operator.increment.c", - "keyword.operator.decrement.c", - "keyword.operator.bitwise.shift.c", - ], + scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], settings: { - foreground: "#ff678d", + foreground: "var(--syntax-primitive)", }, }, { scope: [ - "keyword.operator.assignment.cpp", - "keyword.operator.comparison.cpp", - "keyword.operator.cpp", - "keyword.operator.increment.cpp", - "keyword.operator.decrement.cpp", - "keyword.operator.bitwise.shift.cpp", + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source", + "entity.name.tag", ], settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["punctuation.separator.c", "punctuation.separator.cpp"], - settings: { - foreground: "#ff678d", + foreground: "var(--syntax-string)", }, }, { - scope: ["support.type.posix-reserved.c", "support.type.posix-reserved.cpp"], + scope: "support", settings: { - foreground: "#08c0ef", + foreground: "var(--syntax-primitive)", }, }, { - scope: ["keyword.operator.sizeof.c", "keyword.operator.sizeof.cpp"], + scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], settings: { - foreground: "#ff678d", + foreground: "var(--syntax-object)", }, }, { - scope: "variable.c", + scope: "meta.property-name", settings: { - foreground: "#79797F", + foreground: "var(--syntax-property)", }, }, { - scope: ["storage.type.annotation.java", "storage.type.object.array.java"], + scope: "variable", settings: { - foreground: "#ffca00", + foreground: "var(--syntax-variable)", }, }, { - scope: "source.java", + scope: "variable.other", settings: { - foreground: "#ff6762", + foreground: "var(--syntax-variable)", }, }, { scope: [ - "punctuation.section.block.begin.java", - "punctuation.section.block.end.java", - "punctuation.definition.method-parameters.begin.java", - "punctuation.definition.method-parameters.end.java", - "meta.method.identifier.java", - "punctuation.section.method.begin.java", - "punctuation.section.method.end.java", - "punctuation.terminator.java", - "punctuation.section.class.begin.java", - "punctuation.section.class.end.java", - "punctuation.section.inner-class.begin.java", - "punctuation.section.inner-class.end.java", - "meta.method-call.java", - "punctuation.section.class.begin.bracket.curly.java", - "punctuation.section.class.end.bracket.curly.java", - "punctuation.section.method.begin.bracket.curly.java", - "punctuation.section.method.end.bracket.curly.java", - "punctuation.separator.period.java", - "punctuation.bracket.angle.java", - "punctuation.definition.annotation.java", - "meta.method.body.java", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "meta.method.java", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: ["storage.modifier.import.java", "storage.type.java", "storage.type.generic.java"], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "keyword.operator.instanceof.java", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "meta.definition.variable.name.java", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "token.variable.parameter.java", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "import.storage.java", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "token.package.keyword", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "token.package", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "token.storage.type.java", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "keyword.operator.assignment.go", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: ["keyword.operator.arithmetic.go", "keyword.operator.address.go"], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "entity.name.package.go", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "support.other.namespace.use.php", - "support.other.namespace.use-as.php", - "support.other.namespace.php", - "entity.other.alias.php", - "meta.interface.php", - ], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "keyword.operator.error-control.php", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.operator.type.php", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["punctuation.section.array.begin.php", "punctuation.section.array.end.php"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "storage.type.php", - "meta.other.type.phpdoc.php", - "keyword.other.type.php", - "keyword.other.array.phpdoc.php", - ], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "meta.function-call.php", - "meta.function-call.object.php", - "meta.function-call.static.php", - ], - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: [ - "punctuation.definition.parameters.begin.bracket.round.php", - "punctuation.definition.parameters.end.bracket.round.php", - "punctuation.separator.delimiter.php", - "punctuation.section.scope.begin.php", - "punctuation.section.scope.end.php", - "punctuation.terminator.expression.php", - "punctuation.definition.arguments.begin.bracket.round.php", - "punctuation.definition.arguments.end.bracket.round.php", - "punctuation.definition.storage-type.begin.bracket.round.php", - "punctuation.definition.storage-type.end.bracket.round.php", - "punctuation.definition.array.begin.bracket.round.php", - "punctuation.definition.array.end.bracket.round.php", - "punctuation.definition.begin.bracket.round.php", - "punctuation.definition.end.bracket.round.php", - "punctuation.definition.begin.bracket.curly.php", - "punctuation.definition.end.bracket.curly.php", - "punctuation.definition.section.switch-block.end.bracket.curly.php", - "punctuation.definition.section.switch-block.start.bracket.curly.php", - "punctuation.definition.section.switch-block.begin.bracket.curly.php", - "punctuation.definition.section.switch-block.end.bracket.curly.php", - ], - settings: { - foreground: "#79797F", - }, - }, - { - scope: [ - "support.constant.ext.php", - "support.constant.std.php", - "support.constant.core.php", - "support.constant.parser-token.php", - ], - settings: { - foreground: "#ffd452", - }, - }, - { - scope: ["entity.name.goto-label.php", "support.other.php"], - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: [ - "keyword.operator.logical.php", - "keyword.operator.bitwise.php", - "keyword.operator.arithmetic.php", - ], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "keyword.operator.regexp.php", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.operator.comparison.php", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["keyword.operator.heredoc.php", "keyword.operator.nowdoc.php"], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "variable.other.class.php", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "invalid.illegal.non-null-typehinted.php", - settings: { - foreground: "#f44747", - }, - }, - { - scope: "variable.other.generic-type.haskell", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "storage.type.haskell", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "storage.type.cs", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "entity.name.variable.local.cs", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "entity.name.label.cs", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "entity.name.scope-resolution.function.call", - "entity.name.scope-resolution.function.definition", - ], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "punctuation.definition.delayed.unison", - "punctuation.definition.list.begin.unison", - "punctuation.definition.list.end.unison", - "punctuation.definition.ability.begin.unison", - "punctuation.definition.ability.end.unison", - "punctuation.operator.assignment.as.unison", - "punctuation.separator.pipe.unison", - "punctuation.separator.delimiter.unison", - "punctuation.definition.hash.unison", - ], - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "support.constant.edge", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "support.type.prelude.elm", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.constant.elm", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "entity.global.clojure", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "meta.symbol.clojure", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "constant.keyword.clojure", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["meta.arguments.coffee", "variable.parameter.function.coffee"], - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "storage.modifier.import.groovy", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "meta.method.groovy", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "meta.definition.variable.name.groovy", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "meta.definition.class.inherited.classes.groovy", - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: "support.variable.semantic.hlsl", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: [ - "support.type.texture.hlsl", - "support.type.sampler.hlsl", - "support.type.object.hlsl", - "support.type.object.rw.hlsl", - "support.type.fx.hlsl", - "support.type.object.hlsl", - ], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["text.variable", "text.bracketed"], - settings: { - foreground: "#ff6762", - }, - }, - { - scope: ["support.type.swift", "support.type.vb.asp"], - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "meta.scope.prerequisites.makefile", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "source.makefile", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "source.ini", - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: "constant.language.symbol.ruby", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: ["function.parameter.ruby", "function.parameter.cs"], - settings: { - foreground: "#79797F", - }, - }, - { - scope: "constant.language.symbol.elixir", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: - "text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: - "text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "entity.name.function.xi", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "entity.name.class.xi", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "constant.character.character-class.regexp.xi", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "constant.regexp.xi", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "keyword.control.xi", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "invalid.xi", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "beginning.punctuation.definition.quote.markdown.xi", - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: "beginning.punctuation.definition.list.markdown.xi", - settings: { - foreground: "#84848A", - }, - }, - { - scope: "constant.character.xi", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "accent.xi", - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "wikiword.xi", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "constant.other.color.rgb-value.xi", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "punctuation.definition.tag.xi", - settings: { - foreground: "#84848A", - }, - }, - { - scope: ["support.constant.property-value.scss", "support.constant.property-value.css"], - settings: { - foreground: "#ffd452", - }, - }, - { - scope: ["keyword.operator.css", "keyword.operator.scss", "keyword.operator.less"], - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: [ - "support.constant.color.w3c-standard-color-name.css", - "support.constant.color.w3c-standard-color-name.scss", + "invalid.broken", + "invalid.illegal", + "invalid.unimplemented", + "invalid.deprecated", + "message.error", + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted", + "brackethighlighter.unmatched", + "token.error-token", ], settings: { - foreground: "#ffd452", - }, - }, - { - scope: "punctuation.separator.list.comma.css", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "support.type.vendored.property-name.css", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.type.property-name.css", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "support.type.property-name", - settings: { - foreground: "#79797F", + foreground: "var(--syntax-critical)", }, }, { - scope: "support.constant.property-value", + scope: "carriage-return", settings: { - foreground: "#79797F", + foreground: "var(--syntax-keyword)", }, }, { - scope: "support.constant.font-name", + scope: "string source", settings: { - foreground: "#ffd452", + foreground: "var(--syntax-variable)", }, }, { - scope: "entity.other.attribute-name.class.css", + scope: "string variable", settings: { - foreground: "#61d5c0", - fontStyle: "normal", - }, - }, - { - scope: "entity.other.attribute-name.id", - settings: { - foreground: "#9d6afb", - fontStyle: "normal", + foreground: "var(--syntax-constant)", }, }, { scope: [ - "entity.other.attribute-name.pseudo-element", - "entity.other.attribute-name.pseudo-class", + "source.regexp", + "string.regexp", + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition", + "string.regexp constant.character.escape", ], settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "meta.selector", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "selector.sass", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "rgb-value", - settings: { - foreground: "#08c0ef", - }, - }, - { - scope: "inline-color-decoration rgb-value", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "less rgb-value", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "control.elements", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "keyword.operator.less", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "entity.name.tag", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "entity.other.attribute-name", - settings: { - foreground: "#61d5c0", - fontStyle: "normal", - }, - }, - { - scope: "constant.character.entity", - settings: { - foreground: "#ff6762", + foreground: "var(--syntax-regexp)", }, }, { - scope: "meta.tag", + scope: "support.constant", settings: { - foreground: "#79797F", + foreground: "var(--syntax-primitive)", }, }, { - scope: "invalid.illegal.bad-ampersand.html", + scope: "support.variable", settings: { - foreground: "#79797F", + foreground: "var(--syntax-variable)", }, }, { - scope: "markup.heading", + scope: "meta.module-reference", settings: { - foreground: "#ff6762", - }, - }, - { - scope: ["markup.heading punctuation.definition.heading", "entity.name.section"], - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "entity.name.section.markdown", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "punctuation.definition.heading.markdown", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "markup.heading.setext", - settings: { - foreground: "#79797F", - }, - }, - { - scope: ["markup.heading.setext.1.markdown", "markup.heading.setext.2.markdown"], - settings: { - foreground: "#ff6762", - }, - }, - { - scope: ["markup.bold", "todo.bold"], - settings: { - foreground: "#ffd452", - }, - }, - { - scope: "punctuation.definition.bold", - settings: { - foreground: "#ffca00", - }, - }, - { - scope: "punctuation.definition.bold.markdown", - settings: { - foreground: "#ffd452", - }, - }, - { - scope: ["markup.italic", "punctuation.definition.italic", "todo.emphasis"], - settings: { - foreground: "#ff678d", - fontStyle: "italic", - }, - }, - { - scope: "emphasis md", - settings: { - foreground: "#ff678d", - }, - }, - { - scope: "markup.italic.markdown", - settings: { - fontStyle: "italic", - }, - }, - { - scope: ["markup.underline.link.markdown", "markup.underline.link.image.markdown"], - settings: { - foreground: "#ff678d", - }, - }, - { - scope: ["string.other.link.title.markdown", "string.other.link.description.markdown"], - settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "punctuation.definition.metadata.markdown", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: ["markup.inline.raw.markdown", "markup.inline.raw.string.markdown"], - settings: { - foreground: "#5ecc71", + foreground: "var(--syntax-info)", }, }, { scope: "punctuation.definition.list.begin.markdown", settings: { - foreground: "#ff6762", + foreground: "var(--syntax-punctuation)", }, }, { - scope: "punctuation.definition.list.markdown", + scope: ["markup.heading", "markup.heading entity.name"], settings: { - foreground: "#ff6762", + fontStyle: "bold", + foreground: "var(--syntax-info)", }, }, { - scope: "beginning.punctuation.definition.list.markdown", + scope: "markup.quote", settings: { - foreground: "#ff6762", + foreground: "var(--syntax-info)", }, }, { - scope: [ - "punctuation.definition.string.begin.markdown", - "punctuation.definition.string.end.markdown", - ], + scope: "markup.italic", settings: { - foreground: "#ff6762", - }, - }, - { - scope: "markup.quote.markdown", - settings: { - foreground: "#84848A", - }, - }, - { - scope: "keyword.other.unit", - settings: { - foreground: "#ff6762", + fontStyle: "italic", + // foreground: "", }, }, { - scope: "markup.changed.diff", + scope: "markup.bold", settings: { - foreground: "#ffca00", + fontStyle: "bold", + foreground: "var(--text-strong)", }, }, { scope: [ - "meta.diff.header.from-file", + "markup.raw", + "markup.inserted", "meta.diff.header.to-file", - "punctuation.definition.from-file.diff", - "punctuation.definition.to-file.diff", + "punctuation.definition.inserted", + "markup.changed", + "punctuation.definition.changed", + "markup.ignored", + "markup.untracked", ], settings: { - foreground: "#9d6afb", - }, - }, - { - scope: "markup.inserted.diff", - settings: { - foreground: "#5ecc71", - }, - }, - { - scope: "markup.deleted.diff", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "string.regexp", - settings: { - foreground: "#64d1db", - }, - }, - { - scope: "constant.other.character-class.regexp", - settings: { - foreground: "#ff6762", + foreground: "var(--text-base)", }, }, { - scope: "keyword.operator.quantifier.regexp", + scope: "meta.diff.range", settings: { - foreground: "#ffd452", + fontStyle: "bold", + foreground: "var(--syntax-unknown)", }, }, { - scope: "constant.character.escape", + scope: "meta.diff.header", settings: { - foreground: "#68cdf2", + foreground: "var(--syntax-unknown)", }, }, { - scope: "source.json meta.structure.dictionary.json > string.quoted.json", + scope: "meta.separator", settings: { - foreground: "#ff6762", + fontStyle: "bold", + foreground: "var(--syntax-unknown)", }, }, { - scope: - "source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string", + scope: "meta.output", settings: { - foreground: "#ff6762", + foreground: "var(--syntax-unknown)", }, }, { - scope: [ - "source.json meta.structure.dictionary.json > value.json > string.quoted.json", - "source.json meta.structure.array.json > value.json > string.quoted.json", - "source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation", - "source.json meta.structure.array.json > value.json > string.quoted.json > punctuation", - ], + scope: "meta.export.default", settings: { - foreground: "#5ecc71", + foreground: "var(--syntax-unknown)", }, }, { scope: [ - "source.json meta.structure.dictionary.json > constant.language.json", - "source.json meta.structure.array.json > constant.language.json", + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote", ], settings: { - foreground: "#08c0ef", + foreground: "var(--syntax-unknown)", }, }, { - scope: "support.type.property-name.json", + scope: ["constant.other.reference.link", "string.other.link"], settings: { - foreground: "#ff6762", - }, - }, - { - scope: "support.type.property-name.json punctuation", - settings: { - foreground: "#ff6762", - }, - }, - { - scope: "punctuation.definition.block.sequence.item.yaml", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "block.scope.end", - settings: { - foreground: "#79797F", - }, - }, - { - scope: "block.scope.begin", - settings: { - foreground: "#79797F", + fontStyle: "underline", + foreground: "var(--syntax-unknown)", }, }, { scope: "token.info-token", settings: { - foreground: "#9d6afb", + foreground: "var(--syntax-info)", }, }, { scope: "token.warn-token", settings: { - foreground: "#ffd452", - }, - }, - { - scope: "token.error-token", - settings: { - foreground: "#f44747", + foreground: "var(--syntax-warning)", }, }, { scope: "token.debug-token", settings: { - foreground: "#ff678d", - }, - }, - { - scope: "invalid.illegal", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.broken", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.deprecated", - settings: { - foreground: "#ffffff", - }, - }, - { - scope: "invalid.unimplemented", - settings: { - foreground: "#ffffff", + foreground: "var(--syntax-info)", }, }, ], semanticTokenColors: { - comment: "#84848A", - string: "#5ecc71", - number: "#68cdf2", - regexp: "#64d1db", - keyword: "#ff678d", - variable: "#ffa359", - parameter: "#adadb1", - property: "#ffa359", - function: "#9d6afb", - method: "#9d6afb", - type: "#d568ea", - class: "#d568ea", - namespace: "#ffca00", - enumMember: "#08c0ef", - "variable.constant": "#ffd452", - "variable.defaultLibrary": "#ffca00", + comment: "var(--syntax-comment)", + string: "var(--syntax-string)", + number: "var(--syntax-constant)", + regexp: "var(--syntax-regexp)", + keyword: "var(--syntax-keyword)", + variable: "var(--syntax-variable)", + parameter: "var(--syntax-variable)", + property: "var(--syntax-property)", + function: "var(--syntax-primitive)", + method: "var(--syntax-primitive)", + type: "var(--syntax-type)", + class: "var(--syntax-type)", + namespace: "var(--syntax-type)", + enumMember: "var(--syntax-primitive)", + "variable.constant": "var(--syntax-constant)", + "variable.defaultLibrary": "var(--syntax-unknown)", }, } as unknown as ThemeRegistrationResolved) }) diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css index 6fe95fccf..a491074fe 100644 --- a/packages/ui/src/components/icon-button.css +++ b/packages/ui/src/components/icon-button.css @@ -2,20 +2,11 @@ display: inline-flex; align-items: center; justify-content: center; - border-radius: 100%; + border-radius: 6px; text-decoration: none; user-select: none; aspect-ratio: 1; - - &:disabled { - background-color: var(--icon-strong-disabled); - color: var(--icon-invert-base); - cursor: not-allowed; - } - - &:focus { - outline: none; - } + flex-shrink: 0; &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -51,45 +42,62 @@ } &[data-variant="secondary"] { + border: transparent; background-color: var(--button-secondary-base); color: var(--text-strong); + box-shadow: var(--shadow-xs-border); &:hover:not(:disabled) { - background-color: var(--surface-hover); + background-color: var(--button-secondary-hover); } &:active:not(:disabled) { - background-color: var(--surface-active); + background-color: var(--button-secondary-base); } &:focus:not(:disabled) { - background-color: var(--surface-focus); + background-color: var(--button-secondary-base); + } + &:focus-visible:not(:active) { + background-color: var(--button-secondary-base); + box-shadow: var(--shadow-xs-border-focus); + } + &:focus-visible:active { + box-shadow: none; + } + + [data-slot="icon"] { + color: var(--icon-strong-base); } } &[data-variant="ghost"] { background-color: transparent; + /* color: var(--icon-base); */ [data-slot="icon"] { - color: var(--icon-weak-base); + color: var(--icon-base); + } - &:hover:not(:disabled) { - color: var(--icon-weak-hover); + &:hover:not(:disabled) { + background-color: var(--surface-base-hover); + + [data-slot="icon"] { + color: var(--icon-hover); } - &:active:not(:disabled) { - color: var(--icon-string-active); + } + &:active:not(:disabled) { + [data-slot="icon"] { + color: var(--icon-active); } } - - /* color: var(--text-strong); */ - /**/ - /* &:hover:not(:disabled) { */ - /* background-color: var(--surface-hover); */ - /* } */ - /* &:active:not(:disabled) { */ - /* background-color: var(--surface-active); */ - /* } */ - /* &:focus:not(:disabled) { */ - /* background-color: var(--surface-focus); */ - /* } */ + &:selected:not(:disabled) { + background-color: var(--surface-base-active); + [data-slot="icon"] { + color: var(--icon-selected); + } + } + &:focus:not(:disabled) { + background-color: var(--surface-focus); + } } &[data-size="normal"] { @@ -103,9 +111,14 @@ &[data-size="large"] { height: 32px; - padding: 0 8px 0 6px; + /* padding: 0 8px 0 6px; */ gap: 8px; + [data-slot="icon"] { + height: 16px; + width: 16px; + } + /* text-12-medium */ font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -114,4 +127,14 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } + + &:disabled { + background-color: var(--icon-strong-disabled); + color: var(--icon-invert-base); + cursor: not-allowed; + } + + &:focus { + outline: none; + } } diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx index abc82609b..79a7cb9c9 100644 --- a/packages/ui/src/components/icon-button.tsx +++ b/packages/ui/src/components/icon-button.tsx @@ -2,7 +2,7 @@ import { Button as Kobalte } from "@kobalte/core/button" import { type ComponentProps, splitProps } from "solid-js" import { Icon, IconProps } from "./icon" -export interface IconButtonProps { +export interface IconButtonProps extends ComponentProps<typeof Kobalte> { icon: IconProps["name"] size?: "normal" | "large" iconSize?: IconProps["size"] diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index a2e127290..27a43e51e 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -150,6 +150,13 @@ const newIcons = { "code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`, "square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`, "circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`, + stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`, + enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`, + "layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`, + "layout-right": `<path d="M17.0832 2.91699L17.5832 2.91699L17.5832 2.41699L17.0832 2.41699L17.0832 2.91699ZM2.91651 2.91699L2.91651 2.41699L2.41651 2.41699L2.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.4165 17.0837L2.4165 17.5837L2.9165 17.5837L2.9165 17.0837ZM17.0832 17.0837L17.0832 17.5837L17.5832 17.5837L17.5832 17.0837L17.0832 17.0837ZM11.5832 17.0837L11.5832 17.5837L12.5832 17.5837L12.5832 17.0837L12.0832 17.0837L11.5832 17.0837ZM12.5832 2.91699L12.5832 2.41699L11.5832 2.41699L11.5832 2.91699L12.0832 2.91699L12.5832 2.91699ZM17.0832 2.91699L17.0832 2.41699L2.91651 2.41699L2.91651 2.91699L2.91651 3.41699L17.0832 3.41699L17.0832 2.91699ZM2.91651 2.91699L2.41651 2.91699L2.4165 17.0837L2.9165 17.0837L3.4165 17.0837L3.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.9165 17.5837L17.0832 17.5837L17.0832 17.0837L17.0832 16.5837L2.9165 16.5837L2.9165 17.0837ZM17.0832 17.0837L17.5832 17.0837L17.5832 2.91699L17.0832 2.91699L16.5832 2.91699L16.5832 17.0837L17.0832 17.0837ZM12.0832 17.0837L12.5832 17.0837L12.5832 2.91699L12.0832 2.91699L11.5832 2.91699L11.5832 17.0837L12.0832 17.0837Z" fill="currentColor"/>`, + "speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`, + "align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`, + expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index cd2d4caa9..ebc897a1f 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -24,5 +24,4 @@ export * from "./tooltip" export * from "./typewriter" export * from "../context/helper" -export * from "../context/shiki" export * from "../context/marked" diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index 509e242c9..9557e90f3 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -2,22 +2,32 @@ import { TextField as Kobalte } from "@kobalte/core/text-field" import { Show, splitProps } from "solid-js" import type { ComponentProps } from "solid-js" -export interface InputProps extends ComponentProps<typeof Kobalte> { +export interface InputProps + extends ComponentProps<typeof Kobalte.Input>, + Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> { label?: string hideLabel?: boolean description?: string } export function Input(props: InputProps) { - const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"]) + const [local, others] = splitProps(props, [ + "class", + "label", + "hideLabel", + "description", + "value", + "onChange", + "onKeyDown", + ]) return ( - <Kobalte {...others} data-component="input"> + <Kobalte data-component="input" value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown}> <Show when={local.label}> <Kobalte.Label data-slot="label" classList={{ "sr-only": local.hideLabel }}> {local.label} </Kobalte.Label> </Show> - <Kobalte.Input data-slot="input" class={local.class} placeholder={local.placeholder} /> + <Kobalte.Input {...others} data-slot="input" class={local.class} /> <Show when={local.description}> <Kobalte.Description data-slot="description">{local.description}</Kobalte.Description> </Show> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 01a58025a..74d9a3113 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -53,16 +53,11 @@ export function Message(props: MessageProps) { return ( <Switch> <Match when={props.message.role === "user" && props.message}> - {(userMessage) => ( - <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} /> - )} + {(userMessage) => <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} />} </Match> <Match when={props.message.role === "assistant" && props.message}> {(assistantMessage) => ( - <AssistantMessageDisplay - message={assistantMessage() as AssistantMessage} - parts={props.parts} - /> + <AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} /> )} </Match> </Switch> @@ -93,12 +88,7 @@ export function Part(props: MessagePartProps) { const component = createMemo(() => PART_MAPPING[props.part.type]) return ( <Show when={component()}> - <Dynamic - component={component()} - part={props.part} - message={props.message} - hideDetails={props.hideDetails} - /> + <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} /> </Show> ) } @@ -226,10 +216,7 @@ ToolRegistry.register({ name: "list", render(props) { return ( - <BasicTool - icon="bullet-list" - trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }} - > + <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}> <Show when={false && props.output}> <div data-component="tool-output">{props.output}</div> </Show> @@ -364,7 +351,7 @@ ToolRegistry.register({ </div> <div data-slot="actions"> <Show when={props.metadata.filediff}> - <DiffChanges diff={props.metadata.filediff} /> + <DiffChanges changes={props.metadata.filediff} /> </Show> </div> </div> diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx index a659c0f2e..f2e497fe5 100644 --- a/packages/ui/src/components/progress-circle.tsx +++ b/packages/ui/src/components/progress-circle.tsx @@ -7,13 +7,7 @@ export interface ProgressCircleProps extends Pick<ComponentProps<"svg">, "class" } export function ProgressCircle(props: ProgressCircleProps) { - const [split, rest] = splitProps(props, [ - "percentage", - "size", - "strokeWidth", - "class", - "classList", - ]) + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) const size = () => split.size || 16 const strokeWidth = () => split.strokeWidth || 3 @@ -42,13 +36,7 @@ export function ProgressCircle(props: ProgressCircleProps) { [split.class ?? ""]: !!split.class, }} > - <circle - cx={center} - cy={center} - r={radius()} - data-slot="background" - stroke-width={strokeWidth()} - /> + <circle cx={center} cy={center} r={radius()} data-slot="background" stroke-width={strokeWidth()} /> <circle cx={center} cy={center} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index ed10cbf14..79445420a 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,14 +1,14 @@ [data-component="select"] { - [data-slot="trigger"] { + [data-slot="select-trigger"] { padding: 0 4px 0 8px; box-shadow: none; - [data-slot="value"] { + [data-slot="select-trigger-value"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - [data-slot="icon"] { + [data-slot="select-trigger-icon"] { width: 16px; height: 16px; flex-shrink: 0; @@ -38,7 +38,7 @@ animation: select-open 0.15s ease-out; } - [data-slot="list"] { + [data-slot="select-content-list"] { overflow-y: auto; max-height: 12rem; white-space: nowrap; @@ -55,7 +55,7 @@ /* [data-slot="section"] { */ /* } */ - [data-slot="item"] { + [data-slot="select-item"] { position: relative; display: flex; align-items: center; @@ -85,7 +85,7 @@ background-color: var(--surface-raised-base); pointer-events: none; } - [data-slot="item-indicator"] { + [data-slot="select-item-indicator"] { margin-left: auto; width: 16px; height: 16px; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 806d7be12..dea6743ee 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -41,17 +41,17 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { )} itemComponent={(itemProps) => ( <Kobalte.Item - data-slot="item" + data-slot="select-item" classList={{ ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} {...itemProps} > - <Kobalte.ItemLabel data-slot="item-label"> + <Kobalte.ItemLabel data-slot="select-item-label"> {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} </Kobalte.ItemLabel> - <Kobalte.ItemIndicator data-slot="item-indicator"> + <Kobalte.ItemIndicator data-slot="select-item-indicator"> <Icon name="check-small" size="small" /> </Kobalte.ItemIndicator> </Kobalte.Item> @@ -61,7 +61,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { }} > <Kobalte.Trigger - data-slot="trigger" + data-slot="select-trigger" as={Button} size={props.size} variant={props.variant} @@ -70,7 +70,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { [props.class ?? ""]: !!props.class, }} > - <Kobalte.Value<T> data-slot="value"> + <Kobalte.Value<T> data-slot="select-trigger-value"> {(state) => { const selected = state.selectedOption() ?? props.current if (!selected) return props.placeholder || "" @@ -78,7 +78,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { return selected as string }} </Kobalte.Value> - <Kobalte.Icon data-slot="icon"> + <Kobalte.Icon data-slot="select-trigger-icon"> <Icon name="chevron-down" size="small" /> </Kobalte.Icon> </Kobalte.Trigger> @@ -90,7 +90,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { }} data-component="select-content" > - <Kobalte.Listbox data-slot="list" /> + <Kobalte.Listbox data-slot="select-content-list" /> </Kobalte.Content> </Kobalte.Portal> </Kobalte> diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 1d786fb4a..67f289283 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -7,7 +7,7 @@ overflow: clip; [data-slot="list"] { - height: 40px; + height: 48px; width: 100%; position: relative; display: flex; @@ -39,7 +39,7 @@ [data-slot="trigger"] { position: relative; height: 100%; - padding: 8px 24px; + padding: 14px 24px; display: flex; align-items: center; color: var(--text-base); diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index 0577365d6..92825aca1 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -6,54 +6,55 @@ [data-component="tooltip"] { z-index: 1000; max-width: 320px; - border-radius: 12px; + border-radius: 6px; background-color: var(--surface-float-base); - color: var(--white); - padding: 2px 12px 2px 12px; + color: rgba(253, 252, 252, 0.94); + padding: 2px 8px; + border: 0.5px solid rgba(253, 252, 252, 0.2); box-shadow: var(--shadow-md); pointer-events: none !important; - transition: all 150ms ease-out; - transform: translate3d(0, 0, 0); - transform-origin: var(--kb-tooltip-content-transform-origin); + /* transition: all 150ms ease-out; */ + /* transform: translate3d(0, 0, 0); */ + /* transform-origin: var(--kb-tooltip-content-transform-origin); */ - /* text-14-regular */ + /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 171.429% */ + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); &[data-expanded] { opacity: 1; - transform: translate3d(0, 0, 0); + /* transform: translate3d(0, 0, 0); */ } &[data-closed] { opacity: 0; } - &[data-placement="top"] { - &[data-closed] { - transform: translate3d(0, 4px, 0); - } - } - - &[data-placement="bottom"] { - &[data-closed] { - transform: translate3d(0, -4px, 0); - } - } - - &[data-placement="left"] { - &[data-closed] { - transform: translate3d(4px, 0, 0); - } - } - - &[data-placement="right"] { - &[data-closed] { - transform: translate3d(-4px, 0, 0); - } - } + /* &[data-placement="top"] { */ + /* &[data-closed] { */ + /* transform: translate3d(0, 4px, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="bottom"] { */ + /* &[data-closed] { */ + /* transform: translate3d(0, -4px, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="left"] { */ + /* &[data-closed] { */ + /* transform: translate3d(4px, 0, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="right"] { */ + /* &[data-closed] { */ + /* transform: translate3d(-4px, 0, 0); */ + /* } */ + /* } */ } diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index ff13c8d61..e3784ed8e 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,9 +1,9 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip" -import { children, createEffect, createSignal, splitProps } from "solid-js" +import { children, createEffect, createSignal, splitProps, type JSX } from "solid-js" import type { ComponentProps } from "solid-js" export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> { - value: string | (() => string) + value: JSX.Element class?: string } @@ -29,13 +29,13 @@ export function Tooltip(props: TooltipProps) { }) return ( - <KobalteTooltip forceMount {...others} open={open()} onOpenChange={setOpen}> + <KobalteTooltip forceMount gutter={4} {...others} open={open()} onOpenChange={setOpen}> <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}> {c()} </KobalteTooltip.Trigger> <KobalteTooltip.Portal> <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}> - {typeof others.value === "function" ? others.value() : others.value} + {others.value} {/* <KobalteTooltip.Arrow data-slot="arrow" /> */} </KobalteTooltip.Content> </KobalteTooltip.Portal> diff --git a/packages/ui/src/components/typewriter.tsx b/packages/ui/src/components/typewriter.tsx index 9adb267ad..2f6ecb016 100644 --- a/packages/ui/src/components/typewriter.tsx +++ b/packages/ui/src/components/typewriter.tsx @@ -2,11 +2,7 @@ import { createEffect, Show, type ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" -export const Typewriter = <T extends ValidComponent = "p">(props: { - text?: string - class?: string - as?: T -}) => { +export const Typewriter = <T extends ValidComponent = "p">(props: { text?: string; class?: string; as?: T }) => { const [store, setStore] = createStore({ typing: false, displayed: "", diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 18ce4280a..804d449c5 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -1,14 +1,14 @@ import { marked } from "marked" import markedShiki from "marked-shiki" import { bundledLanguages, type BundledLanguage } from "shiki" - import { createSimpleContext } from "./helper" -import { useShiki } from "./shiki" +import { getSharedHighlighter } from "@pierre/precision-diffs" + +const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] }) export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", init: () => { - const highlighter = useShiki() return marked.use( markedShiki({ async highlight(code, lang) { @@ -20,7 +20,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( } return highlighter.codeToHtml(code, { lang: lang || "text", - theme: "opencode", + theme: "OpenCode", tabindex: false, }) }, diff --git a/packages/ui/src/context/shiki.tsx b/packages/ui/src/context/shiki.tsx deleted file mode 100644 index d33b98ab7..000000000 --- a/packages/ui/src/context/shiki.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import { createSimpleContext } from "./helper" -import { createHighlighter, type ThemeInput } from "shiki" - -const theme: ThemeInput = { - colors: { - "actionBar.toggledBackground": "var(--surface-raised-base)", - "activityBarBadge.background": "var(--surface-brand-base)", - "checkbox.border": "var(--border-base)", - "editor.background": "transparent", - "editor.foreground": "var(--text-base)", - "editor.inactiveSelectionBackground": "var(--surface-raised-base)", - "editor.selectionHighlightBackground": "var(--border-active)", - "editorIndentGuide.activeBackground1": "var(--border-weak-base)", - "editorIndentGuide.background1": "var(--border-weak-base)", - "input.placeholderForeground": "var(--text-weak)", - "list.activeSelectionIconForeground": "var(--text-base)", - "list.dropBackground": "var(--surface-raised-base)", - "menu.background": "var(--surface-base)", - "menu.border": "var(--border-base)", - "menu.foreground": "var(--text-base)", - "menu.selectionBackground": "var(--surface-interactive-base)", - "menu.separatorBackground": "var(--border-base)", - "ports.iconRunningProcessForeground": "var(--icon-success-base)", - "sideBarSectionHeader.background": "transparent", - "sideBarSectionHeader.border": "var(--border-weak-base)", - "sideBarTitle.foreground": "var(--text-weak)", - "statusBarItem.remoteBackground": "var(--surface-success-base)", - "statusBarItem.remoteForeground": "var(--text-base)", - "tab.lastPinnedBorder": "var(--border-weak-base)", - "tab.selectedBackground": "var(--surface-raised-base)", - "tab.selectedForeground": "var(--text-weak)", - "terminal.inactiveSelectionBackground": "var(--surface-raised-base)", - "widget.border": "var(--border-base)", - }, - displayName: "opencode", - name: "opencode", - semanticHighlighting: true, - semanticTokenColors: { - customLiteral: "var(--syntax-function)", - newOperator: "var(--syntax-operator)", - numberLiteral: "var(--syntax-number)", - stringLiteral: "var(--syntax-string)", - }, - tokenColors: [ - { - scope: [ - "meta.embedded", - "source.groovy.embedded", - "string meta.image.inline.markdown", - "variable.legacy.builtin.python", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "emphasis", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "strong", - settings: { - fontStyle: "bold", - }, - }, - { - scope: "header", - settings: { - foreground: "var(--markdown-heading)", - }, - }, - { - scope: "comment", - settings: { - foreground: "var(--syntax-comment)", - }, - }, - { - scope: "constant.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "constant.numeric", - "variable.other.enummember", - "keyword.operator.plus.exponent", - "keyword.operator.minus.exponent", - ], - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "constant.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.tag", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["entity.name.tag.css", "entity.name.tag.less"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.other.attribute-name", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "entity.other.attribute-name.class.css", - "source.css entity.other.attribute-name.class", - "entity.other.attribute-name.id.css", - "entity.other.attribute-name.parent-selector.css", - "entity.other.attribute-name.parent.less", - "source.css entity.other.attribute-name.pseudo-class", - "entity.other.attribute-name.pseudo-element.css", - "source.css.less entity.other.attribute-name.id", - "entity.other.attribute-name.scss", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "invalid", - settings: { - foreground: "var(--syntax-critical)", - }, - }, - { - scope: "markup.underline", - settings: { - fontStyle: "underline", - }, - }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--markdown-strong)", - }, - }, - { - scope: "markup.heading", - settings: { - fontStyle: "bold", - foreground: "var(--theme-markdown-heading)", - }, - }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "markup.strikethrough", - settings: { - fontStyle: "strikethrough", - }, - }, - { - scope: "markup.inserted", - settings: { - foreground: "var(--text-diff-add-base)", - }, - }, - { - scope: "markup.deleted", - settings: { - foreground: "var(--text-diff-delete-base)", - }, - }, - { - scope: "markup.changed", - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "punctuation.definition.quote.begin.markdown", - settings: { - foreground: "var(--markdown-block-quote)", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--markdown-list-enumeration)", - }, - }, - { - scope: "markup.inline.raw", - settings: { - foreground: "var(--markdown-code)", - }, - }, - { - scope: "punctuation.definition.tag", - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["meta.preprocessor", "entity.name.function.preprocessor"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "meta.preprocessor.string", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "meta.preprocessor.numeric", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "meta.structure.dictionary.key.python", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--text-weak)", - }, - }, - { - scope: "storage", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "storage.type", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["storage.modifier", "keyword.operator.noexcept"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["string", "meta.embedded.assembly"], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.tag", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.value", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - "punctuation.section.embedded", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["meta.template.expression"], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: [ - "support.type.vendored.property-name", - "support.type.property-name", - "source.css variable", - "source.coffee.embedded", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.control", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.operator", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.alignof", - "keyword.operator.typeid", - "keyword.operator.alignas", - "keyword.operator.instanceof", - "keyword.operator.logical.python", - "keyword.operator.wordlike", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.other.unit", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "support.function.git-rebase", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "constant.sha.git-rebase", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: [ - "storage.modifier.import.java", - "variable.language.wildcard.java", - "storage.modifier.package.java", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "variable.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal", - ], - settings: { - foreground: "var(--syntax-function)", - }, - }, - { - scope: [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["variable.other.constant", "variable.other.enummember"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["meta.object-literal.key"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "keyword.operator.quantifier.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["constant.character", "constant.other.option"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "constant.character.escape", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.label", - settings: { - foreground: "var(--text-weak)", - }, - }, - ], - type: "dark", -} - -const highlighter = await createHighlighter({ - themes: [theme], - langs: [], -}) - -export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ - name: "Shiki", - init: () => { - return highlighter - }, -}) diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx index 5893ca751..96579c999 100644 --- a/packages/ui/src/demo.tsx +++ b/packages/ui/src/demo.tsx @@ -126,7 +126,7 @@ const Demo: Component = () => { <Tooltip value="This is a right tooltip" placement="right"> <Button variant="secondary">Right Tooltip</Button> </Tooltip> - <Tooltip value={() => `Dynamic tooltip: ${new Date().toLocaleTimeString()}`} placement="top"> + <Tooltip value={`Dynamic tooltip: ${new Date().toLocaleTimeString()}`} placement="top"> <Button variant="primary">Dynamic Tooltip</Button> </Tooltip> </section> @@ -191,7 +191,14 @@ const Demo: Component = () => { <Dialog open={dialogOpen()} onOpenChange={setDialogOpen}> <Dialog.Title>Example Dialog</Dialog.Title> <Dialog.Description>This is an example dialog with a title and description.</Dialog.Description> - <div style={{ "margin-top": "16px", display: "flex", gap: "8px", "justify-content": "flex-end" }}> + <div + style={{ + "margin-top": "16px", + display: "flex", + gap: "8px", + "justify-content": "flex-end", + }} + > <Button variant="ghost" onClick={() => setDialogOpen(false)}> Cancel </Button> diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index ca9bc5388..e0cb6e7aa 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -11,10 +11,7 @@ export interface FilteredListProps<T> { current?: T groupBy?: (x: T) => string sortBy?: (a: T, b: T) => number - sortGroupsBy?: ( - a: { category: string; items: T[] }, - b: { category: string; items: T[] }, - ) => number + sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number onSelect?: (value: T | undefined) => void } @@ -25,8 +22,7 @@ export function useFilteredList<T>(props: FilteredListProps<T>) { () => store.filter, async (filter) => { const needle = filter?.toLowerCase() - const all = - (typeof props.items === "function" ? await props.items(needle) : props.items) || [] + const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/tailwind/colors.css b/packages/ui/src/styles/tailwind/colors.css index 527c43109..95a497ba9 100644 --- a/packages/ui/src/styles/tailwind/colors.css +++ b/packages/ui/src/styles/tailwind/colors.css @@ -196,15 +196,20 @@ --color-icon-diff-delete-base: var(--icon-diff-delete-base); --color-icon-diff-delete-hover: var(--icon-diff-delete-hover); --color-syntax-comment: var(--syntax-comment); + --color-syntax-regexp: var(--syntax-regexp); --color-syntax-string: var(--syntax-string); --color-syntax-keyword: var(--syntax-keyword); --color-syntax-function: var(--syntax-function); --color-syntax-number: var(--syntax-number); --color-syntax-operator: var(--syntax-operator); --color-syntax-variable: var(--syntax-variable); + --color-syntax-property: var(--syntax-property); + --color-syntax-parameter: var(--syntax-parameter); --color-syntax-type: var(--syntax-type); --color-syntax-constant: var(--syntax-constant); --color-syntax-punctuation: var(--syntax-punctuation); + --color-syntax-namespace: var(--syntax-namespace); + --color-syntax-enum: var(--syntax-enum); --color-syntax-success: var(--syntax-success); --color-syntax-warning: var(--syntax-warning); --color-syntax-critical: var(--syntax-critical); @@ -232,4 +237,4 @@ --color-border-weaker-focus: var(--border-weaker-focus); --color-button-ghost-hover: var(--button-ghost-hover); --color-button-ghost-hover2: var(--button-ghost-hover2); -}
\ No newline at end of file +} diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 7dadcab9e..2f05aad56 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -50,29 +50,21 @@ --radius-4xl: 2rem; --shadow-xs: - 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), - 0 1px 3px 0 rgba(19, 16, 16, 0.08); + 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); --shadow-md: - 0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), - 0 1px 2px -1px rgba(19, 16, 16, 0.12); + 0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12); --shadow-xs-border: - 0 0 0 1px var(--border-weak-base, rgba(11, 6, 0, 0.20)), - 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), - 0 1px 3px 0 rgba(19, 16, 16, 0.08); + 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); --shadow-xs-border-select: 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); --shadow-xs-border-focus: - 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.20)), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), - 0 1px 3px 0 rgba(19, 16, 16, 0.12), - 0 0 0 2px var(--background-weak, #F1F0F0), + 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0), 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99)); - --text-mix-blend-mode: multiply; } @@ -273,20 +265,25 @@ --icon-diff-add-active: var(--mint-light-12); --icon-diff-delete-base: var(--ember-light-10); --icon-diff-delete-hover: var(--ember-light-11); - --syntax-comment: #8a8a8a; - --syntax-string: #d68c27; - --syntax-keyword: #3b7dd8; - --syntax-function: #d1383d; - --syntax-number: #3d9a57; - --syntax-operator: #d68c27; - --syntax-variable: #b0851f; - --syntax-type: #318795; - --syntax-constant: #953170; - --syntax-punctuation: #1a1a1a; - --syntax-success: var(--apple-dark-10); + --syntax-comment: var(--text-weaker); + --syntax-regexp: var(--text-base); + --syntax-string: var(--mint-light-11); + --syntax-keyword: var(--text-weak); + --syntax-primitive: var(--ember-light-11); + --syntax-operator: var(--text-weak); + --syntax-variable: var(--text-strong); + --syntax-property: var(--lilac-light-11); + --syntax-type: var(--cobalt-light-11); + --syntax-constant: var(--lilac-light-11); + --syntax-punctuation: var(--text-weak); + --syntax-object: var(--blue-light-11); + --syntax-success: var(--apple-light-10); --syntax-warning: var(--amber-light-10); - --syntax-critical: var(--ember-dark-9); - --syntax-info: var(--lilac-dark-11); + --syntax-critical: var(--ember-light-9); + --syntax-info: var(--lilac-light-11); + --syntax-diff-add: var(--mint-light-11); + --syntax-diff-delete: var(--ember-light-11); + --syntax-unknown: red; --markdown-heading: #d68c27; --markdown-text: #1a1a1a; --markdown-link: #3b7dd8; @@ -413,7 +410,7 @@ --text-on-brand-weaker: var(--smoke-dark-alpha-8); --text-on-brand-strong: var(--smoke-dark-alpha-12); --button-secondary-base: var(--smoke-dark-4); - --button-secondary-hover: #2A2727; + --button-secondary-hover: #2a2727; --border-base: var(--smoke-dark-alpha-7); --border-hover: var(--smoke-dark-alpha-8); --border-active: var(--smoke-dark-alpha-9); @@ -508,20 +505,24 @@ --icon-diff-add-active: var(--mint-dark-11); --icon-diff-delete-base: var(--ember-dark-9); --icon-diff-delete-hover: var(--ember-dark-10); - --syntax-comment: #808080; - --syntax-string: #9d7cd8; - --syntax-keyword: #fab283; - --syntax-function: #e06c75; - --syntax-number: #7fd88f; - --syntax-operator: #f5a742; - --syntax-variable: #e5c07b; - --syntax-type: #56b6c2; - --syntax-constant: #c2569a; - --syntax-punctuation: #eeeeee; + --syntax-comment: var(--text-weaker); + --syntax-regexp: var(--text-base); + --syntax-string: var(--mint-dark-11); + --syntax-keyword: var(--text-weak); + --syntax-primitive: var(--ember-dark-11); + --syntax-operator: var(--text-weak); + --syntax-variable: var(--text-strong); + --syntax-property: var(--lilac-dark-11); + --syntax-type: var(--cobalt-dark-11); + --syntax-constant: var(--lilac-dark-11); + --syntax-punctuation: var(--text-weak); + --syntax-object: var(--blue-dark-11); --syntax-success: var(--apple-dark-10); --syntax-warning: var(--amber-dark-10); - --syntax-critical: var(--ember-dark-10); - --syntax-info: var(--lilac-dark-10); + --syntax-critical: var(--ember-dark-9); + --syntax-info: var(--lilac-dark-11); + --syntax-diff-add: var(--mint-dark-11); + --syntax-diff-delete: var(--ember-dark-11); --markdown-heading: #9d7cd8; --markdown-text: #eeeeee; --markdown-link: #fab283; diff --git a/packages/ui/sst-env.d.ts b/packages/ui/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/ui/sst-env.d.ts +++ b/packages/ui/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 440aa8f9b..c35314407 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -13,16 +13,9 @@ "module": "ESNext", "moduleResolution": "bundler", "noEmit": true, - "lib": [ - "es2022", - "dom", - "dom.iterable" - ], + "lib": ["es2022", "dom", "dom.iterable"], // Type Checking & Safety "strict": true, - "types": [ - "vite/client", - "bun" - ] + "types": ["vite/client", "bun"] } } diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 24987ca35..ed1542cee 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -108,9 +108,6 @@ export default defineConfig({ ], }), ], - redirects: { - "/discord": "https://discord.gg/opencode", - }, }) function configSchema() { diff --git a/packages/web/package.json b/packages/web/package.json index 2de696b3e..aefaa220d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.23", + "version": "1.0.55", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/public/favicon.svg b/packages/web/public/favicon.svg index 3c81bbdb4..bf8f9075d 100644 --- a/packages/web/public/favicon.svg +++ b/packages/web/public/favicon.svg @@ -1,5 +1,4 @@ -<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg"> -<rect width="600" height="600" fill="black"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/> -<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/> +<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="400" height="400" fill="#0E0E0E"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M312 340H88V60H312V340ZM256 116H144V284H256V116Z" fill="white"/> </svg> diff --git a/packages/web/src/content/docs/1-0.mdx b/packages/web/src/content/docs/1-0.mdx index 49db53d72..6737482a7 100644 --- a/packages/web/src/content/docs/1-0.mdx +++ b/packages/web/src/content/docs/1-0.mdx @@ -16,13 +16,14 @@ The new TUI works like the old one since it connects to the same opencode server You should not be autoupgraded to 1.0 if you are currently using a previous version. However some older versions of OpenCode always grab latest. - To upgrade manually, run + ```bash $ opencode upgrade 1.0.0 ``` To downgrade back to 0.x, run + ```bash $ opencode upgrade 0.15.31 ``` diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 1312442d6..083db369b 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -184,6 +184,16 @@ This is useful for scripting, automation, or when you want a quick answer withou opencode run Explain the use of context in Go ``` +You can also attach to a running `opencode serve` instance to avoid MCP server cold boot times on every run: + +```bash +# Start a headless server in one terminal +opencode serve + +# In another terminal, run commands that attach to it +opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" +``` + #### Flags | Flag | Short | Description | @@ -197,6 +207,8 @@ opencode run Explain the use of context in Go | `--file` | `-f` | File(s) to attach to message | | `--format` | | Format: default (formatted) or json (raw JSON events) | | `--title` | | Title for the session (uses truncated prompt if no value provided) | +| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) | +| `--port` | | Port for the local server (defaults to random port) | --- diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 8853ca334..9fc41a53d 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -21,9 +21,11 @@ OpenCode comes with several built-in formatters for popular languages and framew | clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | | ktlint | .kt, .kts | `ktlint` command available | | ruff | .py, .pyi | `ruff` command available with config | +| uv | .py, .pyi | `uv` command available | | rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | | standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | | htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | +| air | .R | `air` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index f6d2b3129..20c0d2f99 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -41,28 +41,36 @@ You can also install it with the following commands: - **Using Node.js** - <Tabs> + <Tabs> + <TabItem label="npm"> - ```bash - npm install -g opencode-ai - ``` - </TabItem> - <TabItem label="Bun"> + ```bash + npm install -g opencode-ai + ``` + + </TabItem> + + <TabItem label="Bun"> ```bash bun install -g opencode-ai ``` - </TabItem> - <TabItem label="pnpm"> + + </TabItem> + + <TabItem label="pnpm"> ```bash pnpm install -g opencode-ai ``` - </TabItem> - <TabItem label="Yarn"> + + </TabItem> + + <TabItem label="Yarn"> ```bash yarn global add opencode-ai ``` + </TabItem> - </Tabs> +</Tabs> - **Using Homebrew on macOS and Linux** diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 4579c2127..a0ba5d5d9 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -187,4 +187,3 @@ permission: Only analyze code and suggest changes. ``` - diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 41e6308f7..6a7520e56 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -301,6 +301,42 @@ Or if you already have an API key, you can select **Manually enter API Key** and --- +### Deep Infra + +1. Head over to the [Deep Infra dashboard](https://deepinfra.com/dash), create an account, and generate an API key. + +2. Run `opencode auth login` and select **Deep Infra**. + + ```bash + $ opencode auth login + + ┌ Add credential + │ + ◆ Select provider + │ ● Deep Infra + │ ... + └ + ``` + +3. Enter your Deep Infra API key. + + ```bash + $ opencode auth login + + ┌ Add credential + │ + ◇ Select provider + │ Deep Infra + │ + ◇ Enter your API key + │ _ + └ + ``` + +4. Run the `/models` command to select a model. + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. @@ -482,7 +518,6 @@ To use Google Vertex AI with OpenCode: 4. Run the `/models` command to select a model like _Kimi-K2-Instruct_ or _GLM-4.6_. - --- ### LM Studio @@ -935,9 +970,9 @@ You can use any OpenAI-compatible provider with opencode. Most modern AI provide ##### Example -Here's an example setting the `apiKey` and `headers` options. +Here's an example setting the `apiKey`, `headers`, and model `limit` options. -```json title="opencode.json" {9,11} +```json title="opencode.json" {9,11,17-20} { "$schema": "https://opencode.ai/config.json", "provider": { @@ -953,7 +988,11 @@ Here's an example setting the `apiKey` and `headers` options. }, "models": { "my-model-name": { - "name": "My Model Display Name" + "name": "My Model Display Name", + "limit": { + "context": 200000, + "output": 65536 + } } } } @@ -961,7 +1000,14 @@ Here's an example setting the `apiKey` and `headers` options. } ``` -We are setting the `apiKey` using the `env` variable syntax, [learn more](/docs/config#env-vars). +Configuration details: + +- **apiKey**: Set using `env` variable syntax, [learn more](/docs/config#env-vars). +- **headers**: Custom headers sent with each request. +- **limit.context**: Maximum input tokens the model accepts. +- **limit.output**: Maximum tokens the model can generate. + +The `limit` fields allow OpenCode to understand how much context you have left. Standard providers pull these from models.dev automatically. --- diff --git a/packages/web/src/content/docs/troubleshooting.mdx b/packages/web/src/content/docs/troubleshooting.mdx index fb8e964ba..d38b892fc 100644 --- a/packages/web/src/content/docs/troubleshooting.mdx +++ b/packages/web/src/content/docs/troubleshooting.mdx @@ -90,8 +90,8 @@ If you encounter `ProviderModelNotFoundError` you are most likely incorrectly referencing a model somewhere. Models should be referenced like so: `<providerId>/<modelId>` - Examples: + - `openai/gpt-4.1` - `openrouter/google/gemini-2.5-flash` - `opencode/kimi-k2` diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index d2da4a51c..6c9b9fb88 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -75,6 +75,7 @@ You can also access our models through the following API endpoints. | Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Code Fast 1 | grok-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode/<model-id>`. For example, for GPT 5 Codex, you would @@ -117,8 +118,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GLM 4.6 | $0.60 | $2.20 | $0.10 | - | | Kimi K2 | $0.60 | $2.50 | $0.36 | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - | -| Grok Code Fast 1 | Free | Free | - | - | -| Code Supernova | Free | Free | - | - | +| Grok Code Fast 1 | Free | Free | Free | - | +| Big Pickle | Free | Free | Free | - | | Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | | Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | | Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | @@ -138,7 +139,7 @@ Credit card fees are passed along at cost; we don't charge anything beyond that. The free models: - Grok Code Fast 1 is currently free on OpenCode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code. -- Code Supernova is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. :::tip Subscription plans and a free tier are coming soon. @@ -153,8 +154,7 @@ Subscription plans and a free tier are coming soon. All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions: - Grok Code Fast 1: During its free period, collected data may be used to improve Grok Code. -- Code Supernova: During its free period, collected data may be used to improve - the model. +- Big Pickle: During its free period, collected data may be used to improve the model. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/styles/custom.css b/packages/web/src/styles/custom.css index e947f1f18..648176e42 100644 --- a/packages/web/src/styles/custom.css +++ b/packages/web/src/styles/custom.css @@ -30,8 +30,6 @@ --sl-color-divider: var(--sl-color-gray-5); } - - body { color: var(--color-text) !important; font-size: 14px !important; @@ -60,7 +58,6 @@ body { --color-border-weak: hsl(0, 4%, 23%); --color-icon: hsl(10, 3%, 43%); - } } @@ -145,7 +142,7 @@ a[aria-current="page"] { font-weight: 600 !important; } -ul.top-level a[aria-current="page"] > span { +ul.top-level a[aria-current="page"] > span { color: var(--color-text-strong) !important; } @@ -163,7 +160,6 @@ ul.top-level a[aria-current="page"] > span { font-weight: 600 !important; } - .top-level li a { border-radius: 0; width: 100%; @@ -221,13 +217,16 @@ site-search > button span { } } - - site-search > button > kbd { font-size: 14px !important; } -h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { +h1 a, +h2 a, +h3 a, +h4 a, +h5 a, +h6 a { color: var(--color-text-strong) !important; } @@ -263,7 +262,8 @@ strong { font-weight: 500 !important; } -ul, ol { +ul, +ol { list-style: none !important; padding: 0 !important; } @@ -280,7 +280,8 @@ ul, ol { color: var(--color-text-strong) !important; } -body > .page > header, :root[data-has-sidebar] body > .page > header { +body > .page > header, +:root[data-has-sidebar] body > .page > header { background: var(--color-background) !important; padding: 24px !important; } @@ -303,7 +304,6 @@ body > .page > header, :root[data-has-sidebar] body > .page > header { max-width: 100% !important; } - nav.sidebar .sl-container ul li a, div.right-sidebar .sl-container ul li a { padding: 4px 24px !important; @@ -317,7 +317,7 @@ div.right-sidebar .sl-container ul li a:hover { background: var(--color-background-weak); @media (prefers-color-scheme: dark) { - background: var(--color-background-weak) + background: var(--color-background-weak); } } @@ -326,14 +326,12 @@ div.right-sidebar .sl-container ul li ul li { padding: 4px 12px 0 12px !important; } - nav.sidebar .sl-container ul li a[aria-current="true"], div.right-sidebar .sl-container ul li a[aria-current="true"] { color: var(--color-text-strong) !important; opacity: 100%; } - h2#starlight__on-this-page { font-size: 14px !important; color: var(--color-text-strong) !important; @@ -375,7 +373,6 @@ nav.sidebar ul.top-level > li > details > summary .group-label > span { .expressive-code { margin: 0.75rem 0 3rem 0 !important; border-radius: 6px; - } .expressive-code figure { @@ -386,8 +383,6 @@ nav.sidebar ul.top-level > li > details > summary .group-label > span { box-shadow: none; } - - @media (prefers-color-scheme: dark) { .shiki, .shiki span { diff --git a/packages/web/sst-env.d.ts b/packages/web/sst-env.d.ts index b6a7e9066..0397645b5 100644 --- a/packages/web/sst-env.d.ts +++ b/packages/web/sst-env.d.ts @@ -6,4 +6,4 @@ /// <reference path="../../sst-env.d.ts" /> import "sst" -export {}
\ No newline at end of file +export {} |
