summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-06-27 11:29:20 -0400
committerDax Raad <[email protected]>2025-06-27 11:33:37 -0400
commit2ec0611f42cf31072376ac74d42e4187d76feb12 (patch)
tree3f4a189b2d932721b6b16acec9da5231ae73d455
parent334161a30ecbcf01f164cb267c891db2abd3b612 (diff)
downloadopencode-2ec0611f42cf31072376ac74d42e4187d76feb12.tar.gz
opencode-2ec0611f42cf31072376ac74d42e4187d76feb12.zip
lazy load formatters
-rw-r--r--package.json6
-rw-r--r--packages/opencode/src/app/app.ts46
-rw-r--r--packages/opencode/src/bus/index.ts6
-rw-r--r--packages/opencode/src/cli/bootstrap.ts17
-rw-r--r--packages/opencode/src/cli/cmd/run.ts190
-rw-r--r--packages/opencode/src/cli/cmd/tui.ts108
-rw-r--r--packages/opencode/src/config/config.ts1
-rw-r--r--packages/opencode/src/config/hooks.ts54
-rw-r--r--packages/opencode/src/file/index.ts13
-rw-r--r--packages/opencode/src/file/time.ts (renamed from packages/opencode/src/tool/util/file-times.ts)4
-rw-r--r--packages/opencode/src/format/index.ts115
-rw-r--r--packages/opencode/src/index.ts111
-rw-r--r--packages/opencode/src/session/index.ts20
-rw-r--r--packages/opencode/src/share/share.ts13
-rw-r--r--packages/opencode/src/tool/edit.ts17
-rw-r--r--packages/opencode/src/tool/patch.ts6
-rw-r--r--packages/opencode/src/tool/read.ts4
-rw-r--r--packages/opencode/src/tool/write.ts13
-rw-r--r--packages/opencode/src/util/project.ts91
19 files changed, 404 insertions, 431 deletions
diff --git a/package.json b/package.json
index ed4fcdeda..91515be73 100644
--- a/package.json
+++ b/package.json
@@ -41,5 +41,9 @@
],
"patchedDependencies": {
- }
+ },
+ "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 44b6d9c75..9b26c05bc 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<typeof Info>
- const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
+ const ctx = Context.create<{
+ info: Info
+ services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
+ }>("app")
const APP_JSON = "app.json"
- async function create(input: { cwd: string }) {
+ export type Input = {
+ cwd: string
+ }
+
+ export async function provide<T>(
+ input: Input,
+ cb: (app: App.Info) => Promise<T>,
+ ) {
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<State>(
@@ -115,22 +129,6 @@ export namespace App {
return ctx.use().info
}
- export async function provide<T>(
- input: { cwd: string },
- cb: (app: Info) => Promise<T>,
- ) {
- 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 596e6c1c9..8461269a9 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<Definition extends EventDefinition>(
+ export async function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -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<Definition extends EventDefinition>(
diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts
new file mode 100644
index 000000000..66c8a7570
--- /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<T>(
+ input: App.Input,
+ cb: (app: App.Info) => Promise<T>,
+) {
+ 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 f8f886868..1905aa173 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<string, [string, string]> = {
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)
+ 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
+ }
- return Session.create()
- })()
+ if (args.session) return Session.get(args.session)
- if (!session) {
- UI.error("Session not found")
- return
- }
+ return Session.create()
+ })()
- const isPiped = !process.stdout.isTTY
+ if (!session) {
+ UI.error("Session not found")
+ return
+ }
- UI.empty()
- UI.println(UI.logo())
- UI.empty()
- UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
- UI.empty()
+ const isPiped = !process.stdout.isTTY
- 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()
+ UI.empty()
+ UI.println(UI.logo())
+ UI.empty()
+ UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
+ UI.empty()
- const { providerID, modelID } = args.model
- ? Provider.parseModel(args.model)
- : await Provider.defaultModel()
+ 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,
- )
+ 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 === "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
- }
- printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
+ 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,
- },
- ],
- })
+ 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()
- },
- )
+ 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 000000000..203cc2998
--- /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 bf3c0ecdf..efb379b52 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 000000000..ffa2475f7
--- /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 000000000..7b5beab4d
--- /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
index 7eb60aecf..531321974 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 750177498..5ce27bc4e 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<string, Hook[]> = {}
- 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<string, boolean> = {}
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
+ 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
+ }
- 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 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 Hook {
- command: string[]
- environment?: Record<string, string>
+ 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 Native {
+ interface Definition {
name: string
command: string[]
environment?: Record<string, string>
@@ -79,7 +70,7 @@ export namespace Format {
enabled(): Promise<boolean>
}
- 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 25fcf71dd..144d6328b 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 0591bb56a..f457ba9c7 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 f5faee6ed..f498e0f45 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<void> = Promise.resolve()
const pending = new Map<string, any>()
- 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 d597635e2..fb02a536c 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 508814ac4..6266d1639 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 63e5c8efe..3691459db 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 264633ff0..b05158055 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 72a9bda52..000000000
--- 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<string> {
- 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>([^<]+)<\/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)
- }
-}