summaryrefslogtreecommitdiffhomepage
path: root/js/src
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-20 22:00:00 -0400
committerDax Raad <[email protected]>2025-05-26 12:40:17 -0400
commit2860a2bb1a1f227c26b02f1325454ab79d6f6451 (patch)
tree2a52a5c159025c6ff6854af2995c24c4d7d46b77 /js/src
parent9b564f0b73d099d40c79517213211ba81b3312c6 (diff)
downloadopencode-2860a2bb1a1f227c26b02f1325454ab79d6f6451.tar.gz
opencode-2860a2bb1a1f227c26b02f1325454ab79d6f6451.zip
sync
Diffstat (limited to 'js/src')
-rw-r--r--js/src/index.ts7
-rw-r--r--js/src/lsp/client.ts178
-rw-r--r--js/src/lsp/index.ts31
-rw-r--r--js/src/lsp/language.ts83
-rw-r--r--js/src/tool/edit.ts232
-rw-r--r--js/src/tool/tool.ts3
-rw-r--r--js/src/tool/view.ts8
-rw-r--r--js/src/util/log.ts1
-rw-r--r--js/src/util/scrap.ts5
9 files changed, 375 insertions, 173 deletions
diff --git a/js/src/index.ts b/js/src/index.ts
index 609abf14b..7d114c642 100644
--- a/js/src/index.ts
+++ b/js/src/index.ts
@@ -1,10 +1,11 @@
import { App } from "./app";
import { Server } from "./server/server";
-import { Cli, Command, Option, runExit } from "clipanion";
+import { Cli, Command, Option } from "clipanion";
import fs from "fs/promises";
import path from "path";
import { Bus } from "./bus";
import { Session } from "./session/session";
+import { LSP } from "./lsp";
const cli = new Cli({
binaryLabel: `opencode`,
@@ -25,6 +26,7 @@ cli.register(
}
},
);
+
cli.register(
class extends Command {
static paths = [["generate"]];
@@ -71,6 +73,9 @@ cli.register(
"tool:",
part.toolInvocation.toolName,
part.toolInvocation.args,
+ part.toolInvocation.state === "result"
+ ? part.toolInvocation.result
+ : "",
);
}
}
diff --git a/js/src/lsp/client.ts b/js/src/lsp/client.ts
new file mode 100644
index 000000000..e6cfdb2eb
--- /dev/null
+++ b/js/src/lsp/client.ts
@@ -0,0 +1,178 @@
+import { spawn } from "child_process";
+import path from "path";
+import {
+ createMessageConnection,
+ Disposable,
+ StreamMessageReader,
+ StreamMessageWriter,
+} from "vscode-jsonrpc/node";
+import { App } from "../app";
+import { Log } from "../util/log";
+import { LANGUAGE_EXTENSIONS } from "./language";
+
+export namespace LSPClient {
+ const log = Log.create({ service: "lsp.client" });
+
+ export type Info = Awaited<ReturnType<typeof create>>;
+
+ export async function create(input: { cmd: string[] }) {
+ log.info("starting client", input);
+ let version = 0;
+
+ const app = await App.use();
+ const [command, ...args] = input.cmd;
+ const server = spawn(command, args, {
+ stdio: ["pipe", "pipe", "pipe"],
+ cwd: app.root,
+ });
+
+ const connection = createMessageConnection(
+ new StreamMessageReader(server.stdout),
+ new StreamMessageWriter(server.stdin),
+ );
+
+ const diagnostics = new Map<string, any>();
+ connection.onNotification("textDocument/publishDiagnostics", (params) => {
+ log.info("textDocument/publishDiagnostics", {
+ path: new URL(params.uri).pathname,
+ });
+ diagnostics.set(new URL(params.uri).pathname, params.diagnostics);
+ });
+ connection.listen();
+
+ await connection.sendRequest("initialize", {
+ processId: server.pid,
+ initializationOptions: {
+ workspaceFolders: [
+ {
+ name: "workspace",
+ uri: "file://" + app.root,
+ },
+ ],
+ tsserver: {
+ path: require.resolve("typescript/lib/tsserver.js"),
+ },
+ },
+ capabilities: {
+ workspace: {
+ configuration: true,
+ didChangeConfiguration: {
+ dynamicRegistration: true,
+ },
+ didChangeWatchedFiles: {
+ dynamicRegistration: true,
+ relativePatternSupport: true,
+ },
+ },
+ textDocument: {
+ synchronization: {
+ dynamicRegistration: true,
+ didSave: true,
+ },
+ completion: {
+ completionItem: {},
+ },
+ codeLens: {
+ dynamicRegistration: true,
+ },
+ documentSymbol: {},
+ codeAction: {
+ codeActionLiteralSupport: {
+ codeActionKind: {
+ valueSet: [],
+ },
+ },
+ },
+ publishDiagnostics: {
+ versionSupport: true,
+ },
+ semanticTokens: {
+ requests: {
+ range: {},
+ full: {},
+ },
+ tokenTypes: [],
+ tokenModifiers: [],
+ formats: [],
+ },
+ },
+ window: {},
+ },
+ });
+ await connection.sendNotification("initialized", {});
+ log.info("initialized");
+
+ const result = {
+ get connection() {
+ return connection;
+ },
+ notify: {
+ async open(input: { path: string }) {
+ log.info("textDocument/didOpen", input);
+ diagnostics.delete(input.path);
+ const text = await Bun.file(input.path).text();
+ const languageId = LANGUAGE_EXTENSIONS[path.extname(input.path)];
+ await connection.sendNotification("textDocument/didOpen", {
+ textDocument: {
+ uri: `file://` + input.path,
+ languageId,
+ version: 1,
+ text: text,
+ },
+ });
+ },
+ async change(input: { path: string }) {
+ log.info("textDocument/didChange", input);
+ diagnostics.delete(input.path);
+ const text = await Bun.file(input.path).text();
+ version++;
+ await connection.sendNotification("textDocument/didChange", {
+ textDocument: {
+ uri: `file://` + input.path,
+ version: Date.now(),
+ },
+ contentChanges: [
+ {
+ text,
+ },
+ ],
+ });
+ },
+ },
+ get diagnostics() {
+ return diagnostics;
+ },
+ async refreshDiagnostics(input: { path: string }) {
+ log.info("refreshing diagnostics", input);
+ let notif: Disposable | undefined;
+ return await Promise.race([
+ new Promise<void>(async (resolve) => {
+ notif = connection.onNotification(
+ "textDocument/publishDiagnostics",
+ (params) => {
+ log.info("refreshed diagnostics", input);
+ if (new URL(params.uri).pathname === input.path) {
+ diagnostics.set(
+ new URL(params.uri).pathname,
+ params.diagnostics,
+ );
+ resolve();
+ notif?.dispose();
+ }
+ },
+ );
+ await result.notify.change(input);
+ }),
+ new Promise<void>((resolve) =>
+ setTimeout(() => {
+ notif?.dispose();
+ resolve();
+ }, 5000),
+ ),
+ ]);
+ },
+ };
+
+ return result;
+ }
+}
diff --git a/js/src/lsp/index.ts b/js/src/lsp/index.ts
new file mode 100644
index 000000000..ac200fc51
--- /dev/null
+++ b/js/src/lsp/index.ts
@@ -0,0 +1,31 @@
+import { App } from "../app";
+import { Log } from "../util/log";
+import { LSPClient } from "./client";
+
+export namespace LSP {
+ const log = Log.create({ service: "lsp" });
+
+ const state = App.state("lsp", async () => {
+ const clients = new Map<string, LSPClient.Info>();
+
+ clients.set(
+ "typescript",
+ await LSPClient.create({
+ cmd: ["bun", "x", "typescript-language-server", "--stdio"],
+ }),
+ );
+
+ return {
+ clients,
+ diagnostics: new Map<string, any>(),
+ };
+ });
+
+ export async function run<T>(
+ input: (client: LSPClient.Info) => Promise<T>,
+ ): Promise<T[]> {
+ const clients = await state().then((x) => [...x.clients.values()]);
+ const tasks = clients.map((x) => input(x));
+ return Promise.all(tasks);
+ }
+}
diff --git a/js/src/lsp/language.ts b/js/src/lsp/language.ts
new file mode 100644
index 000000000..0a9bc0f7f
--- /dev/null
+++ b/js/src/lsp/language.ts
@@ -0,0 +1,83 @@
+export const LANGUAGE_EXTENSIONS: Record<string, string> = {
+ ".abap": "abap",
+ ".bat": "bat",
+ ".bib": "bibtex",
+ ".bibtex": "bibtex",
+ ".clj": "clojure",
+ ".coffee": "coffeescript",
+ ".c": "c",
+ ".cpp": "cpp",
+ ".cxx": "cpp",
+ ".cc": "cpp",
+ ".c++": "cpp",
+ ".cs": "csharp",
+ ".css": "css",
+ ".d": "d",
+ ".pas": "pascal",
+ ".pascal": "pascal",
+ ".diff": "diff",
+ ".patch": "diff",
+ ".dart": "dart",
+ ".dockerfile": "dockerfile",
+ ".ex": "elixir",
+ ".exs": "elixir",
+ ".erl": "erlang",
+ ".hrl": "erlang",
+ ".fs": "fsharp",
+ ".fsi": "fsharp",
+ ".fsx": "fsharp",
+ ".fsscript": "fsharp",
+ ".gitcommit": "git-commit",
+ ".gitrebase": "git-rebase",
+ ".go": "go",
+ ".groovy": "groovy",
+ ".hbs": "handlebars",
+ ".handlebars": "handlebars",
+ ".hs": "haskell",
+ ".html": "html",
+ ".htm": "html",
+ ".ini": "ini",
+ ".java": "java",
+ ".js": "javascript",
+ ".jsx": "javascriptreact",
+ ".json": "json",
+ ".tex": "latex",
+ ".latex": "latex",
+ ".less": "less",
+ ".lua": "lua",
+ ".makefile": "makefile",
+ makefile: "makefile",
+ ".md": "markdown",
+ ".markdown": "markdown",
+ ".m": "objective-c",
+ ".mm": "objective-cpp",
+ ".pl": "perl",
+ ".pm": "perl6",
+ ".php": "php",
+ ".ps1": "powershell",
+ ".psm1": "powershell",
+ ".pug": "jade",
+ ".jade": "jade",
+ ".py": "python",
+ ".r": "r",
+ ".cshtml": "razor",
+ ".razor": "razor",
+ ".rb": "ruby",
+ ".rs": "rust",
+ ".scss": "scss",
+ ".sass": "sass",
+ ".scala": "scala",
+ ".shader": "shaderlab",
+ ".sh": "shellscript",
+ ".bash": "shellscript",
+ ".zsh": "shellscript",
+ ".ksh": "shellscript",
+ ".sql": "sql",
+ ".swift": "swift",
+ ".ts": "typescript",
+ ".tsx": "typescriptreact",
+ ".xml": "xml",
+ ".xsl": "xsl",
+ ".yaml": "yaml",
+ ".yml": "yaml",
+};
diff --git a/js/src/tool/edit.ts b/js/src/tool/edit.ts
index 24bf29bcb..e83564f0f 100644
--- a/js/src/tool/edit.ts
+++ b/js/src/tool/edit.ts
@@ -4,6 +4,7 @@ import * as path from "path";
import { Log } from "../util/log";
import { Tool } from "./tool";
import { FileTimes } from "./util/file-times";
+import { LSP } from "../lsp";
const log = Log.create({ service: "tool.edit" });
@@ -78,7 +79,7 @@ Before using this tool:
- Use the LS tool to verify the parent directory exists and is the correct location
To make a file edit, provide the following:
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
+1. file_path: The relative path to the file to modify (must be relative, not absolute)
2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
3. new_string: The edited text to replace the old_string
@@ -112,7 +113,7 @@ WARNING: If you do not follow these requirements:
When making edits:
- Ensure the edit results in idiomatic, correct code
- Do not leave the code in a broken state
- - Always use absolute file paths (starting with /)
+ - Always use relative file paths
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`;
@@ -134,21 +135,68 @@ export const EditTool = Tool.define({
filePath = path.join(process.cwd(), filePath);
}
- // Handle different operations based on parameters
- if (params.old_string === "") {
- return {
- output: createNewFile(filePath, params.new_string),
- };
- }
+ await (async () => {
+ if (params.old_string === "") {
+ await createNewFile(filePath, params.new_string);
+ return;
+ }
- if (params.new_string === "") {
- return {
- output: deleteContent(filePath, params.old_string),
- };
+ const read = FileTimes.get(filePath);
+ if (!read)
+ throw new Error(
+ `You must read the file ${filePath} before editing it. Use the View tool first`,
+ );
+ const file = Bun.file(filePath);
+ if (!(await file.exists())) throw new Error(`File ${filePath} not found`);
+ const stats = await file.stat();
+ if (stats.isDirectory())
+ throw new Error(`Path is a directory, not a file: ${filePath}`);
+ if (stats.mtime.getTime() > read.getTime())
+ throw new Error(
+ `File ${filePath} has been modified since it was last read.\nLast modification: ${read.toISOString()}\nLast read: ${stats.mtime.toISOString()}\n\nPlease read the file again before modifying it.`,
+ );
+
+ const content = await file.text();
+ const index = content.indexOf(params.old_string);
+ if (index === -1)
+ throw new Error(
+ `old_string not found in file. Make sure it matches exactly, including whitespace and line breaks`,
+ );
+ const lastIndex = content.lastIndexOf(params.old_string);
+ if (index !== lastIndex)
+ throw new Error(
+ `old_string appears multiple times in the file. Please provide more context to ensure a unique match`,
+ );
+
+ const newContent =
+ content.substring(0, index) +
+ params.new_string +
+ content.substring(index + params.old_string.length);
+
+ console.log(newContent);
+ await file.write(newContent);
+ })();
+
+ FileTimes.write(filePath);
+ FileTimes.read(filePath);
+
+ let output = "";
+ await LSP.run((client) => client.refreshDiagnostics({ path: filePath }));
+ const diagnostics = await LSP.run(async (client) => client.diagnostics);
+ for (const diagnostic of diagnostics) {
+ for (const [file, params] of diagnostic.entries()) {
+ if (params.length === 0) continue;
+ if (file === filePath) {
+ output += `\nThis file has errors, please fix\n<file_diagnostics>\n${JSON.stringify(params)}\n</file_diagnostics>\n`;
+ continue;
+ }
+ output += `\n<project_diagnostics>\n${JSON.stringify(params)}\n</project_diagnostics>\n`;
+ }
}
+ console.log(output);
return {
- output: replaceContent(filePath, params.old_string, params.new_string),
+ output,
};
},
});
@@ -186,160 +234,4 @@ async function createNewFile(
}
}
-async function deleteContent(
- filePath: string,
- oldString: string,
-): Promise<string> {
- try {
- // Check if file exists
- let fileStats;
- try {
- fileStats = fs.statSync(filePath);
- if (fileStats.isDirectory()) {
- throw new Error(`Path is a directory, not a file: ${filePath}`);
- }
- } catch (err: any) {
- if (err.code === "ENOENT") {
- throw new Error(`File not found: ${filePath}`);
- }
- throw err;
- }
-
- const lastReadTime = FileTimes.get(filePath);
- if (!lastReadTime) {
- throw new Error(
- "You must read the file before editing it. Use the View tool first",
- );
- }
-
- const modTime = fileStats.mtime;
- if (modTime > lastReadTime) {
- throw new Error(
- `File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`,
- );
- }
-
- const oldContent = fs.readFileSync(filePath, "utf8");
- const index = oldContent.indexOf(oldString);
- if (index === -1) {
- throw new Error(
- "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks",
- );
- }
-
- const lastIndex = oldContent.lastIndexOf(oldString);
- if (index !== lastIndex) {
- throw new Error(
- "old_string appears multiple times in the file. Please provide more context to ensure a unique match",
- );
- }
-
- const newContent =
- oldContent.substring(0, index) +
- oldContent.substring(index + oldString.length);
-
- const { diff, additions, removals } = generateDiff(
- oldContent,
- newContent,
- filePath,
- );
-
- // Write the file
- fs.writeFileSync(filePath, newContent);
-
- FileTimes.write(filePath);
- FileTimes.read(filePath);
-
- return `Content deleted from file: ${filePath}`;
- } catch (err: any) {
- throw new Error(`Failed to delete content: ${err.message}`);
- }
-}
-
-async function replaceContent(
- filePath: string,
- oldString: string,
- newString: string,
-): Promise<string> {
- try {
- // Check if file exists
- let fileStats;
- try {
- fileStats = fs.statSync(filePath);
- if (fileStats.isDirectory()) {
- throw new Error(`Path is a directory, not a file: ${filePath}`);
- }
- } catch (err: any) {
- if (err.code === "ENOENT") {
- throw new Error(`File not found: ${filePath}`);
- }
- throw err;
- }
-
- // Check if file has been read before
- const lastReadTime = getLastReadTime(filePath);
- if (!lastReadTime) {
- throw new Error(
- "You must read the file before editing it. Use the View tool first",
- );
- }
-
- // Check if file has been modified since last read
- const modTime = fileStats.mtime;
- if (modTime > lastReadTime) {
- throw new Error(
- `File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`,
- );
- }
-
- // Read the file content
- const oldContent = fs.readFileSync(filePath, "utf8");
-
- // Find the string to replace
- const index = oldContent.indexOf(oldString);
- if (index === -1) {
- throw new Error(
- "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks",
- );
- }
-
- // Check if the string appears multiple times
- const lastIndex = oldContent.lastIndexOf(oldString);
- if (index !== lastIndex) {
- throw new Error(
- "old_string appears multiple times in the file. Please provide more context to ensure a unique match",
- );
- }
-
- // Create the new content
- const newContent =
- oldContent.substring(0, index) +
- newString +
- oldContent.substring(index + oldString.length);
-
- // Check if content actually changed
- if (oldContent === newContent) {
- throw new Error(
- "new content is the same as old content. No changes made.",
- );
- }
-
- // Generate diff
- const { diff, additions, removals } = generateDiff(
- oldContent,
- newContent,
- filePath,
- );
-
- // Write the file
- fs.writeFileSync(filePath, newContent);
-
- FileTimes.write(filePath);
- FileTimes.read(filePath);
-
- return `Content replaced in file: ${filePath}`;
- } catch (err: any) {
- throw new Error(`Failed to replace content: ${err.message}`);
- }
-}
-
+function getFile(filePath: string) {}
diff --git a/js/src/tool/tool.ts b/js/src/tool/tool.ts
index 748025707..47a3918d4 100644
--- a/js/src/tool/tool.ts
+++ b/js/src/tool/tool.ts
@@ -40,6 +40,9 @@ export namespace Tool {
output: result.output,
};
} catch (e: any) {
+ log.error("error", {
+ msg: e.toString(),
+ });
return "An error occurred: " + e.toString();
}
},
diff --git a/js/src/tool/view.ts b/js/src/tool/view.ts
index ba6fabbfa..2da0ab0f3 100644
--- a/js/src/tool/view.ts
+++ b/js/src/tool/view.ts
@@ -2,6 +2,8 @@ import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { Tool } from "./tool";
+import { LSP } from "../lsp";
+import { FileTimes } from "./util/file-times";
const MAX_READ_SIZE = 250 * 1024;
const DEFAULT_READ_LIMIT = 2000;
@@ -117,8 +119,11 @@ export const ViewTool = Tool.define({
}
output += "\n</file>";
+ await LSP.run((client) => client.notify.open({ path: filePath }));
+ FileTimes.read(filePath);
+
return {
- output: output,
+ output,
};
},
});
@@ -143,4 +148,3 @@ function isImageFile(filePath: string): string | false {
return false;
}
}
-
diff --git a/js/src/util/log.ts b/js/src/util/log.ts
index c43514ef6..d05f42dc2 100644
--- a/js/src/util/log.ts
+++ b/js/src/util/log.ts
@@ -12,6 +12,7 @@ export namespace Log {
};
export function file(directory: string) {
+ return;
const out = Bun.file(
path.join(AppPath.data(directory), "opencode.out.log"),
).writer();
diff --git a/js/src/util/scrap.ts b/js/src/util/scrap.ts
new file mode 100644
index 000000000..16005acdc
--- /dev/null
+++ b/js/src/util/scrap.ts
@@ -0,0 +1,5 @@
+export const foo: string = "42";
+
+export function dummyFunction(): void {
+ console.log("This is a dummy function");
+}