Browse Source

lazy load formatters

Dax Raad 8 months ago
parent
commit
d972c27f03

+ 5 - 1
package.json

@@ -41,5 +41,9 @@
   ],
   ],
   "patchedDependencies": {
   "patchedDependencies": {
     "[email protected]": "patches/[email protected]"
     "[email protected]": "patches/[email protected]"
-  }
+  },
+  "randomField": "purple-elephant-42",
+  "mysteriousData": "cosmic-banana-7891",
+  "quirkyValue": "dancing-octopus-314",
+  "whimsicalEntry": "flying-penguin-2024"
 }
 }

+ 22 - 24
packages/opencode/src/app/app.ts

@@ -2,7 +2,6 @@ import "zod-openapi/extend"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { Context } from "../util/context"
 import { Context } from "../util/context"
 import { Filesystem } from "../util/filesystem"
 import { Filesystem } from "../util/filesystem"
-import { Project } from "../util/project"
 import { Global } from "../global"
 import { Global } from "../global"
 import path from "path"
 import path from "path"
 import os from "os"
 import os from "os"
@@ -13,7 +12,6 @@ export namespace App {
 
 
   export const Info = z
   export const Info = z
     .object({
     .object({
-      project: z.string(),
       user: z.string(),
       user: z.string(),
       hostname: z.string(),
       hostname: z.string(),
       git: z.boolean(),
       git: z.boolean(),
@@ -33,11 +31,21 @@ export namespace App {
     })
     })
   export type Info = z.infer<typeof Info>
   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"
   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", {
     log.info("creating", {
       cwd: input.cwd,
       cwd: input.cwd,
     })
     })
@@ -66,10 +74,8 @@ export namespace App {
     >()
     >()
 
 
     const root = git ?? input.cwd
     const root = git ?? input.cwd
-    const project = await Project.getName(root)
 
 
     const info: Info = {
     const info: Info = {
-      project: project,
       user: os.userInfo().username,
       user: os.userInfo().username,
       hostname: os.hostname(),
       hostname: os.hostname(),
       time: {
       time: {
@@ -84,12 +90,20 @@ export namespace App {
         cwd: input.cwd,
         cwd: input.cwd,
       },
       },
     }
     }
-    const result = {
+    const app = {
       services,
       services,
       info,
       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>(
   export function state<State>(
@@ -115,22 +129,6 @@ export namespace App {
     return ctx.use().info
     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() {
   export async function initialize() {
     const { info } = ctx.use()
     const { info } = ctx.use()
     info.time.initialized = Date.now()
     info.time.initialized = Date.now()

+ 4 - 2
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,
     def: Definition,
     properties: z.output<Definition["properties"]>,
     properties: z.output<Definition["properties"]>,
   ) {
   ) {
@@ -60,12 +60,14 @@ export namespace Bus {
     log.info("publishing", {
     log.info("publishing", {
       type: def.type,
       type: def.type,
     })
     })
+    const pending = []
     for (const key of [def.type, "*"]) {
     for (const key of [def.type, "*"]) {
       const match = state().subscriptions.get(key)
       const match = state().subscriptions.get(key)
       for (const sub of match ?? []) {
       for (const sub of match ?? []) {
-        sub(payload)
+        pending.push(sub(payload))
       }
       }
     }
     }
+    return Promise.all(pending)
   }
   }
 
 
   export function subscribe<Definition extends EventDefinition>(
   export function subscribe<Definition extends EventDefinition>(

+ 17 - 0
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)
+  })
+}

+ 90 - 100
packages/opencode/src/cli/cmd/run.ts

@@ -1,14 +1,13 @@
 import type { Argv } from "yargs"
 import type { Argv } from "yargs"
-import { App } from "../../app/app"
 import { Bus } from "../../bus"
 import { Bus } from "../../bus"
 import { Provider } from "../../provider/provider"
 import { Provider } from "../../provider/provider"
 import { Session } from "../../session"
 import { Session } from "../../session"
-import { Share } from "../../share/share"
 import { Message } from "../../session/message"
 import { Message } from "../../session/message"
 import { UI } from "../ui"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { cmd } from "./cmd"
 import { Flag } from "../../flag/flag"
 import { Flag } from "../../flag/flag"
 import { Config } from "../../config/config"
 import { Config } from "../../config/config"
+import { bootstrap } from "../bootstrap"
 
 
 const TOOL: Record<string, [string, string]> = {
 const TOOL: Record<string, [string, string]> = {
   todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
   todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -56,118 +55,109 @@ export const RunCommand = cmd({
   },
   },
   handler: async (args) => {
   handler: async (args) => {
     const message = args.message.join(" ")
     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.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()
+    })
   },
   },
 })
 })

+ 108 - 0
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)
+      }
+    }
+  },
+})

+ 1 - 0
packages/opencode/src/config/config.ts

@@ -22,6 +22,7 @@ export namespace Config {
       }
       }
     }
     }
     log.info("loaded", result)
     log.info("loaded", result)
+
     return result
     return result
   })
   })
 
 

+ 54 - 0
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",
+          })
+        }
+      }
+    })
+  }
+}

+ 13 - 0
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(),
+      }),
+    ),
+  }
+}

+ 2 - 2
packages/opencode/src/tool/util/file-times.ts → 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", () => {
   export const state = App.state("tool.filetimes", () => {
     const read: {
     const read: {
       [sessionID: string]: {
       [sessionID: string]: {

+ 49 - 66
packages/opencode/src/format/index.ts

@@ -1,77 +1,68 @@
 import { App } from "../app/app"
 import { App } from "../app/app"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
-import { Config } from "../config/config"
+import { Bus } from "../bus"
+import { File } from "../file"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import path from "path"
 import path from "path"
 
 
 export namespace Format {
 export namespace Format {
   const log = Log.create({ service: "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 {
     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
     name: string
     command: string[]
     command: string[]
     environment?: Record<string, string>
     environment?: Record<string, string>
@@ -79,7 +70,7 @@ export namespace Format {
     enabled(): Promise<boolean>
     enabled(): Promise<boolean>
   }
   }
 
 
-  const FORMATTERS: Native[] = [
+  const FORMATTERS: Definition[] = [
     {
     {
       name: "prettier",
       name: "prettier",
       command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
       command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
@@ -133,17 +124,9 @@ export namespace Format {
       },
       },
     },
     },
     {
     {
-      name: "mix format",
+      name: "mix",
       command: ["mix", "format", "$FILE"],
       command: ["mix", "format", "$FILE"],
-      extensions: [
-        ".ex",
-        ".exs",
-        ".eex",
-        ".heex",
-        ".leex",
-        ".neex",
-        ".sface",
-      ],
+      extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
       async enabled() {
       async enabled() {
         try {
         try {
           const proc = Bun.spawn({
           const proc = Bun.spawn({

+ 3 - 108
packages/opencode/src/index.ts

@@ -1,28 +1,19 @@
 import "zod-openapi/extend"
 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 yargs from "yargs"
 import { hideBin } from "yargs/helpers"
 import { hideBin } from "yargs/helpers"
 import { RunCommand } from "./cli/cmd/run"
 import { RunCommand } from "./cli/cmd/run"
 import { GenerateCommand } from "./cli/cmd/generate"
 import { GenerateCommand } from "./cli/cmd/generate"
 import { ScrapCommand } from "./cli/cmd/scrap"
 import { ScrapCommand } from "./cli/cmd/scrap"
 import { Log } from "./util/log"
 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 { UpgradeCommand } from "./cli/cmd/upgrade"
 import { ModelsCommand } from "./cli/cmd/models"
 import { ModelsCommand } from "./cli/cmd/models"
-import { Provider } from "./provider/provider"
 import { UI } from "./cli/ui"
 import { UI } from "./cli/ui"
 import { Installation } from "./installation"
 import { Installation } from "./installation"
-import { Bus } from "./bus"
-import { Config } from "./config/config"
 import { NamedError } from "./util/error"
 import { NamedError } from "./util/error"
 import { FormatError } from "./cli/error"
 import { FormatError } from "./cli/error"
 import { ServeCommand } from "./cli/cmd/serve"
 import { ServeCommand } from "./cli/cmd/serve"
+import { TuiCommand } from "./cli/cmd/tui"
 
 
 const cancel = new AbortController()
 const cancel = new AbortController()
 
 
@@ -55,103 +46,7 @@ const cli = yargs(hideBin(process.argv))
     })
     })
   })
   })
   .usage("\n" + UI.logo())
   .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(RunCommand)
   .command(GenerateCommand)
   .command(GenerateCommand)
   .command(ScrapCommand)
   .command(ScrapCommand)

+ 8 - 12
packages/opencode/src/session/index.ts

@@ -78,6 +78,12 @@ export namespace Session {
         info: Info,
         info: Info,
       }),
       }),
     ),
     ),
+    Idle: Bus.event(
+      "session.idle",
+      z.object({
+        sessionID: z.string(),
+      }),
+    ),
     Error: Bus.event(
     Error: Bus.event(
       "session.error",
       "session.error",
       z.object({
       z.object({
@@ -854,18 +860,8 @@ export namespace Session {
       [Symbol.dispose]() {
       [Symbol.dispose]() {
         log.info("unlocking", { sessionID })
         log.info("unlocking", { sessionID })
         state().pending.delete(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,
         })
         })
       },
       },
     }
     }

+ 4 - 9
packages/opencode/src/share/share.ts

@@ -1,4 +1,3 @@
-import { App } from "../app/app"
 import { Bus } from "../bus"
 import { Bus } from "../bus"
 import { Installation } from "../installation"
 import { Installation } from "../installation"
 import { Session } from "../session"
 import { Session } from "../session"
@@ -11,12 +10,6 @@ export namespace Share {
   let queue: Promise<void> = Promise.resolve()
   let queue: Promise<void> = Promise.resolve()
   const pending = new Map<string, any>()
   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) {
   export async function sync(key: string, content: any) {
     const [root, ...splits] = key.split("/")
     const [root, ...splits] = key.split("/")
     if (root !== "session") return
     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 =
   export const URL =

+ 11 - 6
packages/opencode/src/tool/edit.ts

@@ -5,13 +5,14 @@
 import { z } from "zod"
 import { z } from "zod"
 import * as path from "path"
 import * as path from "path"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { FileTimes } from "./util/file-times"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch } from "diff"
 import { createTwoFilesPatch } from "diff"
 import { Permission } from "../permission"
 import { Permission } from "../permission"
 import DESCRIPTION from "./edit.txt"
 import DESCRIPTION from "./edit.txt"
 import { App } from "../app/app"
 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({
 export const EditTool = Tool.define({
   id: "edit",
   id: "edit",
@@ -60,7 +61,9 @@ export const EditTool = Tool.define({
       if (params.oldString === "") {
       if (params.oldString === "") {
         contentNew = params.newString
         contentNew = params.newString
         await Bun.write(filepath, params.newString)
         await Bun.write(filepath, params.newString)
-        await Format.run(filepath)
+        await Bus.publish(File.Event.Edited, {
+          file: filepath,
+        })
         return
         return
       }
       }
 
 
@@ -69,7 +72,7 @@ export const EditTool = Tool.define({
       if (!stats) throw new Error(`File ${filepath} not found`)
       if (!stats) throw new Error(`File ${filepath} not found`)
       if (stats.isDirectory())
       if (stats.isDirectory())
         throw new Error(`Path is a directory, not a file: ${filepath}`)
         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()
       contentOld = await file.text()
 
 
       contentNew = replace(
       contentNew = replace(
@@ -79,7 +82,9 @@ export const EditTool = Tool.define({
         params.replaceAll,
         params.replaceAll,
       )
       )
       await file.write(contentNew)
       await file.write(contentNew)
-      await Format.run(filepath)
+      await Bus.publish(File.Event.Edited, {
+        file: filepath,
+      })
       contentNew = await file.text()
       contentNew = await file.text()
     })()
     })()
 
 
@@ -87,7 +92,7 @@ export const EditTool = Tool.define({
       createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
       createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
     )
     )
 
 
-    FileTimes.read(ctx.sessionID, filepath)
+    FileTime.read(ctx.sessionID, filepath)
 
 
     let output = ""
     let output = ""
     await LSP.touchFile(filepath, true)
     await LSP.touchFile(filepath, true)

+ 3 - 3
packages/opencode/src/tool/patch.ts

@@ -2,7 +2,7 @@ import { z } from "zod"
 import * as path from "path"
 import * as path from "path"
 import * as fs from "fs/promises"
 import * as fs from "fs/promises"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { FileTimes } from "./util/file-times"
+import { FileTime } from "../file/time"
 import DESCRIPTION from "./patch.txt"
 import DESCRIPTION from "./patch.txt"
 
 
 const PatchParams = z.object({
 const PatchParams = z.object({
@@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
         absPath = path.resolve(process.cwd(), absPath)
         absPath = path.resolve(process.cwd(), absPath)
       }
       }
 
 
-      await FileTimes.assert(ctx.sessionID, absPath)
+      await FileTime.assert(ctx.sessionID, absPath)
 
 
       try {
       try {
         const stats = await fs.stat(absPath)
         const stats = await fs.stat(absPath)
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
       totalAdditions += additions
       totalAdditions += additions
       totalRemovals += removals
       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`
     const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`

+ 2 - 2
packages/opencode/src/tool/read.ts

@@ -3,7 +3,7 @@ import * as fs from "fs"
 import * as path from "path"
 import * as path from "path"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
-import { FileTimes } from "./util/file-times"
+import { FileTime } from "../file/time"
 import DESCRIPTION from "./read.txt"
 import DESCRIPTION from "./read.txt"
 import { App } from "../app/app"
 import { App } from "../app/app"
 
 
@@ -90,7 +90,7 @@ export const ReadTool = Tool.define({
 
 
     // just warms the lsp client
     // just warms the lsp client
     await LSP.touchFile(filePath, true)
     await LSP.touchFile(filePath, true)
-    FileTimes.read(ctx.sessionID, filePath)
+    FileTime.read(ctx.sessionID, filePath)
 
 
     return {
     return {
       output,
       output,

+ 8 - 5
packages/opencode/src/tool/write.ts

@@ -1,12 +1,13 @@
 import { z } from "zod"
 import { z } from "zod"
 import * as path from "path"
 import * as path from "path"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { FileTimes } from "./util/file-times"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
 import { Permission } from "../permission"
 import { Permission } from "../permission"
 import DESCRIPTION from "./write.txt"
 import DESCRIPTION from "./write.txt"
 import { App } from "../app/app"
 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({
 export const WriteTool = Tool.define({
   id: "write",
   id: "write",
@@ -27,7 +28,7 @@ export const WriteTool = Tool.define({
 
 
     const file = Bun.file(filepath)
     const file = Bun.file(filepath)
     const exists = await file.exists()
     const exists = await file.exists()
-    if (exists) await FileTimes.assert(ctx.sessionID, filepath)
+    if (exists) await FileTime.assert(ctx.sessionID, filepath)
 
 
     await Permission.ask({
     await Permission.ask({
       id: "write",
       id: "write",
@@ -43,8 +44,10 @@ export const WriteTool = Tool.define({
     })
     })
 
 
     await Bun.write(filepath, params.content)
     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 = ""
     let output = ""
     await LSP.touchFile(filepath, true)
     await LSP.touchFile(filepath, true)

+ 0 - 91
packages/opencode/src/util/project.ts

@@ -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)
-  }
-}