Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/app/src/custom-elements.d.ts
3 changes: 2 additions & 1 deletion packages/enterprise/src/custom-elements.d.ts
172 changes: 171 additions & 1 deletion packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,151 @@ import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { existsSync } from "fs"

type PaneSource = "option" | "positional"

const PANE_COUNTS = new Set([2, 4, 6])

function parsePaneCount(value: string | number | undefined): number | null {
if (value === undefined || value === null) return null
const count = typeof value === "number" ? value : Number(value)
if (!Number.isInteger(count)) return null
return PANE_COUNTS.has(count) ? count : null
}

function buildChildArgs(
rawArgs: string[],
paneCount: number,
paneSource: PaneSource,
): string[] {
const result: string[] = []
let removedPositional = false
let skipNext = false
let seenDoubleDash = false
const paneString = String(paneCount)

for (const arg of rawArgs) {
if (skipNext) {
skipNext = false
continue
}
if (arg === "--") {
seenDoubleDash = true
result.push(arg)
continue
}
if (!seenDoubleDash && paneSource === "option") {
if (arg === "--panes") {
skipNext = true
continue
}
if (arg.startsWith("--panes=")) {
continue
}
}
if (!seenDoubleDash && paneSource === "positional" && !removedPositional && !arg.startsWith("-") && arg === paneString) {
removedPositional = true
continue
}
result.push(arg)
}

return result
}

function buildSplitCommand(
direction: "V" | "H",
size: string,
cwd: string,
profileId: string | undefined,
childArgs: string[],
): string[] {
const args = ["split-pane", `-${direction}`, "--size", size]
if (profileId) args.push("-p", profileId)
args.push("-d", cwd, "opencode", ...childArgs)
return args
}

function buildPaneLayout(
paneCount: number,
cwd: string,
profileId: string | undefined,
childArgs: string[],
): string[] | null {
const splitV33 = buildSplitCommand("V", "0.33", cwd, profileId, childArgs)
const splitV50 = buildSplitCommand("V", "0.5", cwd, profileId, childArgs)
const splitH50 = buildSplitCommand("H", "0.5", cwd, profileId, childArgs)

if (paneCount === 2) {
return ["-w", "0", ...splitV50]
}
if (paneCount === 4) {
return [
"-w",
"0",
...splitV50,
";",
"move-focus",
"left",
";",
...splitH50,
";",
"move-focus",
"right",
";",
...splitH50,
]
}
if (paneCount === 6) {
return [
"-w",
"0",
...splitV33,
";",
...splitV50,
";",
...splitH50,
";",
"move-focus",
"left",
";",
...splitH50,
";",
"move-focus",
"left",
";",
...splitH50,
]
}
return null
}

function splitWindowsTerminalPanes(
paneCount: number,
cwd: string,
childArgs: string[],
): boolean {
if (process.platform !== "win32") return false
if (!process.env.WT_SESSION) return false
const wt = Bun.which("wt") ?? Bun.which("wt.exe")
if (!wt) return false

const profileId = process.env.WT_PROFILE_ID
const layout = buildPaneLayout(paneCount, cwd, profileId, childArgs)
if (!layout) return false

try {
Bun.spawn({
cmd: [wt, ...layout],
stdout: "ignore",
stderr: "ignore",
})
return true
} catch {
return false
}
}

declare global {
const OPENCODE_WORKER_PATH: string
Expand All @@ -21,6 +166,10 @@ export const TuiThreadCommand = cmd({
type: "string",
describe: "path to start opencode in",
})
.option("panes", {
type: "number",
describe: "open multiple Windows Terminal panes (2, 4, 6)",
})
.option("model", {
type: "string",
alias: ["m"],
Expand All @@ -47,14 +196,35 @@ export const TuiThreadCommand = cmd({
handler: async (args) => {
// 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()
const rawArgs = process.argv.slice(2)
const paneFromOption = parsePaneCount(args.panes)
let paneCount = paneFromOption
let paneSource: PaneSource | null = paneFromOption ? "option" : null
let projectArg = args.project
if (!paneCount) {
const fromProject = parsePaneCount(args.project)
if (fromProject) {
const candidate = path.resolve(baseCwd, String(args.project))
if (!existsSync(candidate)) {
paneCount = fromProject
paneSource = "positional"
projectArg = undefined
}
}
}
const cwd = projectArg ? path.resolve(baseCwd, projectArg) : process.cwd()
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
if (paneCount && paneSource) {
const childArgs = buildChildArgs(rawArgs, paneCount, paneSource)
splitWindowsTerminalPanes(paneCount, cwd, childArgs)
}

try {
process.chdir(cwd)
} catch (e) {
Expand Down
24 changes: 21 additions & 3 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
Expand All @@ -23,6 +24,24 @@ import { existsSync } from "fs"
export namespace Config {
const log = Log.create({ service: "config" })

function resolvePluginSpecifier(plugin: string, configFilepath: string) {
const baseUrl = pathToFileURL(configFilepath).href
try {
return import.meta.resolve(plugin, baseUrl)
} catch {}
try {
const req = createRequire(configFilepath)
return pathToFileURL(req.resolve(plugin)).href
} catch {}
try {
const resolved = Bun.resolveSync(plugin, configFilepath)
return resolved.startsWith("file://")
? resolved
: pathToFileURL(resolved).href
} catch {}
return null
}

// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
Expand Down Expand Up @@ -1170,9 +1189,8 @@ export namespace Config {
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
} catch (err) {}
const resolved = resolvePluginSpecifier(plugin, configFilepath)
if (resolved) data.plugin[i] = resolved
}
}
return data
Expand Down
9 changes: 4 additions & 5 deletions packages/opencode/src/file/ignore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { sep } from "node:path"

export namespace FileIgnore {
const FOLDERS = new Set([
"node_modules",
Expand Down Expand Up @@ -64,18 +62,19 @@ export namespace FileIgnore {
whitelist?: Bun.Glob[]
},
) {
const normalized = filepath.replaceAll("\\", "/")
for (const glob of opts?.whitelist || []) {
if (glob.match(filepath)) return false
if (glob.match(normalized)) return false
}

const parts = filepath.split(sep)
const parts = normalized.split("/")
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}

const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
if (glob.match(filepath)) return true
if (glob.match(normalized)) return true
}

return false
Expand Down
20 changes: 12 additions & 8 deletions packages/opencode/src/patch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,25 +76,29 @@ export namespace Patch {
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
const line = lines[startIdx]
const addPrefix = "*** Add File:"
const deletePrefix = "*** Delete File:"
const updatePrefix = "*** Update File:"
const movePrefix = "*** Move to:"

if (line.startsWith("*** Add File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (line.startsWith(addPrefix)) {
const filePath = line.slice(addPrefix.length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}

if (line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (line.startsWith(deletePrefix)) {
const filePath = line.slice(deletePrefix.length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}

if (line.startsWith("*** Update File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (line.startsWith(updatePrefix)) {
const filePath = line.slice(updatePrefix.length).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()
if (nextIdx < lines.length && lines[nextIdx].startsWith(movePrefix)) {
movePath = lines[nextIdx].slice(movePrefix.length).trim()
nextIdx++
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export namespace Project {
.then((x) => {
const dirname = path.dirname(x.trim())
if (dirname === ".") return sandbox
return dirname
return path.resolve(sandbox, dirname)
})
.catch(() => undefined)

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const BashTool = Tool.define("bash", async () => {
.nothrow()
.text()
.then((x) => x.trim())
.then((x) => (x ? x : path.resolve(cwd, arg)))
log.info("resolved path", { arg, resolved })
if (resolved) {
// Git Bash on Windows returns Unix-style paths like /c/Users/...
Expand Down
22 changes: 19 additions & 3 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { createRequire } from "module"

test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
Expand Down Expand Up @@ -400,9 +401,24 @@ test("resolves scoped npm plugins in config", async () => {
const pluginEntries = config.plugin ?? []

const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
const expected = import.meta.resolve("@scope/plugin", baseUrl)

expect(pluginEntries.includes(expected)).toBe(true)
let expected: string | null = null
try {
expected = import.meta.resolve("@scope/plugin", baseUrl)
} catch {}
if (!expected) {
try {
expected = pathToFileURL(createRequire(baseUrl).resolve("@scope/plugin")).href
} catch {}
}
if (!expected) {
try {
expected = pathToFileURL(Bun.resolveSync("@scope/plugin", path.join(tmp.path, "opencode.json"))).href
} catch {}
}

expect(expected).toBeTruthy()

expect(pluginEntries.includes(expected!)).toBe(true)

const scopedEntry = pluginEntries.find((entry) => entry === expected)
expect(scopedEntry).toBeDefined()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/ide/ide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test"
import { Ide } from "../../src/ide"

describe("ide", () => {
const original = structuredClone(process.env)
const original = Object.fromEntries(Object.entries(process.env))

afterEach(() => {
Object.keys(process.env).forEach((key) => {
Expand Down
Loading