mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 19:24:22 +01:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.44",
|
||||
"version": "1.0.46",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -77,8 +77,7 @@ async function regenerateWindowsCmdWrappers() {
|
||||
|
||||
// npm_config_global is string | undefined
|
||||
// if it exists, the value is true
|
||||
const isGlobal =
|
||||
process.env.npm_config_global === "true" || pkgPath.includes(path.join("npm", "node_modules"))
|
||||
const isGlobal = process.env.npm_config_global === "true" || pkgPath.includes(path.join("npm", "node_modules"))
|
||||
|
||||
// The npm rebuild command does 2 things - Execute lifecycle scripts and rebuild bin links
|
||||
// We want to skip lifecycle scripts to avoid infinite loops, so we use --ignore-scripts
|
||||
@@ -94,9 +93,7 @@ async function regenerateWindowsCmdWrappers() {
|
||||
console.log("Successfully rebuilt npm bin links")
|
||||
} catch (error) {
|
||||
console.error("Error rebuilding npm links:", error.message)
|
||||
console.error(
|
||||
"npm rebuild failed. You may need to manually run: npm rebuild opencode-ai --ignore-scripts",
|
||||
)
|
||||
console.error("npm rebuild failed. You may need to manually run: npm rebuild opencode-ai --ignore-scripts")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -19,23 +19,12 @@ const result = z.toJSONSchema(Config.Info, {
|
||||
const schema = ctx.jsonSchema
|
||||
|
||||
// Preserve strictness: set additionalProperties: false for objects
|
||||
if (
|
||||
schema &&
|
||||
typeof schema === "object" &&
|
||||
schema.type === "object" &&
|
||||
schema.additionalProperties === undefined
|
||||
) {
|
||||
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
|
||||
schema.additionalProperties = false
|
||||
}
|
||||
|
||||
// Add examples and default descriptions for string fields with defaults
|
||||
if (
|
||||
schema &&
|
||||
typeof schema === "object" &&
|
||||
"type" in schema &&
|
||||
schema.type === "string" &&
|
||||
schema?.default
|
||||
) {
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
@@ -199,10 +199,8 @@ export namespace ACP {
|
||||
|
||||
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 filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
@@ -218,9 +216,7 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z
|
||||
.array(Todo.Info)
|
||||
.safeParse(JSON.parse(part.state.output))
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
@@ -229,9 +225,7 @@ export namespace ACP {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled"
|
||||
? "completed"
|
||||
: (todo.status as PlanEntry["status"])
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
@@ -481,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) {
|
||||
@@ -587,8 +580,7 @@ export namespace ACP {
|
||||
const agent = session.modeId ?? "build"
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
|
||||
> = []
|
||||
for (const part of params.prompt) {
|
||||
switch (part.type) {
|
||||
@@ -794,9 +786,7 @@ export namespace ACP {
|
||||
|
||||
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)
|
||||
|
||||
@@ -13,11 +13,7 @@ export class ACPSessionManager {
|
||||
this.sdk = sdk
|
||||
}
|
||||
|
||||
async create(
|
||||
cwd: string,
|
||||
mcpServers: McpServer[],
|
||||
model?: ACPSessionState["model"],
|
||||
): Promise<ACPSessionState> {
|
||||
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
|
||||
const session = await this.sdk.session
|
||||
.create({
|
||||
body: {
|
||||
|
||||
@@ -143,18 +143,7 @@ export namespace Agent {
|
||||
tools: {},
|
||||
builtIn: false,
|
||||
}
|
||||
const {
|
||||
name,
|
||||
model,
|
||||
prompt,
|
||||
tools,
|
||||
description,
|
||||
temperature,
|
||||
top_p,
|
||||
mode,
|
||||
permission,
|
||||
...extra
|
||||
} = value
|
||||
const { name, model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
|
||||
item.options = {
|
||||
...item.options,
|
||||
...extra,
|
||||
@@ -223,10 +212,7 @@ export namespace Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAgentPermissions(
|
||||
basePermission: any,
|
||||
overridePermission: any,
|
||||
): Agent.Info["permission"] {
|
||||
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
|
||||
if (typeof basePermission.bash === "string") {
|
||||
basePermission.bash = {
|
||||
"*": basePermission.bash,
|
||||
|
||||
@@ -8,10 +8,7 @@ import { readableStreamToText } from "bun"
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(
|
||||
cmd: string[],
|
||||
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
|
||||
) {
|
||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
...options,
|
||||
|
||||
@@ -19,10 +19,7 @@ export namespace Bus {
|
||||
|
||||
const registry = new Map<string, EventDefinition>()
|
||||
|
||||
export function event<Type extends string, Properties extends ZodType>(
|
||||
type: Type,
|
||||
properties: Properties,
|
||||
) {
|
||||
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
@@ -73,10 +70,7 @@ export namespace Bus {
|
||||
|
||||
export function subscribe<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => void,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,7 @@ export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(AuthLoginCommand)
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -64,9 +60,7 @@ export const AuthListCommand = cmd({
|
||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
prompts.outro(
|
||||
`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"),
|
||||
)
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -86,9 +80,7 @@ export const AuthLoginCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then(
|
||||
(x) => x.json() as any,
|
||||
)
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
@@ -290,8 +282,7 @@ export const AuthLoginCommand = cmd({
|
||||
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",
|
||||
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\//, "")
|
||||
|
||||
@@ -7,11 +7,7 @@ import { EOL } from "os"
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(DiagnosticsCommand)
|
||||
.command(SymbolsCommand)
|
||||
.command(DocumentSymbolsCommand)
|
||||
.demandCommand(),
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
builder: (yargs) =>
|
||||
yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -19,9 +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)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,8 +4,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
builder: (yargs) =>
|
||||
yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
|
||||
@@ -189,9 +189,7 @@ export const GithubInstallCommand = cmd({
|
||||
async function getAppInfo() {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") {
|
||||
prompts.log.error(
|
||||
`Could not find git repository. Please run this command from a git repository.`,
|
||||
)
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
@@ -204,13 +202,9 @@ export const GithubInstallCommand = cmd({
|
||||
// ie. git@github.com:sst/opencode
|
||||
// ie. ssh://git@github.com/sst/opencode.git
|
||||
// ie. ssh://git@github.com/sst/opencode
|
||||
const parsed = info.match(
|
||||
/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/,
|
||||
)
|
||||
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
|
||||
if (!parsed) {
|
||||
prompts.log.error(
|
||||
`Could not find git repository. Please run this command from a git repository.`,
|
||||
)
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
const [, owner, repo] = parsed
|
||||
@@ -451,9 +445,7 @@ export const GithubRunCommand = cmd({
|
||||
const summary = await summarize(response)
|
||||
await pushToLocalBranch(summary)
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) =>
|
||||
c.body.includes(`${shareBaseUrl}/s/${shareId}`),
|
||||
)
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
|
||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||
}
|
||||
// Fork PR
|
||||
@@ -465,9 +457,7 @@ export const GithubRunCommand = cmd({
|
||||
const summary = await summarize(response)
|
||||
await pushToForkBranch(summary, prData)
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) =>
|
||||
c.body.includes(`${shareBaseUrl}/s/${shareId}`),
|
||||
)
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
|
||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||
}
|
||||
}
|
||||
@@ -557,12 +547,8 @@ export const GithubRunCommand = cmd({
|
||||
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
|
||||
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
|
||||
// ie. 
|
||||
const mdMatches = prompt.matchAll(
|
||||
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
|
||||
)
|
||||
const tagMatches = prompt.matchAll(
|
||||
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
|
||||
)
|
||||
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
|
||||
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
|
||||
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
|
||||
console.log("Images", JSON.stringify(matches, null, 2))
|
||||
|
||||
@@ -587,10 +573,7 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Replace img tag with file path, ie. @image.png
|
||||
const replacement = `@${filename}`
|
||||
prompt =
|
||||
prompt.slice(0, start + offset) +
|
||||
replacement +
|
||||
prompt.slice(start + offset + tag.length)
|
||||
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
|
||||
offset += replacement.length - tag.length
|
||||
|
||||
const contentType = res.headers.get("content-type")
|
||||
@@ -873,8 +856,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||
}
|
||||
|
||||
if (!["admin", "write"].includes(permission))
|
||||
throw new Error(`User ${actor} does not have write permissions`)
|
||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
||||
}
|
||||
|
||||
async function createComment() {
|
||||
@@ -922,9 +904,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
|
||||
return `<a href="${shareBaseUrl}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
|
||||
})()
|
||||
const shareUrl = shareId
|
||||
? `[opencode session](${shareBaseUrl}/s/${shareId}) | `
|
||||
: ""
|
||||
const shareUrl = shareId ? `[opencode session](${shareBaseUrl}/s/${shareId}) | ` : ""
|
||||
return `\n\n${image}${shareUrl}[github run](${runUrl})`
|
||||
}
|
||||
|
||||
@@ -1100,13 +1080,9 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
})
|
||||
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
||||
|
||||
const files = (pr.files.nodes || []).map(
|
||||
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
|
||||
)
|
||||
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
|
||||
const reviewData = (pr.reviews.nodes || []).map((r) => {
|
||||
const comments = (r.comments.nodes || []).map(
|
||||
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
|
||||
)
|
||||
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
|
||||
return [
|
||||
`- ${r.author.login} at ${r.submittedAt}:`,
|
||||
` - Review body: ${r.body}`,
|
||||
@@ -1128,15 +1104,9 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
`Deletions: ${pr.deletions}`,
|
||||
`Total Commits: ${pr.commits.totalCount}`,
|
||||
`Changed Files: ${pr.files.nodes.length} files`,
|
||||
...(comments.length > 0
|
||||
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
|
||||
: []),
|
||||
...(files.length > 0
|
||||
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
|
||||
: []),
|
||||
...(reviewData.length > 0
|
||||
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
|
||||
: []),
|
||||
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
|
||||
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
|
||||
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
|
||||
"</pull_request>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
@@ -138,9 +138,7 @@ export const RunCommand = cmd({
|
||||
|
||||
const outputJsonEvent = (type: string, data: any) => {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL,
|
||||
)
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -160,9 +158,7 @@ export const RunCommand = cmd({
|
||||
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")
|
||||
(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()
|
||||
@@ -215,10 +211,7 @@ export const RunCommand = cmd({
|
||||
],
|
||||
initialValue: "once",
|
||||
}).catch(() => "reject")
|
||||
const response = (result.toString().includes("cancel") ? "reject" : result) as
|
||||
| "once"
|
||||
| "always"
|
||||
| "reject"
|
||||
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
|
||||
await sdk.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionID, permissionID: permission.id },
|
||||
body: { response },
|
||||
@@ -280,10 +273,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
|
||||
const cfgResult = await sdk.config.get()
|
||||
if (
|
||||
cfgResult.data &&
|
||||
(cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)
|
||||
) {
|
||||
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)
|
||||
@@ -336,10 +326,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
|
||||
const cfgResult = await sdk.config.get()
|
||||
if (
|
||||
cfgResult.data &&
|
||||
(cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
@@ -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)}%)`
|
||||
|
||||
@@ -2,16 +2,7 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
untrack,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
} from "solid-js"
|
||||
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"
|
||||
@@ -111,11 +102,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onExit={onExit} />
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} />}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
@@ -440,12 +427,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{" "}
|
||||
@@ -461,11 +443,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>
|
||||
|
||||
@@ -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,12 +59,9 @@ 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)),
|
||||
),
|
||||
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,9 +20,7 @@ export function DialogSessionList() {
|
||||
|
||||
const deleteKeybind = "ctrl+d"
|
||||
|
||||
const currentSessionID = createMemo(() =>
|
||||
route.data.type === "session" ? route.data.sessionID : undefined,
|
||||
)
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -3,19 +3,9 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
|
||||
const LOGO_LEFT = [
|
||||
` `,
|
||||
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄`,
|
||||
`█░░█ █░░█ █▀▀▀ █░░█`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`,
|
||||
]
|
||||
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
|
||||
|
||||
const LOGO_RIGHT = [
|
||||
` ▄ `,
|
||||
`█▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
||||
`█░░░ █░░█ █░░█ █▀▀▀`,
|
||||
`▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||
]
|
||||
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
@@ -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,
|
||||
@@ -185,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()
|
||||
@@ -324,9 +317,7 @@ 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)
|
||||
@@ -393,9 +384,7 @@ export function Autocomplete(props: {
|
||||
return
|
||||
}
|
||||
// Check if a space was typed after the trigger character
|
||||
const currentText = props
|
||||
.input()
|
||||
.getTextRange(store.index + 1, props.input().cursorOffset + 1)
|
||||
const currentText = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
|
||||
if (currentText.includes(" ")) {
|
||||
hide()
|
||||
}
|
||||
@@ -433,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("@")
|
||||
}
|
||||
|
||||
@@ -487,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>
|
||||
|
||||
@@ -324,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)
|
||||
@@ -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
|
||||
@@ -565,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
|
||||
@@ -578,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)
|
||||
@@ -595,12 +576,8 @@ 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
|
||||
}
|
||||
}}
|
||||
@@ -691,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}>
|
||||
@@ -717,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>
|
||||
|
||||
@@ -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
|
||||
@@ -195,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
|
||||
|
||||
@@ -146,12 +146,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
result.index,
|
||||
reconcile(event.properties.info),
|
||||
)
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
@@ -186,12 +181,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
result.index,
|
||||
reconcile(event.properties.part),
|
||||
)
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
|
||||
@@ -196,7 +196,7 @@ 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.map((x) => RGBA.fromHex(x!))
|
||||
const palette = colors.palette.filter((x) => x !== null).map((x) => RGBA.fromHex(x))
|
||||
const isDark = mode == "dark"
|
||||
|
||||
// Generate gray scale based on terminal background
|
||||
@@ -528,13 +528,7 @@ function generateSyntax(theme: Theme) {
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable.builtin",
|
||||
"type.builtin",
|
||||
"function.builtin",
|
||||
"module.builtin",
|
||||
"constant.builtin",
|
||||
],
|
||||
scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"],
|
||||
style: {
|
||||
foreground: theme.error,
|
||||
},
|
||||
|
||||
@@ -30,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>
|
||||
@@ -53,14 +49,7 @@ export function Home() {
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useRoute } from "@tui/context/route"
|
||||
export function DialogMessage(props: { messageID: string; sessionID: string }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const message = createMemo(() =>
|
||||
sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID),
|
||||
)
|
||||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
const route = useRoute()
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,9 +19,7 @@ export function DialogTimeline(props: { sessionID: string; onMove: (messageID: s
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic,
|
||||
) as TextPart
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
@@ -35,11 +33,5 @@ export function DialogTimeline(props: { sessionID: string; onMove: (messageID: s
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
onMove={(option) => props.onMove(option.value)}
|
||||
title="Timeline"
|
||||
options={options()}
|
||||
/>
|
||||
)
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -19,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"
|
||||
@@ -41,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"
|
||||
@@ -653,14 +640,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}>
|
||||
@@ -675,19 +655,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>
|
||||
@@ -743,18 +717,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}>
|
||||
@@ -763,16 +731,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>
|
||||
)}
|
||||
@@ -792,9 +754,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] ?? []}
|
||||
@@ -850,9 +810,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()
|
||||
@@ -893,14 +851,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>
|
||||
)
|
||||
}}
|
||||
@@ -911,16 +863,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>
|
||||
@@ -960,8 +905,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
|
||||
@@ -973,9 +917,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>
|
||||
@@ -987,9 +929,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>
|
||||
@@ -1016,12 +956,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
|
||||
<text fg={theme.text}>{props.part.text.trim()}</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -1261,16 +1196,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>
|
||||
</>
|
||||
@@ -1285,8 +1214,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>
|
||||
</>
|
||||
@@ -1300,8 +1228,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>
|
||||
)
|
||||
@@ -1337,11 +1264,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}>
|
||||
|
||||
@@ -22,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(),
|
||||
@@ -84,9 +78,7 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
<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 === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
@@ -162,9 +154,7 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
</text>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text
|
||||
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
|
||||
>
|
||||
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -41,12 +41,7 @@ export const TuiSpawnCommand = cmd({
|
||||
)
|
||||
cwd = new URL("../../../../", import.meta.url).pathname
|
||||
} else cmd.push(process.execPath)
|
||||
cmd.push(
|
||||
"attach",
|
||||
server.url.toString(),
|
||||
"--dir",
|
||||
args.project ? path.resolve(args.project) : process.cwd(),
|
||||
)
|
||||
cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
|
||||
@@ -71,9 +71,7 @@ export const TuiThreadCommand = cmd({
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||
),
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
})
|
||||
worker.onerror = console.error
|
||||
|
||||
@@ -53,9 +53,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.background : theme.textMuted}>
|
||||
{Locale.titlecase(key)}
|
||||
</text>
|
||||
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,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
|
||||
@@ -57,8 +57,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const result = pipe(
|
||||
props.options,
|
||||
filter((x) => x.disabled !== true),
|
||||
(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
|
||||
})
|
||||
@@ -173,7 +172,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
@@ -214,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}
|
||||
@@ -230,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)}
|
||||
/>
|
||||
@@ -248,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>
|
||||
)}
|
||||
@@ -265,7 +255,7 @@ function Option(props: {
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: string
|
||||
footer?: JSX.Element | string
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
@@ -284,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}>
|
||||
|
||||
@@ -5,10 +5,7 @@ import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: {
|
||||
value: string
|
||||
renderer: CliRenderer
|
||||
}): Promise<string | undefined> {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
const editor = process.env["EDITOR"]
|
||||
if (!editor) return
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ export const UpgradeCommand = {
|
||||
const detectedMethod = await Installation.method()
|
||||
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(
|
||||
`opencode is installed to ${process.execPath} and may be managed by a package manager`,
|
||||
)
|
||||
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
|
||||
const install = await prompts.select({
|
||||
message: "Install anyways?",
|
||||
options: [
|
||||
|
||||
@@ -56,11 +56,7 @@ export const WebCommand = cmd({
|
||||
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,
|
||||
)
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
|
||||
|
||||
// Show network IPs for remote access
|
||||
const networkIPs = getNetworkIPs()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,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,
|
||||
},
|
||||
@@ -330,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()
|
||||
@@ -389,70 +371,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"),
|
||||
@@ -463,11 +405,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"),
|
||||
@@ -476,23 +414,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({
|
||||
@@ -534,23 +460,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()
|
||||
@@ -583,10 +499,7 @@ export namespace Config {
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
enterpriseUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("GitHub Enterprise URL for copilot authentication"),
|
||||
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
|
||||
timeout: z
|
||||
.union([
|
||||
z
|
||||
@@ -610,10 +523,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(),
|
||||
@@ -657,10 +567,7 @@ 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({
|
||||
@@ -694,10 +601,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(),
|
||||
@@ -727,10 +631,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(() => {})
|
||||
@@ -769,9 +670,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()
|
||||
|
||||
@@ -81,9 +81,7 @@ export namespace Fzf {
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(
|
||||
new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])),
|
||||
)
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let fzfEntry: any
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -161,9 +161,7 @@ export namespace Ripgrep {
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(
|
||||
new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])),
|
||||
)
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
@@ -356,12 +354,7 @@ export namespace Ripgrep {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
}) {
|
||||
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
|
||||
|
||||
if (input.glob) {
|
||||
|
||||
@@ -27,10 +27,7 @@ export namespace FileTime {
|
||||
|
||||
export async function assert(sessionID: string, filepath: string) {
|
||||
const time = get(sessionID, filepath)
|
||||
if (!time)
|
||||
throw new Error(
|
||||
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
|
||||
)
|
||||
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const stats = await Bun.file(filepath).stat()
|
||||
if (stats.mtime.getTime() > time.getTime()) {
|
||||
throw new Error(
|
||||
|
||||
@@ -51,10 +51,8 @@ export namespace FileWatcher {
|
||||
for (const evt of evts) {
|
||||
log.info("event", evt)
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update")
|
||||
Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete")
|
||||
Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -132,21 +132,7 @@ export const zig: Info = {
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".cxx",
|
||||
".c++",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".hxx",
|
||||
".h++",
|
||||
".ino",
|
||||
".C",
|
||||
".H",
|
||||
],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
|
||||
@@ -49,11 +49,7 @@ export namespace Identifier {
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(
|
||||
prefix: keyof typeof prefixes,
|
||||
descending: boolean,
|
||||
timestamp?: number,
|
||||
): string {
|
||||
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
|
||||
@@ -44,10 +44,7 @@ export namespace Ide {
|
||||
}
|
||||
|
||||
export function alreadyInstalled() {
|
||||
return (
|
||||
process.env["OPENCODE_CALLER"] === "vscode" ||
|
||||
process.env["OPENCODE_CALLER"] === "vscode-insiders"
|
||||
)
|
||||
return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders"
|
||||
}
|
||||
|
||||
export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import path from "path"
|
||||
import {
|
||||
createMessageConnection,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
} from "vscode-jsonrpc/node"
|
||||
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
||||
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
||||
import { Log } from "../util/log"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
@@ -38,11 +34,7 @@ export namespace LSPClient {
|
||||
),
|
||||
}
|
||||
|
||||
export async function create(input: {
|
||||
serverID: string
|
||||
server: LSPServer.Handle
|
||||
root: string
|
||||
}) {
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
||||
const l = log.clone().tag("serverID", input.serverID)
|
||||
l.info("starting client")
|
||||
|
||||
@@ -70,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")
|
||||
@@ -137,9 +137,7 @@ export namespace LSPClient {
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(Instance.directory, input.path)
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const extension = path.extname(input.path)
|
||||
@@ -181,18 +179,13 @@ export namespace LSPClient {
|
||||
return diagnostics
|
||||
},
|
||||
async waitForDiagnostics(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(Instance.directory, input.path)
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||
log.info("waiting for diagnostics", input)
|
||||
let unsub: () => void
|
||||
return await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (
|
||||
event.properties.path === input.path &&
|
||||
event.properties.serverID === result.serverID
|
||||
) {
|
||||
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
|
||||
log.info("got diagnostics", input)
|
||||
unsub?.()
|
||||
resolve()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -104,10 +104,7 @@ export namespace Patch {
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUpdateFileChunks(
|
||||
lines: string[],
|
||||
startIdx: number,
|
||||
): { chunks: UpdateFileChunk[]; nextIdx: number } {
|
||||
function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
|
||||
const chunks: UpdateFileChunk[] = []
|
||||
let i = startIdx
|
||||
|
||||
@@ -161,10 +158,7 @@ export namespace Patch {
|
||||
return { chunks, nextIdx: i }
|
||||
}
|
||||
|
||||
function parseAddFileContent(
|
||||
lines: string[],
|
||||
startIdx: number,
|
||||
): { content: string; nextIdx: number } {
|
||||
function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
|
||||
let content = ""
|
||||
let i = startIdx
|
||||
|
||||
@@ -303,10 +297,7 @@ export namespace Patch {
|
||||
content: string
|
||||
}
|
||||
|
||||
export function deriveNewContentsFromChunks(
|
||||
filePath: string,
|
||||
chunks: UpdateFileChunk[],
|
||||
): ApplyPatchFileUpdate {
|
||||
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
|
||||
// Read original file content
|
||||
let originalContent: string
|
||||
try {
|
||||
@@ -387,9 +378,7 @@ export namespace Patch {
|
||||
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")}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,10 +388,7 @@ export namespace Patch {
|
||||
return replacements
|
||||
}
|
||||
|
||||
function applyReplacements(
|
||||
lines: string[],
|
||||
replacements: Array<[number, number, string[]]>,
|
||||
): string[] {
|
||||
function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
|
||||
// Apply replacements in reverse order to avoid index shifting
|
||||
const result = [...lines]
|
||||
|
||||
@@ -601,9 +587,7 @@ export namespace Patch {
|
||||
changes.set(resolvedPath, {
|
||||
type: "update",
|
||||
unified_diff: fileUpdate.unified_diff,
|
||||
move_path: hunk.move_path
|
||||
? path.resolve(effectiveCwd, hunk.move_path)
|
||||
: undefined,
|
||||
move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
|
||||
new_content: fileUpdate.content,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -75,14 +75,7 @@ export namespace Permission {
|
||||
async (state) => {
|
||||
for (const pending of Object.values(state.pending)) {
|
||||
for (const item of Object.values(pending)) {
|
||||
item.reject(
|
||||
new RejectedError(
|
||||
item.info.sessionID,
|
||||
item.info.id,
|
||||
item.info.callID,
|
||||
item.info.metadata,
|
||||
),
|
||||
)
|
||||
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -150,11 +143,7 @@ export namespace Permission {
|
||||
export const Response = z.enum(["once", "always", "reject"])
|
||||
export type Response = z.infer<typeof Response>
|
||||
|
||||
export function respond(input: {
|
||||
sessionID: Info["sessionID"]
|
||||
permissionID: Info["id"]
|
||||
response: Response
|
||||
}) {
|
||||
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
|
||||
log.info("response", input)
|
||||
const { pending, approved } = state()
|
||||
const match = pending[input.sessionID]?.[input.permissionID]
|
||||
@@ -166,14 +155,7 @@ export namespace Permission {
|
||||
response: input.response,
|
||||
})
|
||||
if (input.response === "reject") {
|
||||
match.reject(
|
||||
new RejectedError(
|
||||
input.sessionID,
|
||||
input.permissionID,
|
||||
match.info.callID,
|
||||
match.info.metadata,
|
||||
),
|
||||
)
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
|
||||
return
|
||||
}
|
||||
match.resolve()
|
||||
@@ -205,9 +187,7 @@ export namespace Permission {
|
||||
public readonly toolCallID?: string,
|
||||
public readonly metadata?: Record<string, any>,
|
||||
) {
|
||||
super(
|
||||
`The user rejected permission to use this specific tool call. You may try again with different parameters.`,
|
||||
)
|
||||
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ const context = Context.create<Context>("instance")
|
||||
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) {
|
||||
existing = iife(async () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
@@ -125,13 +119,9 @@ export namespace Provider {
|
||||
"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}`
|
||||
}
|
||||
@@ -141,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"
|
||||
@@ -187,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 {
|
||||
@@ -208,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 {
|
||||
@@ -409,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")
|
||||
}
|
||||
|
||||
@@ -443,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",
|
||||
),
|
||||
)
|
||||
@@ -497,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
|
||||
@@ -598,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")
|
||||
|
||||
@@ -3,19 +3,12 @@ import { unique } from "remeda"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
|
||||
export namespace ProviderTransform {
|
||||
function normalizeMessages(
|
||||
msgs: ModelMessage[],
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
): ModelMessage[] {
|
||||
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
|
||||
) {
|
||||
if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {
|
||||
return {
|
||||
...part,
|
||||
toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),
|
||||
@@ -36,10 +29,7 @@ export namespace ProviderTransform {
|
||||
|
||||
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
|
||||
) {
|
||||
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
|
||||
@@ -96,8 +86,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
const shouldUseContentOptions =
|
||||
providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
|
||||
const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
|
||||
|
||||
if (shouldUseContentOptions) {
|
||||
const lastContent = msg.content[msg.content.length - 1]
|
||||
@@ -139,11 +128,7 @@ export namespace ProviderTransform {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function options(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
sessionID: string,
|
||||
): Record<string, any> | undefined {
|
||||
export function options(providerID: string, modelID: string, sessionID: string): Record<string, any> | undefined {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
if (providerID === "openai") {
|
||||
@@ -168,11 +153,7 @@ export namespace ProviderTransform {
|
||||
return result
|
||||
}
|
||||
|
||||
export function providerOptions(
|
||||
npm: string | undefined,
|
||||
providerID: string,
|
||||
options: { [x: string]: any },
|
||||
) {
|
||||
export function providerOptions(npm: string | undefined, providerID: string, options: { [x: string]: any }) {
|
||||
switch (npm) {
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/azure":
|
||||
@@ -205,8 +186,7 @@ export namespace ProviderTransform {
|
||||
|
||||
if (providerID === "anthropic") {
|
||||
const thinking = options?.["thinking"]
|
||||
const budgetTokens =
|
||||
typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
|
||||
const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
|
||||
const enabled = thinking?.["type"] === "enabled"
|
||||
if (enabled && budgetTokens > 0) {
|
||||
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
|
||||
|
||||
@@ -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"
|
||||
@@ -258,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,
|
||||
})),
|
||||
)
|
||||
},
|
||||
@@ -1088,7 +1080,7 @@ export namespace Server {
|
||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
default: [],
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1682,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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -30,17 +30,12 @@ export namespace SessionCompaction {
|
||||
),
|
||||
}
|
||||
|
||||
export function isOverflow(input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: ModelsDev.Model
|
||||
}) {
|
||||
export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: ModelsDev.Model }) {
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return false
|
||||
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
|
||||
const output =
|
||||
Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) ||
|
||||
SessionPrompt.OUTPUT_TOKEN_MAX
|
||||
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
|
||||
const usable = context - output
|
||||
return count > usable
|
||||
}
|
||||
@@ -92,15 +87,9 @@ export namespace SessionCompaction {
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(input: {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) {
|
||||
if (!input.signal) SessionLock.assertUnlocked(input.sessionID)
|
||||
await using lock =
|
||||
input.signal === undefined ? SessionLock.acquire({ sessionID: input.sessionID }) : undefined
|
||||
await using lock = input.signal === undefined ? SessionLock.acquire({ sessionID: input.sessionID }) : undefined
|
||||
const signal = input.signal ?? lock!.signal
|
||||
|
||||
await Session.update(input.sessionID, (draft) => {
|
||||
@@ -160,11 +149,7 @@ export namespace SessionCompaction {
|
||||
// set to 0, we handle loop
|
||||
maxRetries: 0,
|
||||
model: model.language,
|
||||
providerOptions: ProviderTransform.providerOptions(
|
||||
model.npm,
|
||||
model.providerID,
|
||||
model.info.options,
|
||||
),
|
||||
providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options),
|
||||
headers: model.info.headers,
|
||||
abortSignal: signal,
|
||||
onError(error) {
|
||||
@@ -244,11 +229,7 @@ export namespace SessionCompaction {
|
||||
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"),
|
||||
@@ -287,9 +268,7 @@ export namespace SessionCompaction {
|
||||
})
|
||||
if (result.shouldRetry) {
|
||||
for (let retry = 1; retry < maxRetries; retry++) {
|
||||
const lastRetryPart = result.parts.findLast(
|
||||
(p): p is MessageV2.RetryPart => 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)
|
||||
@@ -338,9 +317,7 @@ export namespace SessionCompaction {
|
||||
if (
|
||||
!msg.error ||
|
||||
(MessageV2.AbortedError.isInstance(msg.error) &&
|
||||
result.parts.some(
|
||||
(part): part is MessageV2.TextPart => 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, {
|
||||
|
||||
@@ -172,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,
|
||||
@@ -400,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,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,7 @@ import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Message } from "./message"
|
||||
import {
|
||||
APICallError,
|
||||
convertToModelMessages,
|
||||
LoadAPIKeyError,
|
||||
type ModelMessage,
|
||||
type UIMessage,
|
||||
} from "ai"
|
||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||
import { Identifier } from "../id/id"
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
@@ -17,10 +11,7 @@ import { Storage } from "@/storage/storage"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
export const AbortedError = NamedError.create(
|
||||
"MessageAbortedError",
|
||||
z.object({ message: z.string() }),
|
||||
)
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
@@ -253,12 +244,7 @@ export namespace MessageV2 {
|
||||
export type ToolStateError = z.infer<typeof ToolStateError>
|
||||
|
||||
export const ToolState = z
|
||||
.discriminatedUnion("status", [
|
||||
ToolStatePending,
|
||||
ToolStateRunning,
|
||||
ToolStateCompleted,
|
||||
ToolStateError,
|
||||
])
|
||||
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
.meta({
|
||||
ref: "ToolState",
|
||||
})
|
||||
@@ -454,8 +440,7 @@ export namespace MessageV2 {
|
||||
}
|
||||
}
|
||||
|
||||
const { title, time, ...metadata } =
|
||||
v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
|
||||
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
|
||||
if (part.toolInvocation.state === "call") {
|
||||
return {
|
||||
status: "running",
|
||||
@@ -556,11 +541,7 @@ export namespace MessageV2 {
|
||||
},
|
||||
]
|
||||
// text/plain and directory files are converted into text parts, ignore them
|
||||
if (
|
||||
part.type === "file" &&
|
||||
part.mime !== "text/plain" &&
|
||||
part.mime !== "application/x-directory"
|
||||
)
|
||||
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
@@ -619,9 +600,7 @@ export namespace MessageV2 {
|
||||
state: "output-available",
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
output: part.state.time.compacted
|
||||
? "[Old tool result content cleared]"
|
||||
: part.state.output,
|
||||
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
|
||||
callProviderMetadata: part.metadata,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -51,11 +51,9 @@ export namespace Message {
|
||||
})
|
||||
export type ToolResult = z.infer<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = z
|
||||
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
||||
.meta({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
||||
export const TextPart = z
|
||||
@@ -124,14 +122,7 @@ export namespace Message {
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const MessagePart = z
|
||||
.discriminatedUnion("type", [
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolInvocationPart,
|
||||
SourceUrlPart,
|
||||
FilePart,
|
||||
StepStartPart,
|
||||
])
|
||||
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
|
||||
.meta({
|
||||
ref: "MessagePart",
|
||||
})
|
||||
@@ -149,11 +140,7 @@ export namespace Message {
|
||||
completed: z.number().optional(),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [
|
||||
AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
])
|
||||
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
|
||||
.optional(),
|
||||
sessionID: z.string(),
|
||||
tool: z.record(
|
||||
|
||||
@@ -301,11 +301,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,
|
||||
@@ -340,11 +336,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
|
||||
},
|
||||
@@ -362,9 +354,7 @@ export namespace SessionPrompt {
|
||||
})
|
||||
if (result.shouldRetry) {
|
||||
for (let retry = 1; retry < maxRetries; retry++) {
|
||||
const lastRetryPart = result.parts.findLast(
|
||||
(p): p is MessageV2.RetryPart => 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)
|
||||
@@ -529,11 +519,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,
|
||||
@@ -853,9 +839,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,
|
||||
@@ -929,9 +913,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"),
|
||||
@@ -1010,10 +992,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
|
||||
@@ -1169,10 +1148,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(),
|
||||
@@ -1296,11 +1272,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"),
|
||||
@@ -1323,11 +1295,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
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: {
|
||||
@@ -1822,13 +1790,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,
|
||||
|
||||
107
packages/opencode/src/session/prompt/polaris.txt
Normal file
107
packages/opencode/src/session/prompt/polaris.txt
Normal file
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -45,9 +45,7 @@ export namespace SessionRevert {
|
||||
if (!revert) {
|
||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||
// if no useful parts left in message, same as reverting whole message
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type))
|
||||
? input.partID
|
||||
: undefined
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||
revert = {
|
||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||
partID,
|
||||
|
||||
@@ -60,9 +60,7 @@ export namespace SessionSummary {
|
||||
|
||||
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
|
||||
@@ -73,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,
|
||||
@@ -113,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
|
||||
|
||||
@@ -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"
|
||||
@@ -24,10 +25,10 @@ export namespace SystemPrompt {
|
||||
|
||||
export function provider(modelID: string) {
|
||||
if (modelID.includes("gpt-5")) return [PROMPT_CODEX]
|
||||
if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3"))
|
||||
return [PROMPT_BEAST]
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -100,11 +101,7 @@ export namespace SystemPrompt {
|
||||
}),
|
||||
).catch(() => [])
|
||||
} else {
|
||||
matches = await Filesystem.globUp(
|
||||
instruction,
|
||||
Instance.directory,
|
||||
Instance.worktree,
|
||||
).catch(() => [])
|
||||
matches = await Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
matches.forEach((path) => paths.add(path))
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ export namespace Todo {
|
||||
export const Info = z
|
||||
.object({
|
||||
content: z.string().describe("Brief description of the task"),
|
||||
status: z
|
||||
.string()
|
||||
.describe("Current status of the task: pending, in_progress, completed, cancelled"),
|
||||
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
|
||||
priority: z.string().describe("Priority level of the task: high, medium, low"),
|
||||
id: z.string().describe("Unique identifier for the todo item"),
|
||||
})
|
||||
|
||||
@@ -50,10 +50,7 @@ export namespace Share {
|
||||
await sync("session/info/" + evt.properties.info.id, evt.properties.info)
|
||||
})
|
||||
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
|
||||
await sync(
|
||||
"session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id,
|
||||
evt.properties.info,
|
||||
)
|
||||
await sync("session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, evt.properties.info)
|
||||
})
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
await sync(
|
||||
@@ -70,9 +67,7 @@ export namespace Share {
|
||||
|
||||
export const URL =
|
||||
process.env["OPENCODE_API"] ??
|
||||
(Installation.isPreview() || Installation.isLocal()
|
||||
? "https://api.dev.opencode.ai"
|
||||
: "https://api.opencode.ai")
|
||||
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
return fetch(`${URL}/share_create`, {
|
||||
|
||||
@@ -27,11 +27,7 @@ export namespace Snapshot {
|
||||
log.info("initialized")
|
||||
}
|
||||
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const hash = await $`git --git-dir ${git} write-tree`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.text()
|
||||
const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(Instance.directory).nothrow().text()
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
@@ -45,10 +41,7 @@ export namespace Snapshot {
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.quiet().cwd(Instance.directory).nothrow()
|
||||
|
||||
// If git diff fails, return empty patch
|
||||
if (result.exitCode !== 0) {
|
||||
@@ -71,11 +64,10 @@ export namespace Snapshot {
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result =
|
||||
await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
const result = await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
log.error("failed to restore snapshot", {
|
||||
@@ -121,10 +113,7 @@ export namespace Snapshot {
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const result = await $`git --git-dir=${git} diff ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
const result = await $`git --git-dir=${git} diff ${hash} -- .`.quiet().cwd(Instance.worktree).nothrow()
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -23,13 +23,8 @@ export const EditTool = Tool.define("edit", {
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Replace all occurrences of oldString (default false)"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
@@ -40,9 +35,7 @@ export const EditTool = Tool.define("edit", {
|
||||
throw new Error("oldString and newString must be different")
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(Instance.directory, params.filePath)
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
if (!Filesystem.contains(Instance.directory, filePath)) {
|
||||
const parentDir = path.dirname(filePath)
|
||||
await Permission.ask({
|
||||
@@ -179,11 +172,7 @@ function levenshtein(a: string, b: string): number {
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost,
|
||||
)
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
@@ -385,9 +374,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words
|
||||
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+")
|
||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||
try {
|
||||
const regex = new RegExp(pattern)
|
||||
const match = line.match(regex)
|
||||
@@ -625,12 +612,7 @@ export function trimDiff(diff: string): string {
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
export function replace(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll = false,
|
||||
): string {
|
||||
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
||||
if (oldString === newString) {
|
||||
throw new Error("oldString and newString must be different")
|
||||
}
|
||||
|
||||
@@ -9,14 +9,8 @@ export const GrepTool = Tool.define("grep", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
async execute(params) {
|
||||
if (!params.pattern) {
|
||||
|
||||
@@ -37,18 +37,13 @@ const LIMIT = 100
|
||||
export const ListTool = Tool.define("list", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("The absolute path to the directory to list (must be absolute, not relative)")
|
||||
.optional(),
|
||||
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
|
||||
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(
|
||||
params.ignore?.map((p) => `!${p}`) || [],
|
||||
)
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const files = []
|
||||
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {
|
||||
files.push(file)
|
||||
|
||||
@@ -11,9 +11,7 @@ export const LspDiagnosticTool = Tool.define("lsp_diagnostics", {
|
||||
path: z.string().describe("The path to the file to get diagnostics."),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const normalized = path.isAbsolute(args.path)
|
||||
? args.path
|
||||
: path.join(Instance.directory, args.path)
|
||||
const normalized = path.isAbsolute(args.path) ? args.path : path.join(Instance.directory, args.path)
|
||||
await LSP.touchFile(normalized, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const file = diagnostics[normalized]
|
||||
|
||||
@@ -14,13 +14,8 @@ export const MultiEditTool = Tool.define("multiedit", {
|
||||
z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Replace all occurrences of oldString (default false)"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
|
||||
@@ -18,10 +18,7 @@ export const ReadTool = Tool.define("read", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z.coerce
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.optional(),
|
||||
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
|
||||
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
@@ -56,16 +53,13 @@ export const ReadTool = Tool.define("read", {
|
||||
const suggestions = dirEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) ||
|
||||
base.toLowerCase().includes(entry.toLowerCase()),
|
||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
throw new Error(
|
||||
`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
|
||||
)
|
||||
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
||||
}
|
||||
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
|
||||
@@ -14,10 +14,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
const description = DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
agents
|
||||
.map(
|
||||
(a) =>
|
||||
`- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`,
|
||||
)
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
return {
|
||||
@@ -29,8 +26,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const agent = await Agent.get(params.subagent_type)
|
||||
if (!agent)
|
||||
throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
const session = await Session.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${agent.name} subagent)`,
|
||||
@@ -95,9 +91,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
let all
|
||||
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[],
|
||||
)
|
||||
all = all.flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[])
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
|
||||
@@ -48,15 +48,13 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
let acceptHeader = "*/*"
|
||||
switch (params.format) {
|
||||
case "markdown":
|
||||
acceptHeader =
|
||||
"text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
break
|
||||
case "text":
|
||||
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
|
||||
break
|
||||
case "html":
|
||||
acceptHeader =
|
||||
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
break
|
||||
default:
|
||||
acceptHeader =
|
||||
@@ -160,9 +158,7 @@ async function extractTextFromHTML(html: string) {
|
||||
.on("*", {
|
||||
element(element) {
|
||||
// Reset skip flag when entering other elements
|
||||
if (
|
||||
!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)
|
||||
) {
|
||||
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
|
||||
skipContent = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,14 +15,10 @@ export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
filePath: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(Instance.directory, params.filePath)
|
||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
if (!Filesystem.contains(Instance.directory, filepath)) {
|
||||
const parentDir = path.dirname(filepath)
|
||||
await Permission.ask({
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export namespace Binary {
|
||||
export function search<T>(
|
||||
array: T[],
|
||||
id: string,
|
||||
compare: (item: T) => string,
|
||||
): { found: boolean; index: number } {
|
||||
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
|
||||
let left = 0
|
||||
let right = array.length - 1
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export function defer<T extends () => void | Promise<void>>(
|
||||
fn: T,
|
||||
): T extends () => Promise<void>
|
||||
? { [Symbol.asyncDispose]: () => Promise<void> }
|
||||
: { [Symbol.dispose]: () => void } {
|
||||
): T extends () => Promise<void> ? { [Symbol.asyncDispose]: () => Promise<void> } : { [Symbol.dispose]: () => void } {
|
||||
return {
|
||||
[Symbol.dispose]() {
|
||||
fn()
|
||||
|
||||
@@ -4,17 +4,11 @@ export namespace EventLoop {
|
||||
export async function wait() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
const active = [
|
||||
...(process as any)._getActiveHandles(),
|
||||
...(process as any)._getActiveRequests(),
|
||||
]
|
||||
const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()]
|
||||
Log.Default.info("eventloop", {
|
||||
active,
|
||||
})
|
||||
if (
|
||||
(process as any)._getActiveHandles().length === 0 &&
|
||||
(process as any)._getActiveRequests().length === 0
|
||||
) {
|
||||
if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
setImmediate(check)
|
||||
|
||||
@@ -39,12 +39,7 @@ export namespace Lock {
|
||||
}
|
||||
|
||||
// Clean up empty locks
|
||||
if (
|
||||
lock.readers === 0 &&
|
||||
!lock.writer &&
|
||||
lock.waitingReaders.length === 0 &&
|
||||
lock.waitingWriters.length === 0
|
||||
) {
|
||||
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
|
||||
locks.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Global } from "../global"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z
|
||||
.enum(["DEBUG", "INFO", "WARN", "ERROR"])
|
||||
.meta({ ref: "LogLevel", description: "Log level" })
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
||||
export type Level = z.infer<typeof Level>
|
||||
|
||||
const levelPriority: Record<Level, number> = {
|
||||
@@ -121,11 +119,7 @@ export namespace Log {
|
||||
const next = new Date()
|
||||
const diff = next.getTime() - last
|
||||
last = next.getTime()
|
||||
return (
|
||||
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
|
||||
.filter(Boolean)
|
||||
.join(" ") + "\n"
|
||||
)
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
|
||||
@@ -30,10 +30,7 @@ export namespace Rpc {
|
||||
}
|
||||
}
|
||||
return {
|
||||
call<Method extends keyof T>(
|
||||
method: Method,
|
||||
input: Parameters<T[Method]>[0],
|
||||
): Promise<ReturnType<T[Method]>> {
|
||||
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
|
||||
const requestId = id++
|
||||
return new Promise((resolve) => {
|
||||
pending.set(requestId, resolve)
|
||||
|
||||
@@ -15,11 +15,7 @@ export namespace Wildcard {
|
||||
}
|
||||
|
||||
export function all(input: string, patterns: Record<string, any>) {
|
||||
const sorted = pipe(
|
||||
patterns,
|
||||
Object.entries,
|
||||
sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]),
|
||||
)
|
||||
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
|
||||
let result = undefined
|
||||
for (const [pattern, value] of sorted) {
|
||||
if (match(input, pattern)) {
|
||||
@@ -30,15 +26,8 @@ export namespace Wildcard {
|
||||
return result
|
||||
}
|
||||
|
||||
export function allStructured(
|
||||
input: { head: string; tail: string[] },
|
||||
patterns: Record<string, any>,
|
||||
) {
|
||||
const sorted = pipe(
|
||||
patterns,
|
||||
Object.entries,
|
||||
sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]),
|
||||
)
|
||||
export function allStructured(input: { head: string; tail: string[] }, patterns: Record<string, any>) {
|
||||
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
|
||||
let result = undefined
|
||||
for (const [pattern, value] of sorted) {
|
||||
const parts = pattern.split(/\s+/)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
77
packages/opencode/test/fixture/lsp/fake-lsp-server.js
Normal file
77
packages/opencode/test/fixture/lsp/fake-lsp-server.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,7 @@ describe("ide", () => {
|
||||
|
||||
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"
|
||||
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")
|
||||
})
|
||||
@@ -29,24 +28,21 @@ describe("ide", () => {
|
||||
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
process.env["GIT_ASKPASS"] = "/path/to/Windsurf.app/Contents/Resources/app/extensions/git/dist/askpass.sh"
|
||||
|
||||
expect(Ide.ide()).toBe("Windsurf")
|
||||
})
|
||||
|
||||
95
packages/opencode/test/lsp/client.test.ts
Normal file
95
packages/opencode/test/lsp/client.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -13,9 +13,7 @@ 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),
|
||||
)
|
||||
const delays = Array.from({ length: 7 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1))
|
||||
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000])
|
||||
})
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ describe("tool.patch", () => {
|
||||
await Instance.provide({
|
||||
directory: "/tmp",
|
||||
fn: async () => {
|
||||
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")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -45,9 +43,7 @@ describe("tool.patch", () => {
|
||||
const emptyPatch = `*** Begin Patch
|
||||
*** End Patch`
|
||||
|
||||
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")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -116,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"')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,12 +24,9 @@ test("allStructured matches command sequences", () => {
|
||||
"git status*": "allow",
|
||||
}
|
||||
expect(Wildcard.allStructured({ head: "git", tail: ["status", "--short"] }, rules)).toBe("allow")
|
||||
expect(
|
||||
Wildcard.allStructured(
|
||||
{ head: "npm", tail: ["run", "build", "--watch"] },
|
||||
{ "npm run *": "allow" },
|
||||
),
|
||||
).toBe("allow")
|
||||
expect(Wildcard.allStructured({ head: "npm", tail: ["run", "build", "--watch"] }, { "npm run *": "allow" })).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Wildcard.allStructured({ head: "ls", tail: ["-la"] }, rules)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -54,7 +51,5 @@ test("allStructured handles sed flags", () => {
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "file"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i.bak", "file"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow")
|
||||
expect(
|
||||
Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules),
|
||||
).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user