diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 058cb420ec9..d54f9369af1 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{ {props.children} - + Select model setOpen(false)} class="p-1" /> diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 1e093469fe5..a6e3168cd02 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -6,7 +6,6 @@ import { NamedError } from "@opencode-ai/util/error" import { readableStreamToText } from "bun" import { createRequire } from "module" import { Lock } from "../util/lock" -import { copyPluginAssets } from "../util/asset-copy" export namespace BunProc { const log = Log.create({ service: "bun" }) @@ -66,19 +65,15 @@ export namespace BunProc { using _ = await Lock.write("bun-install") const mod = path.join(Global.Path.cache, "node_modules", pkg) - const bundledDir = path.join(Global.Path.cache, "bundled") - const bundledFile = path.join(bundledDir, `${pkg.replace(/\//g, "-")}.js`) const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json")) const parsed = await pkgjson.json().catch(async () => { - const result = { dependencies: {}, bundled: {} } + const result = { dependencies: {} } await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2)) return result }) - // Check if already installed and bundled - const bundledExists = await Bun.file(bundledFile).exists() - if (parsed.dependencies[pkg] === version && bundledExists) { - return bundledFile + if (parsed.dependencies[pkg] === version) { + return mod } const proxied = !!( @@ -129,97 +124,15 @@ export namespace BunProc { resolvedVersion = installedPkg.version } - const tslibPath = path.join(Global.Path.cache, "node_modules", "tslib", "package.json") - const tslibExists = await Bun.file(tslibPath).exists() - if (!tslibExists) { - const resolvedTslibVersion = "latest" - log.info("installing tslib dependency for runtime compatibility", { - pkg, - tslib: resolvedTslibVersion, - }) - await BunProc.run([ - "add", - "--force", - "--exact", - "--cwd", - Global.Path.cache, - `tslib@${resolvedTslibVersion}`, - ], { - cwd: Global.Path.cache, - }).catch((e) => { - throw new InstallFailedError( - { pkg: "tslib", version: resolvedTslibVersion }, - { - cause: e, - }, - ) - }) - } - - // Bundle the plugin with all dependencies for compiled binary compatibility - // This creates a single file that doesn't require subpath export resolution - await Bun.file(bundledDir) - .exists() - .then(async (exists) => { - if (!exists) await Bun.$`mkdir -p ${bundledDir}` - }) - - // Find the entry point from package.json - const entryPoint = (installedPkg ?? {}).main || "index.js" - const entryPath = path.join(mod, entryPoint) + parsed.dependencies[pkg] = resolvedVersion + await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) - log.info("bundling plugin for compiled binary compatibility", { + log.info("successfully installed plugin", { pkg, - entryPath, - bundledFile, + path: mod, }) - try { - const result = await Bun.build({ - entrypoints: [entryPath], - outdir: bundledDir, - naming: `${pkg.replace(/\//g, "-")}.js`, - target: "bun", - format: "esm", - // Bundle all dependencies to avoid subpath export resolution issues - packages: "bundle", - }) - - if (!result.success) { - log.error("failed to bundle plugin - falling back to unbundled module", { - pkg, - logs: result.logs, - unbundledPath: mod, - }) - // Fall back to unbundled module - return mod - } - - log.info("successfully bundled plugin", { - pkg, - bundledFile, - }) - - // Copy non-JS assets (HTML, CSS, etc.) that plugins may need at runtime - // Some bundled code uses __dirname + ".." to find assets, so copy to both - // the bundled dir and the parent cache dir for compatibility - await copyPluginAssets(mod, bundledDir) - await copyPluginAssets(mod, Global.Path.cache) - } catch (e) { - log.error("failed to bundle plugin - falling back to unbundled module", { - pkg, - error: (e as Error).message, - unbundledPath: mod, - }) - // Fall back to unbundled module - return mod - } - - parsed.dependencies[pkg] = resolvedVersion - if (!parsed.bundled) parsed.bundled = {} - parsed.bundled[pkg] = bundledFile - await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) - return bundledFile + return mod } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index adbd3f45a5e..232cdd57637 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({ "Configure via opencode.json options (profile, region, endpoint) or\n" + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).", ) - prompts.outro("Done") - return } if (provider === "opencode") { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 3f9285f631c..34c9ee7d50b 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -22,10 +22,13 @@ export const AttachCommand = cmd({ }), handler: async (args) => { if (args.dir) process.chdir(args.dir) + // Always pass client's cwd so attached sessions operate in the client's directory + // This ensures file autocomplete, theme discovery, and exports use the correct directory + const directory = process.cwd() await tui({ url: args.url, args: { sessionID: args.session }, - directory: args.dir ? process.cwd() : undefined, + directory, }) }, }) 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 50cf43896a9..71a7d22b8f2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) { const options = createMemo(() => { const q = query() - const favorites = showExtra() ? local.model.favorite() : [] + const needle = q.trim() + const showSections = showExtra() && needle.length === 0 + const favorites = connected() ? local.model.favorite() : [] const recents = local.model.recent() - const recentList = showExtra() + const recentList = showSections ? recents.filter( (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), ) : [] - const favoriteOptions = favorites.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { - providerID: provider.id, - modelID: model.id, - }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Favorites", - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { + const favoriteOptions = showSections + ? favorites.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { providerID: provider.id, modelID: model.id, }, - { recent: true }, - ) - }, - }, - ] - }) + title: model.name ?? item.modelID, + description: provider.name, + category: "Favorites", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model.id, + }, + { recent: true }, + ) + }, + }, + ] + }) + : [] - const recentOptions = recentList.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { - providerID: provider.id, - modelID: model.id, - }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { + const recentOptions = showSections + ? recentList.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { providerID: provider.id, modelID: model.id, }, - { recent: true }, - ) - }, - }, - ] - }) + title: model.name ?? item.modelID, + description: provider.name, + category: "Recent", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model.id, + }, + { recent: true }, + ) + }, + }, + ] + }) + : [] const providerOptions = pipe( sync.data.provider, @@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) { } }), filter((x) => { + if (!showSections) return true const value = x.value const inFavorites = favorites.some( (item) => item.providerID === value.providerID && item.modelID === value.modelID, @@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) { ) : [] - // Apply fuzzy filtering to each section separately, maintaining section order - if (q) { - const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) - const filteredRecents = fuzzysort - .go(q, recentOptions, { keys: ["title"] }) - .map((x) => x.obj) - .slice(0, 5) - const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) - const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) - return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] + // Search shows a single merged list (favorites inline) + if (needle) { + const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) + const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj) + return [...filteredProviders, ...filteredPopular] } return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.ts b/packages/opencode/src/cli/cmd/tui/component/tips.ts index ed8ce147471..1c0e29f081e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.ts +++ b/packages/opencode/src/cli/cmd/tui/component/tips.ts @@ -4,7 +4,7 @@ export const TIPS = [ "Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.", "Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.", - "Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.", + "Run {highlight}/share{/highlight} to create a public link to your conversation at shuv.ai.", "Drag and drop images into the terminal to add them as context for your prompts.", "Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.", "Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.", @@ -61,7 +61,7 @@ export const TIPS = [ "Use {highlight}--format json{/highlight} for machine-readable output in scripts.", "Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.", "Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.", - "Run {highlight}opencode upgrade{/highlight} to update to the latest version.", + "Run {highlight}shuvcode upgrade{/highlight} to update to the latest version.", "Run {highlight}opencode auth list{/highlight} to see all configured providers.", "Run {highlight}opencode agent create{/highlight} for guided agent creation.", "Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.", @@ -92,7 +92,7 @@ export const TIPS = [ "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.", "Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.", "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).", - "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use.", + "Run {highlight}docker run -it --rm ghcr.io/latitudes-dev/shuvcode{/highlight} for containerized use.", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2a0965f20c0..5f247728ef9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -178,6 +178,8 @@ export namespace Config { result.compaction = { ...result.compaction, prune: false } } + result.plugin = deduplicatePlugins(result.plugin ?? []) + return { config: result, directories, @@ -332,6 +334,58 @@ export namespace Config { return plugins } + /** + * Extracts a canonical plugin name from a plugin specifier. + * - For file:// URLs: extracts filename without extension + * - For npm packages: extracts package name without version + * + * @example + * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" + * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" + */ + export function getPluginName(plugin: string): string { + if (plugin.startsWith("file://")) { + return path.parse(new URL(plugin).pathname).name + } + const lastAt = plugin.lastIndexOf("@") + if (lastAt > 0) { + return plugin.substring(0, lastAt) + } + return plugin + } + + /** + * Deduplicates plugins by name, with later entries (higher priority) winning. + * Priority order (highest to lowest): + * 1. Local plugin/ directory + * 2. Local opencode.json + * 3. Global plugin/ directory + * 4. Global opencode.json + * + * Since plugins are added in low-to-high priority order, + * we reverse, deduplicate (keeping first occurrence), then restore order. + */ + export function deduplicatePlugins(plugins: string[]): string[] { + // seenNames: canonical plugin names for duplicate detection + // e.g., "oh-my-opencode", "@scope/pkg" + const seenNames = new Set() + + // uniqueSpecifiers: full plugin specifiers to return + // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" + const uniqueSpecifiers: string[] = [] + + for (const specifier of plugins.toReversed()) { + const name = getPluginName(specifier) + if (!seenNames.has(name)) { + seenNames.add(name) + uniqueSpecifiers.push(specifier) + } + } + + return uniqueSpecifiers.toReversed() + } + export const McpLocal = z .object({ type: z.literal("local").describe("Type of MCP server connection"), diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 86f2a781c83..7a3b9d6fe5e 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -12,6 +12,9 @@ declare global { const OPENCODE_CHANNEL: string } +// Fork customization: shuvcode package name for npm registry and user agent +const PACKAGE_NAME = "shuvcode" + export namespace Installation { const log = Log.create({ service: "installation" }) @@ -95,7 +98,8 @@ export namespace Installation { for (const check of checks) { const output = await check.command() - if (output.includes(check.name === "brew" ? "opencode" : "opencode-ai")) { + // Fork customization: shuvcode is not on brew, check for shuvcode package + if (output.includes(check.name === "brew" ? "opencode" : PACKAGE_NAME)) { return check.name } } @@ -110,6 +114,7 @@ export namespace Installation { }), ) + // Fork customization: shuvcode is not on brew, but keep the function for compatibility async function getBrewFormula() { const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text() if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" @@ -122,21 +127,26 @@ export namespace Installation { let cmd switch (method) { case "curl": - cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({ + // Fork customization: use shuv.ai install script + cmd = $`curl -fsSL https://shuv.ai/install | bash`.env({ ...process.env, VERSION: target, }) break case "npm": - cmd = $`npm install -g opencode-ai@${target}` + // Fork customization: use shuvcode package + cmd = $`npm install -g ${PACKAGE_NAME}@${target}` break case "pnpm": - cmd = $`pnpm install -g opencode-ai@${target}` + // Fork customization: use shuvcode package + cmd = $`pnpm install -g ${PACKAGE_NAME}@${target}` break case "bun": - cmd = $`bun install -g opencode-ai@${target}` + // Fork customization: use shuvcode package + cmd = $`bun install -g ${PACKAGE_NAME}@${target}` break case "brew": { + // Fork customization: shuvcode is not on brew, fallback to upstream formula const formula = await getBrewFormula() cmd = $`brew install ${formula}`.env({ HOMEBREW_NO_AUTO_UPDATE: "1", @@ -163,7 +173,8 @@ export namespace Installation { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" - export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` + // Fork customization: use shuvcode user agent + export const USER_AGENT = `${PACKAGE_NAME}/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export async function latest(installMethod?: Method) { const detectedMethod = installMethod || (await method()) @@ -187,7 +198,8 @@ export namespace Installation { return reg.endsWith("/") ? reg.slice(0, -1) : reg }) const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) + // Fork customization: use shuvcode package name + return fetch(`${registry}/${PACKAGE_NAME}/${channel}`) .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() @@ -195,7 +207,8 @@ export namespace Installation { .then((data: any) => data.version) } - return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") + // Fork customization: check Latitudes-Dev/shuvcode releases instead of upstream + return fetch("https://api.github.com/repos/Latitudes-Dev/shuvcode/releases/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d8d417ae82..f9e9fd6177a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -873,6 +873,7 @@ export namespace Provider { if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat")) delete provider.models[modelID] if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] + if (model.status === "deprecated") delete provider.models[modelID] if ( (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index ab18239a656..5bff0aec16c 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -152,12 +152,19 @@ export namespace Session { directory: Instance.directory, }) const msgs = await messages({ sessionID: input.sessionID }) + const idMap = new Map() + for (const msg of msgs) { if (input.messageID && msg.info.id >= input.messageID) break + const newID = Identifier.ascending("message") + idMap.set(msg.info.id, newID) + + const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined const cloned = await updateMessage({ ...msg.info, sessionID: session.id, - id: Identifier.ascending("message"), + id: newID, + ...(parentID && { parentID }), }) for (const part of msg.parts) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 7f7f65c915c..e5f5ec2f404 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, mock, afterEach } from "bun:test" +import { test, expect, describe, mock } from "bun:test" import { Config } from "../../src/config/config" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" @@ -1199,3 +1199,91 @@ test("project config overrides remote well-known config", async () => { Auth.all = originalAuthAll } }) + +describe("getPluginName", () => { + test("extracts name from file:// URL", () => { + expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo") + expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar") + expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") + }) + + test("extracts name from npm package with version", () => { + expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode") + expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin") + expect(Config.getPluginName("plugin@latest")).toBe("plugin") + }) + + test("extracts name from scoped npm package", () => { + expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg") + expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin") + }) + + test("returns full string for package without version", () => { + expect(Config.getPluginName("some-plugin")).toBe("some-plugin") + expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg") + }) +}) + +describe("deduplicatePlugins", () => { + test("removes duplicates keeping higher priority (later entries)", () => { + const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result).toContain("global-plugin@1.0.0") + expect(result).toContain("local-plugin@2.0.0") + expect(result).toContain("shared-plugin@2.0.0") + expect(result).not.toContain("shared-plugin@1.0.0") + expect(result.length).toBe(3) + }) + + test("prefers local file over npm package with same name", () => { + const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result.length).toBe(1) + expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js") + }) + + test("preserves order of remaining plugins", () => { + const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) + }) + + test("local plugin directory overrides global opencode.json plugin", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const projectDir = path.join(dir, "project") + const opencodeDir = path.join(projectDir, ".opencode") + const pluginDir = path.join(opencodeDir, "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["my-plugin@1.0.0"], + }), + ) + + await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}") + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "project"), + fn: async () => { + const config = await Config.get() + const plugins = config.plugin ?? [] + + const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin") + expect(myPlugins.length).toBe(1) + expect(myPlugins[0].startsWith("file://")).toBe(true) + }, + }) + }) +}) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index dd8e30ba602..7d191e8b4bf 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -77,7 +77,7 @@ [data-slot="user-message-text"] { white-space: pre-wrap; - word-break: break-all; + word-break: break-word; overflow: hidden; background: var(--surface-base); padding: 8px 12px; @@ -254,6 +254,7 @@ scrollbar-width: none; -ms-overflow-style: none; + &::-webkit-scrollbar { display: none; } @@ -271,6 +272,7 @@ /* Hide scrollbar */ scrollbar-width: none; -ms-overflow-style: none; + &::-webkit-scrollbar { display: none; } @@ -413,26 +415,23 @@ border: 1.5px solid transparent; background: linear-gradient(var(--background-base) 0 0) padding-box, - conic-gradient( - from var(--border-angle), - transparent 0deg, - transparent 0deg, - var(--border-warning-strong, var(--border-warning-selected)) 300deg, - var(--border-warning-base) 360deg - ) - border-box; + conic-gradient(from var(--border-angle), + transparent 0deg, + transparent 0deg, + var(--border-warning-strong, var(--border-warning-selected)) 300deg, + var(--border-warning-base) 360deg) border-box; animation: chase-border 2.5s linear infinite; pointer-events: none; z-index: -1; } - & > *:first-child { + &>*:first-child { border-top-left-radius: 6px; border-top-right-radius: 6px; overflow: hidden; } - & > *:last-child { + &>*:last-child { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; overflow: hidden; @@ -458,6 +457,7 @@ from { --border-angle: 0deg; } + to { --border-angle: 360deg; } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 534ea8f50ab..d59f5cfa3e3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -984,6 +984,22 @@ ToolRegistry.register({ ToolRegistry.register({ name: "todowrite", render(props) { + const todos = createMemo(() => { + const meta = props.metadata?.todos + if (Array.isArray(meta)) return meta + + const input = props.input.todos + if (Array.isArray(input)) return input + + return [] + }) + + const subtitle = createMemo(() => { + const list = todos() + if (list.length === 0) return "" + return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` + }) + return ( t.status === "completed").length}/${props.input.todos.length}` - : "", + subtitle: subtitle(), }} > - +
- + {(todo: Todo) => (
diff --git a/script/sync/fork-features.json b/script/sync/fork-features.json index bcdd87a5552..b5c916b1f46 100644 --- a/script/sync/fork-features.json +++ b/script/sync/fork-features.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Fork-specific features from upstream PRs that must be preserved during merges", - "lastUpdated": "2026-01-08", - "lastChange": "Updated builtinPlugins to use opencode-anthropic-auth-shuv@latest instead of opencode-anthropic-auth@0.0.7.", + "lastUpdated": "2026-01-09", + "lastChange": "Removed npm plugin bundling to align with upstream and fix OAuth plugin asset resolution (#285). Fork now imports plugins directly from node_modules like upstream, preserving __dirname-relative asset paths.", "note": "v1.1.6 sync. Fork includes streaming grep optimization, spinner for all tool calls, plugin command execution, and critical PWA/SDK fixes.", "builtinPlugins": { "description": "Builtin plugin versions in BUILTIN array that MUST be preserved. Upstream may update these versions but we use our fork for stability.", @@ -1827,6 +1827,87 @@ } ], "note": "This code was originally added in commit 57ac84410 but lost during v1.1.6 upstream merge. Tests in test/command/plugin-commands.test.ts verify this behavior." + }, + { + "pr": 283, + "title": "Attach command auto-pass cwd", + "author": "fork", + "status": "fork-only", + "description": "The attach command always passes client's cwd to server via x-opencode-directory header. This ensures file autocomplete, theme discovery, and exports use the client's directory when attaching to a remote server.", + "files": ["packages/opencode/src/cli/cmd/tui/attach.ts"], + "criticalCode": [ + { + "file": "packages/opencode/src/cli/cmd/tui/attach.ts", + "description": "Always capture and pass process.cwd() to tui()", + "markers": ["const directory = process.cwd()", "directory,"] + } + ], + "note": "Original fix was in commit 401b498c7. Regressed in upstream PR #7150 (commit dee022674) which made directory conditional on --dir flag." + }, + { + "pr": 285, + "title": "Plugin loading align with upstream", + "author": "fork", + "status": "fork-only", + "description": "Removed npm plugin bundling to align with upstream and fix OAuth plugin asset resolution. Fork now imports plugins directly from node_modules like upstream, preserving __dirname-relative asset paths. Local file:// plugins still bundle for compiled binary compatibility.", + "files": ["packages/opencode/src/bun/index.ts"], + "criticalCode": [ + { + "file": "packages/opencode/src/bun/index.ts", + "description": "BunProc.install() returns node_modules path directly, no bundling", + "markers": ["return mod", "const mod = path.join(Global.Path.cache, \"node_modules\", pkg)"] + } + ], + "note": "Fork previously bundled npm plugins with Bun.build() which broke __dirname-relative asset paths in OAuth plugins (opencode-openai-codex-auth, opencode-antigravity-auth). Upstream imports directly from node_modules. Local plugins still use bundleLocalPlugin() for file:// paths." + }, + { + "pr": 0, + "title": "Shuvcode upgrade and auto-update functionality", + "author": "fork", + "status": "fork-only", + "description": "Fork-specific package name, npm registry, GitHub releases URL, and install script for shuvcode upgrade command. Uses shuvcode npm package instead of opencode-ai, checks Latitudes-Dev/shuvcode GitHub releases, and uses shuv.ai install script.", + "files": [ + "packages/opencode/src/installation/index.ts", + "packages/opencode/src/cli/cmd/tui/component/tips.ts" + ], + "criticalCode": [ + { + "file": "packages/opencode/src/installation/index.ts", + "description": "PACKAGE_NAME constant for shuvcode npm package", + "markers": ["const PACKAGE_NAME = \"shuvcode\""] + }, + { + "file": "packages/opencode/src/installation/index.ts", + "description": "npm/bun/pnpm install commands use shuvcode package", + "markers": ["${PACKAGE_NAME}@${target}"] + }, + { + "file": "packages/opencode/src/installation/index.ts", + "description": "curl install uses shuv.ai install script", + "markers": ["https://shuv.ai/install"] + }, + { + "file": "packages/opencode/src/installation/index.ts", + "description": "npm registry check uses shuvcode package", + "markers": ["${registry}/${PACKAGE_NAME}/${channel}"] + }, + { + "file": "packages/opencode/src/installation/index.ts", + "description": "GitHub releases check uses Latitudes-Dev/shuvcode", + "markers": ["https://api.github.com/repos/Latitudes-Dev/shuvcode/releases/latest"] + }, + { + "file": "packages/opencode/src/installation/index.ts", + "description": "USER_AGENT uses shuvcode package name", + "markers": ["${PACKAGE_NAME}/${CHANNEL}/${VERSION}"] + }, + { + "file": "packages/opencode/src/cli/cmd/tui/component/tips.ts", + "description": "Tips reference shuvcode command and shuv.ai", + "markers": ["shuvcode upgrade", "shuv.ai", "ghcr.io/latitudes-dev/shuvcode"] + } + ], + "note": "CRITICAL: Upstream merges frequently overwrite these with anomalyco/opencode and opencode-ai references. Always verify after merge." } ] }