From 2ec0611f42cf31072376ac74d42e4187d76feb12 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 27 Jun 2025 11:29:20 -0400 Subject: [PATCH] lazy load formatters --- package.json | 6 +- packages/opencode/src/app/app.ts | 46 ++-- packages/opencode/src/bus/index.ts | 6 +- packages/opencode/src/cli/bootstrap.ts | 17 ++ packages/opencode/src/cli/cmd/run.ts | 196 +++++++++--------- packages/opencode/src/cli/cmd/tui.ts | 108 ++++++++++ packages/opencode/src/config/config.ts | 1 + packages/opencode/src/config/hooks.ts | 54 +++++ packages/opencode/src/file/index.ts | 13 ++ .../{tool/util/file-times.ts => file/time.ts} | 4 +- packages/opencode/src/format/index.ts | 117 +++++------ packages/opencode/src/index.ts | 111 +--------- packages/opencode/src/session/index.ts | 20 +- packages/opencode/src/share/share.ts | 13 +- packages/opencode/src/tool/edit.ts | 17 +- packages/opencode/src/tool/patch.ts | 6 +- packages/opencode/src/tool/read.ts | 4 +- packages/opencode/src/tool/write.ts | 13 +- packages/opencode/src/util/project.ts | 91 -------- 19 files changed, 408 insertions(+), 435 deletions(-) create mode 100644 packages/opencode/src/cli/bootstrap.ts create mode 100644 packages/opencode/src/cli/cmd/tui.ts create mode 100644 packages/opencode/src/config/hooks.ts create mode 100644 packages/opencode/src/file/index.ts rename packages/opencode/src/{tool/util/file-times.ts => file/time.ts} (94%) delete mode 100644 packages/opencode/src/util/project.ts diff --git a/package.json b/package.json index ed4fcded..91515be7 100644 --- a/package.json +++ b/package.json @@ -41,5 +41,9 @@ ], "patchedDependencies": { "ai@4.3.16": "patches/ai@4.3.16.patch" - } + }, + "randomField": "purple-elephant-42", + "mysteriousData": "cosmic-banana-7891", + "quirkyValue": "dancing-octopus-314", + "whimsicalEntry": "flying-penguin-2024" } diff --git a/packages/opencode/src/app/app.ts b/packages/opencode/src/app/app.ts index 44b6d9c7..9b26c05b 100644 --- a/packages/opencode/src/app/app.ts +++ b/packages/opencode/src/app/app.ts @@ -2,7 +2,6 @@ import "zod-openapi/extend" import { Log } from "../util/log" import { Context } from "../util/context" import { Filesystem } from "../util/filesystem" -import { Project } from "../util/project" import { Global } from "../global" import path from "path" import os from "os" @@ -13,7 +12,6 @@ export namespace App { export const Info = z .object({ - project: z.string(), user: z.string(), hostname: z.string(), git: z.boolean(), @@ -33,11 +31,21 @@ export namespace App { }) export type Info = z.infer - const ctx = Context.create>>("app") + const ctx = Context.create<{ + info: Info + services: Map Promise }> + }>("app") const APP_JSON = "app.json" - async function create(input: { cwd: string }) { + export type Input = { + cwd: string + } + + export async function provide( + input: Input, + cb: (app: App.Info) => Promise, + ) { log.info("creating", { cwd: input.cwd, }) @@ -66,10 +74,8 @@ export namespace App { >() const root = git ?? input.cwd - const project = await Project.getName(root) const info: Info = { - project: project, user: os.userInfo().username, hostname: os.hostname(), time: { @@ -84,12 +90,20 @@ export namespace App { cwd: input.cwd, }, } - const result = { + const app = { services, info, } - return result + return ctx.provide(app, async () => { + const result = await cb(app.info) + for (const [key, entry] of app.services.entries()) { + if (!entry.shutdown) continue + log.info("shutdown", { name: key }) + await entry.shutdown?.(await entry.state) + } + return result + }) } export function state( @@ -115,22 +129,6 @@ export namespace App { return ctx.use().info } - export async function provide( - input: { cwd: string }, - cb: (app: Info) => Promise, - ) { - const app = await create(input) - return ctx.provide(app, async () => { - const result = await cb(app.info) - for (const [key, entry] of app.services.entries()) { - if (!entry.shutdown) continue - log.info("shutdown", { name: key }) - await entry.shutdown?.(await entry.state) - } - return result - }) - } - export async function initialize() { const { info } = ctx.use() info.time.initialized = Date.now() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 596e6c1c..8461269a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -49,7 +49,7 @@ export namespace Bus { ) } - export function publish( + export async function publish( def: Definition, properties: z.output, ) { @@ -60,12 +60,14 @@ export namespace Bus { log.info("publishing", { type: def.type, }) + const pending = [] for (const key of [def.type, "*"]) { const match = state().subscriptions.get(key) for (const sub of match ?? []) { - sub(payload) + pending.push(sub(payload)) } } + return Promise.all(pending) } export function subscribe( diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts new file mode 100644 index 00000000..66c8a757 --- /dev/null +++ b/packages/opencode/src/cli/bootstrap.ts @@ -0,0 +1,17 @@ +import { App } from "../app/app" +import { ConfigHooks } from "../config/hooks" +import { Format } from "../format" +import { Share } from "../share/share" + +export async function bootstrap( + input: App.Input, + cb: (app: App.Info) => Promise, +) { + return App.provide(input, async (app) => { + Share.init() + Format.init() + ConfigHooks.init() + + return cb(app) + }) +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f8f88686..1905aa17 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,14 +1,13 @@ import type { Argv } from "yargs" -import { App } from "../../app/app" import { Bus } from "../../bus" import { Provider } from "../../provider/provider" import { Session } from "../../session" -import { Share } from "../../share/share" import { Message } from "../../session/message" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { Config } from "../../config/config" +import { bootstrap } from "../bootstrap" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -56,118 +55,109 @@ export const RunCommand = cmd({ }, handler: async (args) => { const message = args.message.join(" ") - await App.provide( - { - cwd: process.cwd(), - }, - async () => { - await Share.init() - const session = await (async () => { - if (args.continue) { - const first = await Session.list().next() - if (first.done) return - return first.value - } - - if (args.session) return Session.get(args.session) - - return Session.create() - })() - - if (!session) { - UI.error("Session not found") - return + await bootstrap({ cwd: process.cwd() }, async () => { + const session = await (async () => { + if (args.continue) { + const first = await Session.list().next() + if (first.done) return + return first.value } - const isPiped = !process.stdout.isTTY + if (args.session) return Session.get(args.session) - UI.empty() - UI.println(UI.logo()) - UI.empty() - UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) - UI.empty() + return Session.create() + })() - const cfg = await Config.get() - if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) { - await Session.share(session.id) - UI.println( - UI.Style.TEXT_INFO_BOLD + - "~ https://opencode.ai/s/" + - session.id.slice(-8), - ) - } - UI.empty() + if (!session) { + UI.error("Session not found") + return + } - const { providerID, modelID } = args.model - ? Provider.parseModel(args.model) - : await Provider.defaultModel() + const isPiped = !process.stdout.isTTY + + UI.empty() + UI.println(UI.logo()) + UI.empty() + UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) + UI.empty() + + const cfg = await Config.get() + if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) { + await Session.share(session.id) UI.println( - UI.Style.TEXT_NORMAL_BOLD + "@ ", - UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`, + UI.Style.TEXT_INFO_BOLD + + "~ https://opencode.ai/s/" + + session.id.slice(-8), ) - UI.empty() + } + UI.empty() - function printEvent(color: string, type: string, title: string) { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + - UI.Style.TEXT_DIM + - ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) + const { providerID, modelID } = args.model + ? Provider.parseModel(args.model) + : await Provider.defaultModel() + UI.println( + UI.Style.TEXT_NORMAL_BOLD + "@ ", + UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`, + ) + UI.empty() + + function printEvent(color: string, type: string, title: string) { + UI.println( + color + `|`, + UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, + "", + UI.Style.TEXT_NORMAL + title, + ) + } + + Bus.subscribe(Message.Event.PartUpdated, async (evt) => { + if (evt.properties.sessionID !== session.id) return + const part = evt.properties.part + const message = await Session.getMessage( + evt.properties.sessionID, + evt.properties.messageID, + ) + + if ( + part.type === "tool-invocation" && + part.toolInvocation.state === "result" + ) { + const metadata = message.metadata.tool[part.toolInvocation.toolCallId] + const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [ + part.toolInvocation.toolName, + UI.Style.TEXT_INFO_BOLD, + ] + printEvent(color, tool, metadata?.title || "Unknown") } - Bus.subscribe(Message.Event.PartUpdated, async (evt) => { - if (evt.properties.sessionID !== session.id) return - const part = evt.properties.part - const message = await Session.getMessage( - evt.properties.sessionID, - evt.properties.messageID, - ) - - if ( - part.type === "tool-invocation" && - part.toolInvocation.state === "result" - ) { - const metadata = - message.metadata.tool[part.toolInvocation.toolCallId] - const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [ - part.toolInvocation.toolName, - UI.Style.TEXT_INFO_BOLD, - ] - printEvent(color, tool, metadata?.title || "Unknown") + if (part.type === "text") { + if (part.text.includes("\n")) { + UI.empty() + UI.println(part.text) + UI.empty() + return } - - if (part.type === "text") { - if (part.text.includes("\n")) { - UI.empty() - UI.println(part.text) - UI.empty() - return - } - printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) - } - }) - - const result = await Session.chat({ - sessionID: session.id, - providerID, - modelID, - parts: [ - { - type: "text", - text: message, - }, - ], - }) - - if (isPiped) { - const match = result.parts.findLast((x) => x.type === "text") - if (match) process.stdout.write(match.text) + printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) } - UI.empty() - }, - ) + }) + + const result = await Session.chat({ + sessionID: session.id, + providerID, + modelID, + parts: [ + { + type: "text", + text: message, + }, + ], + }) + + if (isPiped) { + const match = result.parts.findLast((x) => x.type === "text") + if (match) process.stdout.write(match.text) + } + UI.empty() + }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts new file mode 100644 index 00000000..203cc299 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -0,0 +1,108 @@ +import { Global } from "../../global" +import { Provider } from "../../provider/provider" +import { Server } from "../../server/server" +import { bootstrap } from "../bootstrap" +import { UI } from "../ui" +import { cmd } from "./cmd" +import path from "path" +import fs from "fs/promises" +import { Installation } from "../../installation" +import { Config } from "../../config/config" +import { Bus } from "../../bus" +import { AuthLoginCommand } from "./auth" + +export const TuiCommand = cmd({ + command: "$0 [project]", + describe: "start opencode tui", + builder: (yargs) => + yargs.positional("project", { + type: "string", + describe: "path to start opencode in", + }), + handler: async (args) => { + while (true) { + const cwd = args.project ? path.resolve(args.project) : process.cwd() + try { + process.chdir(cwd) + } catch (e) { + UI.error("Failed to change directory to " + cwd) + return + } + const result = await bootstrap({ cwd }, async (app) => { + const providers = await Provider.list() + if (Object.keys(providers).length === 0) { + return "needs_provider" + } + + const server = Server.listen({ + port: 0, + hostname: "127.0.0.1", + }) + + let cmd = ["go", "run", "./main.go"] + let cwd = Bun.fileURLToPath( + new URL("../../../../tui/cmd/opencode", import.meta.url), + ) + if (Bun.embeddedFiles.length > 0) { + const blob = Bun.embeddedFiles[0] as File + let binaryName = blob.name + if (process.platform === "win32" && !binaryName.endsWith(".exe")) { + binaryName += ".exe" + } + const binary = path.join(Global.Path.cache, "tui", binaryName) + const file = Bun.file(binary) + if (!(await file.exists())) { + await Bun.write(file, blob, { mode: 0o755 }) + await fs.chmod(binary, 0o755) + } + cwd = process.cwd() + cmd = [binary] + } + const proc = Bun.spawn({ + cmd: [...cmd, ...process.argv.slice(2)], + cwd, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: { + ...process.env, + OPENCODE_SERVER: server.url.toString(), + OPENCODE_APP_INFO: JSON.stringify(app), + }, + onExit: () => { + server.stop() + }, + }) + + ;(async () => { + if (Installation.VERSION === "dev") return + if (Installation.isSnapshot()) return + const config = await Config.global() + if (config.autoupdate === false) return + const latest = await Installation.latest().catch(() => {}) + if (!latest) return + if (Installation.VERSION === latest) return + const method = await Installation.method() + if (method === "unknown") return + await Installation.upgrade(method, latest) + .then(() => { + Bus.publish(Installation.Event.Updated, { version: latest }) + }) + .catch(() => {}) + })() + + await proc.exited + server.stop() + + return "done" + }) + if (result === "done") break + if (result === "needs_provider") { + UI.empty() + UI.println(UI.logo(" ")) + UI.empty() + await AuthLoginCommand.handler(args) + } + } + }, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf3c0ecd..efb379b5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,6 +22,7 @@ export namespace Config { } } log.info("loaded", result) + return result }) diff --git a/packages/opencode/src/config/hooks.ts b/packages/opencode/src/config/hooks.ts new file mode 100644 index 00000000..ffa2475f --- /dev/null +++ b/packages/opencode/src/config/hooks.ts @@ -0,0 +1,54 @@ +import { App } from "../app/app" +import { Bus } from "../bus" +import { File } from "../file" +import { Session } from "../session" +import { Log } from "../util/log" +import { Config } from "./config" +import path from "path" + +export namespace ConfigHooks { + const log = Log.create({ service: "config.hooks" }) + + export function init() { + log.info("init") + const app = App.info() + + Bus.subscribe(File.Event.Edited, async (payload) => { + const cfg = await Config.get() + const ext = path.extname(payload.properties.file) + for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) { + log.info("file_edited", { + file: payload.properties.file, + command: item.command, + }) + Bun.spawn({ + cmd: item.command.map((x) => + x.replace("$FILE", payload.properties.file), + ), + env: item.environment, + cwd: app.path.cwd, + stdout: "ignore", + stderr: "ignore", + }) + } + }) + + Bus.subscribe(Session.Event.Idle, async () => { + const cfg = await Config.get() + if (cfg.experimental?.hook?.session_completed) { + for (const item of cfg.experimental.hook.session_completed) { + log.info("session_completed", { + command: item.command, + }) + Bun.spawn({ + cmd: item.command, + cwd: App.info().path.cwd, + env: item.environment, + stdout: "ignore", + stderr: "ignore", + }) + } + } + }) + } +} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts new file mode 100644 index 00000000..7b5beab4 --- /dev/null +++ b/packages/opencode/src/file/index.ts @@ -0,0 +1,13 @@ +import { z } from "zod" +import { Bus } from "../bus" + +export namespace File { + export const Event = { + Edited: Bus.event( + "file.edited", + z.object({ + file: z.string(), + }), + ), + } +} diff --git a/packages/opencode/src/tool/util/file-times.ts b/packages/opencode/src/file/time.ts similarity index 94% rename from packages/opencode/src/tool/util/file-times.ts rename to packages/opencode/src/file/time.ts index 7eb60aec..53132197 100644 --- a/packages/opencode/src/tool/util/file-times.ts +++ b/packages/opencode/src/file/time.ts @@ -1,6 +1,6 @@ -import { App } from "../../app/app" +import { App } from "../app/app" -export namespace FileTimes { +export namespace FileTime { export const state = App.state("tool.filetimes", () => { const read: { [sessionID: string]: { diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 75017749..5ce27bc4 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,77 +1,68 @@ import { App } from "../app/app" import { BunProc } from "../bun" -import { Config } from "../config/config" +import { Bus } from "../bus" +import { File } from "../file" import { Log } from "../util/log" import path from "path" export namespace Format { const log = Log.create({ service: "format" }) - const state = App.state("format", async () => { - const hooks: Record = {} - for (const item of FORMATTERS) { - if (await item.enabled()) { - for (const ext of item.extensions) { - const list = hooks[ext] ?? [] - list.push({ - command: item.command, - environment: item.environment, - }) - hooks[ext] = list - } - } - } - - const cfg = await Config.get() - for (const [file, items] of Object.entries( - cfg.experimental?.hook?.file_edited ?? {}, - )) { - for (const item of items) { - const list = hooks[file] ?? [] - list.push({ - command: item.command, - environment: item.environment, - }) - hooks[file] = list - } - } + const state = App.state("format", () => { + const enabled: Record = {} return { - hooks, + enabled, } }) - export async function run(file: string) { - log.info("formatting", { file }) - const { hooks } = await state() - const ext = path.extname(file) - const match = hooks[ext] - if (!match) return - - for (const item of match) { - log.info("running", { command: item.command }) - const proc = Bun.spawn({ - cmd: item.command.map((x) => x.replace("$FILE", file)), - cwd: App.info().path.cwd, - env: item.environment, - stdout: "ignore", - stderr: "ignore", - }) - const exit = await proc.exited - if (exit !== 0) - log.error("failed", { - command: item.command, - ...item.environment, - }) + async function isEnabled(item: Definition) { + const s = state() + let status = s.enabled[item.name] + if (status === undefined) { + status = await item.enabled() + s.enabled[item.name] = status } + return status } - interface Hook { - command: string[] - environment?: Record + async function getFormatter(ext: string) { + const result = [] + for (const item of FORMATTERS) { + if (!item.extensions.includes(ext)) continue + if (!isEnabled(item)) continue + result.push(item) + } + return result } - interface Native { + export function init() { + log.info("init") + Bus.subscribe(File.Event.Edited, async (payload) => { + const file = payload.properties.file + log.info("formatting", { file }) + const ext = path.extname(file) + + for (const item of await getFormatter(ext)) { + log.info("running", { command: item.command }) + const proc = Bun.spawn({ + cmd: item.command.map((x) => x.replace("$FILE", file)), + cwd: App.info().path.cwd, + env: item.environment, + stdout: "ignore", + stderr: "ignore", + }) + const exit = await proc.exited + if (exit !== 0) + log.error("failed", { + command: item.command, + ...item.environment, + }) + } + }) + } + + interface Definition { name: string command: string[] environment?: Record @@ -79,7 +70,7 @@ export namespace Format { enabled(): Promise } - const FORMATTERS: Native[] = [ + const FORMATTERS: Definition[] = [ { name: "prettier", command: [BunProc.which(), "run", "prettier", "--write", "$FILE"], @@ -133,17 +124,9 @@ export namespace Format { }, }, { - name: "mix format", + name: "mix", command: ["mix", "format", "$FILE"], - extensions: [ - ".ex", - ".exs", - ".eex", - ".heex", - ".leex", - ".neex", - ".sface", - ], + extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { try { const proc = Bun.spawn({ diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 25fcf71d..144d6328 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,28 +1,19 @@ import "zod-openapi/extend" -import { App } from "./app/app" -import { Server } from "./server/server" -import fs from "fs/promises" -import path from "path" -import { Share } from "./share/share" -import url from "node:url" -import { Global } from "./global" import yargs from "yargs" import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { ScrapCommand } from "./cli/cmd/scrap" import { Log } from "./util/log" -import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" +import { AuthCommand } from "./cli/cmd/auth" import { UpgradeCommand } from "./cli/cmd/upgrade" import { ModelsCommand } from "./cli/cmd/models" -import { Provider } from "./provider/provider" import { UI } from "./cli/ui" import { Installation } from "./installation" -import { Bus } from "./bus" -import { Config } from "./config/config" import { NamedError } from "./util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" +import { TuiCommand } from "./cli/cmd/tui" const cancel = new AbortController() @@ -55,103 +46,7 @@ const cli = yargs(hideBin(process.argv)) }) }) .usage("\n" + UI.logo()) - .command({ - command: "$0 [project]", - describe: "start opencode tui", - builder: (yargs) => - yargs.positional("project", { - type: "string", - describe: "path to start opencode in", - }), - handler: async (args) => { - while (true) { - const cwd = args.project ? path.resolve(args.project) : process.cwd() - try { - process.chdir(cwd) - } catch (e) { - UI.error("Failed to change directory to " + cwd) - return - } - const result = await App.provide({ cwd }, async (app) => { - const providers = await Provider.list() - if (Object.keys(providers).length === 0) { - return "needs_provider" - } - - await Share.init() - const server = Server.listen({ - port: 0, - hostname: "127.0.0.1", - }) - - let cmd = ["go", "run", "./main.go"] - let cwd = url.fileURLToPath( - new URL("../../tui/cmd/opencode", import.meta.url), - ) - if (Bun.embeddedFiles.length > 0) { - const blob = Bun.embeddedFiles[0] as File - let binaryName = blob.name - if (process.platform === "win32" && !binaryName.endsWith(".exe")) { - binaryName += ".exe" - } - const binary = path.join(Global.Path.cache, "tui", binaryName) - const file = Bun.file(binary) - if (!(await file.exists())) { - await Bun.write(file, blob, { mode: 0o755 }) - await fs.chmod(binary, 0o755) - } - cwd = process.cwd() - cmd = [binary] - } - const proc = Bun.spawn({ - cmd: [...cmd, ...process.argv.slice(2)], - signal: cancel.signal, - cwd, - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - env: { - ...process.env, - OPENCODE_SERVER: server.url.toString(), - OPENCODE_APP_INFO: JSON.stringify(app), - }, - onExit: () => { - server.stop() - }, - }) - - ;(async () => { - if (Installation.VERSION === "dev") return - if (Installation.isSnapshot()) return - const config = await Config.global() - if (config.autoupdate === false) return - const latest = await Installation.latest().catch(() => {}) - if (!latest) return - if (Installation.VERSION === latest) return - const method = await Installation.method() - if (method === "unknown") return - await Installation.upgrade(method, latest) - .then(() => { - Bus.publish(Installation.Event.Updated, { version: latest }) - }) - .catch(() => {}) - })() - - await proc.exited - server.stop() - - return "done" - }) - if (result === "done") break - if (result === "needs_provider") { - UI.empty() - UI.println(UI.logo(" ")) - UI.empty() - await AuthLoginCommand.handler(args) - } - } - }, - }) + .command(TuiCommand) .command(RunCommand) .command(GenerateCommand) .command(ScrapCommand) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0591bb56..f457ba9c 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -78,6 +78,12 @@ export namespace Session { info: Info, }), ), + Idle: Bus.event( + "session.idle", + z.object({ + sessionID: z.string(), + }), + ), Error: Bus.event( "session.error", z.object({ @@ -854,18 +860,8 @@ export namespace Session { [Symbol.dispose]() { log.info("unlocking", { sessionID }) state().pending.delete(sessionID) - Config.get().then((cfg) => { - if (cfg.experimental?.hook?.session_completed) { - for (const item of cfg.experimental.hook.session_completed) { - Bun.spawn({ - cmd: item.command, - cwd: App.info().path.cwd, - env: item.environment, - stdout: "ignore", - stderr: "ignore", - }) - } - } + Bus.publish(Event.Idle, { + sessionID, }) }, } diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts index f5faee6e..f498e0f4 100644 --- a/packages/opencode/src/share/share.ts +++ b/packages/opencode/src/share/share.ts @@ -1,4 +1,3 @@ -import { App } from "../app/app" import { Bus } from "../bus" import { Installation } from "../installation" import { Session } from "../session" @@ -11,12 +10,6 @@ export namespace Share { let queue: Promise = Promise.resolve() const pending = new Map() - const state = App.state("share", async () => { - Bus.subscribe(Storage.Event.Write, async (payload) => { - await sync(payload.properties.key, payload.properties.content) - }) - }) - export async function sync(key: string, content: any) { const [root, ...splits] = key.split("/") if (root !== "session") return @@ -52,8 +45,10 @@ export namespace Share { }) } - export async function init() { - await state() + export function init() { + Bus.subscribe(Storage.Event.Write, async (payload) => { + await sync(payload.properties.key, payload.properties.content) + }) } export const URL = diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index d597635e..fb02a536 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -5,13 +5,14 @@ import { z } from "zod" import * as path from "path" import { Tool } from "./tool" -import { FileTimes } from "./util/file-times" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import { Permission } from "../permission" import DESCRIPTION from "./edit.txt" import { App } from "../app/app" -import { Format } from "../format" +import { File } from "../file" +import { Bus } from "../bus" +import { FileTime } from "../file/time" export const EditTool = Tool.define({ id: "edit", @@ -60,7 +61,9 @@ export const EditTool = Tool.define({ if (params.oldString === "") { contentNew = params.newString await Bun.write(filepath, params.newString) - await Format.run(filepath) + await Bus.publish(File.Event.Edited, { + file: filepath, + }) return } @@ -69,7 +72,7 @@ export const EditTool = Tool.define({ if (!stats) throw new Error(`File ${filepath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`) - await FileTimes.assert(ctx.sessionID, filepath) + await FileTime.assert(ctx.sessionID, filepath) contentOld = await file.text() contentNew = replace( @@ -79,7 +82,9 @@ export const EditTool = Tool.define({ params.replaceAll, ) await file.write(contentNew) - await Format.run(filepath) + await Bus.publish(File.Event.Edited, { + file: filepath, + }) contentNew = await file.text() })() @@ -87,7 +92,7 @@ export const EditTool = Tool.define({ createTwoFilesPatch(filepath, filepath, contentOld, contentNew), ) - FileTimes.read(ctx.sessionID, filepath) + FileTime.read(ctx.sessionID, filepath) let output = "" await LSP.touchFile(filepath, true) diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 508814ac..6266d163 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -2,7 +2,7 @@ import { z } from "zod" import * as path from "path" import * as fs from "fs/promises" import { Tool } from "./tool" -import { FileTimes } from "./util/file-times" +import { FileTime } from "../file/time" import DESCRIPTION from "./patch.txt" const PatchParams = z.object({ @@ -244,7 +244,7 @@ export const PatchTool = Tool.define({ absPath = path.resolve(process.cwd(), absPath) } - await FileTimes.assert(ctx.sessionID, absPath) + await FileTime.assert(ctx.sessionID, absPath) try { const stats = await fs.stat(absPath) @@ -351,7 +351,7 @@ export const PatchTool = Tool.define({ totalAdditions += additions totalRemovals += removals - FileTimes.read(ctx.sessionID, absPath) + FileTime.read(ctx.sessionID, absPath) } const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals` diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 63e5c8ef..3691459d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -3,7 +3,7 @@ import * as fs from "fs" import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" -import { FileTimes } from "./util/file-times" +import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { App } from "../app/app" @@ -90,7 +90,7 @@ export const ReadTool = Tool.define({ // just warms the lsp client await LSP.touchFile(filePath, true) - FileTimes.read(ctx.sessionID, filePath) + FileTime.read(ctx.sessionID, filePath) return { output, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 264633ff..b0515805 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,12 +1,13 @@ import { z } from "zod" import * as path from "path" import { Tool } from "./tool" -import { FileTimes } from "./util/file-times" import { LSP } from "../lsp" import { Permission } from "../permission" import DESCRIPTION from "./write.txt" import { App } from "../app/app" -import { Format } from "../format" +import { Bus } from "../bus" +import { File } from "../file" +import { FileTime } from "../file/time" export const WriteTool = Tool.define({ id: "write", @@ -27,7 +28,7 @@ export const WriteTool = Tool.define({ const file = Bun.file(filepath) const exists = await file.exists() - if (exists) await FileTimes.assert(ctx.sessionID, filepath) + if (exists) await FileTime.assert(ctx.sessionID, filepath) await Permission.ask({ id: "write", @@ -43,8 +44,10 @@ export const WriteTool = Tool.define({ }) await Bun.write(filepath, params.content) - await Format.run(filepath) - FileTimes.read(ctx.sessionID, filepath) + await Bus.publish(File.Event.Edited, { + file: filepath, + }) + FileTime.read(ctx.sessionID, filepath) let output = "" await LSP.touchFile(filepath, true) diff --git a/packages/opencode/src/util/project.ts b/packages/opencode/src/util/project.ts deleted file mode 100644 index 72a9bda5..00000000 --- a/packages/opencode/src/util/project.ts +++ /dev/null @@ -1,91 +0,0 @@ -import path from "path" -import { readdir } from "fs/promises" - -export namespace Project { - export async function getName(rootPath: string): Promise { - try { - const packageJsonPath = path.join(rootPath, "package.json") - const packageJson = await Bun.file(packageJsonPath).json() - if (packageJson.name && typeof packageJson.name === "string") { - return packageJson.name - } - } catch {} - - try { - const cargoTomlPath = path.join(rootPath, "Cargo.toml") - const cargoToml = await Bun.file(cargoTomlPath).text() - const nameMatch = cargoToml.match(/^\s*name\s*=\s*"([^"]+)"/m) - if (nameMatch?.[1]) { - return nameMatch[1] - } - } catch {} - - try { - const pyprojectPath = path.join(rootPath, "pyproject.toml") - const pyproject = await Bun.file(pyprojectPath).text() - const nameMatch = pyproject.match(/^\s*name\s*=\s*"([^"]+)"/m) - if (nameMatch?.[1]) { - return nameMatch[1] - } - } catch {} - - try { - const goModPath = path.join(rootPath, "go.mod") - const goMod = await Bun.file(goModPath).text() - const moduleMatch = goMod.match(/^module\s+(.+)$/m) - if (moduleMatch?.[1]) { - // Extract just the last part of the module path - const parts = moduleMatch[1].trim().split("/") - return parts[parts.length - 1] - } - } catch {} - - try { - const composerPath = path.join(rootPath, "composer.json") - const composer = await Bun.file(composerPath).json() - if (composer.name && typeof composer.name === "string") { - // Composer names are usually vendor/package, extract the package part - const parts = composer.name.split("/") - return parts[parts.length - 1] - } - } catch {} - - try { - const pomPath = path.join(rootPath, "pom.xml") - const pom = await Bun.file(pomPath).text() - const artifactIdMatch = pom.match(/([^<]+)<\/artifactId>/) - if (artifactIdMatch?.[1]) { - return artifactIdMatch[1] - } - } catch {} - - for (const gradleFile of ["build.gradle", "build.gradle.kts"]) { - try { - const gradlePath = path.join(rootPath, gradleFile) - await Bun.file(gradlePath).text() // Check if gradle file exists - // Look for rootProject.name in settings.gradle - const settingsPath = path.join(rootPath, "settings.gradle") - const settings = await Bun.file(settingsPath).text() - const nameMatch = settings.match( - /rootProject\.name\s*=\s*['"]([^'"]+)['"]/, - ) - if (nameMatch?.[1]) { - return nameMatch[1] - } - } catch {} - } - - const dotnetExtensions = [".csproj", ".fsproj", ".vbproj"] - try { - const files = await readdir(rootPath) - for (const file of files) { - if (dotnetExtensions.some((ext) => file.endsWith(ext))) { - // Use the filename without extension as project name - return path.basename(file, path.extname(file)) - } - } - } catch {} - - return path.basename(rootPath) - } -}