Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad 8034ce8a57 add auto formatting and experimental hooks feature 2025-06-26 22:16:55 -04:00
53 changed files with 589 additions and 795 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.5",
"version": "0.0.0",
"bin": {
"opencode": "./bin/opencode",
},
+1 -5
View File
@@ -3,11 +3,7 @@
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
".json": []
},
"session_completed": [
{
+8 -8
View File
@@ -6,20 +6,20 @@
import "sst"
declare module "sst" {
export interface Resource {
Web: {
type: "sst.cloudflare.Astro"
url: string
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
Api: cloudflare.Service
Bucket: cloudflare.R2Bucket
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
}
}
import "sst"
export {}
export {}
+26 -8
View File
@@ -202,7 +202,10 @@
"type": "number"
}
},
"required": ["input", "output"],
"required": [
"input",
"output"
],
"additionalProperties": false
},
"limit": {
@@ -215,7 +218,10 @@
"type": "number"
}
},
"required": ["context", "output"],
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
@@ -234,7 +240,9 @@
"additionalProperties": {}
}
},
"required": ["models"],
"required": [
"models"
],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
@@ -266,7 +274,10 @@
"description": "Environment variables to set when running the MCP server"
}
},
"required": ["type", "command"],
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
@@ -282,7 +293,10 @@
"description": "URL of the remote MCP server"
}
},
"required": ["type", "url"],
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
@@ -315,7 +329,9 @@
}
}
},
"required": ["command"],
"required": [
"command"
],
"additionalProperties": false
}
}
@@ -338,7 +354,9 @@
}
}
},
"required": ["command"],
"required": [
"command"
],
"additionalProperties": false
}
}
@@ -351,4 +369,4 @@
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
+9 -12
View File
@@ -142,7 +142,7 @@ if (!snapshot) {
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='${pkg}'",
"pkgname='opencode-bin'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@@ -166,17 +166,14 @@ if (!snapshot) {
"",
].join("\n")
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
await $`rm -rf ./dist/aur-opencode-bin`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
// Homebrew formula
const homebrewFormula = [
+22 -27
View File
@@ -13,7 +13,6 @@ export namespace App {
export const Info = z
.object({
user: z.string(),
hostname: z.string(),
git: z.boolean(),
path: z.object({
config: z.string(),
@@ -31,21 +30,11 @@ export namespace App {
})
export type Info = z.infer<typeof Info>
const ctx = Context.create<{
info: Info
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
const APP_JSON = "app.json"
export type Input = {
cwd: string
}
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
async function create(input: { cwd: string }) {
log.info("creating", {
cwd: input.cwd,
})
@@ -73,11 +62,8 @@ export namespace App {
}
>()
const root = git ?? input.cwd
const info: Info = {
user: os.userInfo().username,
hostname: os.hostname(),
time: {
initialized: state.initialized,
},
@@ -86,24 +72,16 @@ export namespace App {
config: Global.Path.config,
state: Global.Path.state,
data,
root,
root: git ?? input.cwd,
cwd: input.cwd,
},
}
const app = {
const result = {
services,
info,
}
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
})
return result
}
export function state<State>(
@@ -129,6 +107,22 @@ 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()
@@ -148,3 +142,4 @@ export namespace App {
.replace(/[^A-Za-z0-9_]/g, "-")
}
}
+2 -4
View File
@@ -49,7 +49,7 @@ export namespace Bus {
)
}
export async function publish<Definition extends EventDefinition>(
export function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -60,14 +60,12 @@ 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 ?? []) {
pending.push(sub(payload))
sub(payload)
}
}
return Promise.all(pending)
}
export function subscribe<Definition extends EventDefinition>(
-17
View File
@@ -1,17 +0,0 @@
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)
})
}
+8 -8
View File
@@ -31,7 +31,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
@@ -46,14 +46,14 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
activeEnvVars.push({
provider: provider.name || providerID,
envVar
})
}
}
@@ -62,11 +62,11 @@ export const AuthListCommand = cmd({
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variables`)
}
},
+2 -2
View File
@@ -8,7 +8,7 @@ export const ModelsCommand = cmd({
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
@@ -16,4 +16,4 @@ export const ModelsCommand = cmd({
}
})
},
})
})
+111 -101
View File
@@ -1,13 +1,14 @@
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],
@@ -55,109 +56,118 @@ export const RunCommand = cmd({
},
handler: async (args) => {
const message = args.message.join(" ")
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
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
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_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
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")
}
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
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
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
})
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
const isPiped = !process.stdout.isTTY
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
}
UI.empty()
})
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_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
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")
}
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)
}
UI.empty()
},
)
},
})
+7 -4
View File
@@ -7,9 +7,12 @@ export const ScrapCommand = cmd({
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
-108
View File
@@ -1,108 +0,0 @@
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
View File
@@ -22,7 +22,6 @@ export namespace Config {
}
}
log.info("loaded", result)
return result
})
-54
View File
@@ -1,54 +0,0 @@
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
View File
@@ -1,13 +0,0 @@
import { z } from "zod"
import { Bus } from "../bus"
export namespace File {
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
}
+105 -108
View File
@@ -1,68 +1,75 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import { App } from '../app/app'
import { BunProc } from '../bun'
import { Config } from '../config/config'
import { Log } from '../util/log'
import path from 'path'
export namespace Format {
const log = Log.create({ service: "format" })
const log = Log.create({ service: 'format' })
const state = App.state("format", () => {
const enabled: Record<string, boolean> = {}
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
}
}
return {
enabled,
hooks,
}
})
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
}
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 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
}
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",
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,
})
const exit = await proc.exited
if (exit !== 0)
log.error('failed', {
command: item.command,
...item.environment,
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
}
})
}
}
interface Definition {
interface Hook {
command: string[]
environment?: Record<string, string>
}
interface Native {
name: string
command: string[]
environment?: Record<string, string>
@@ -70,70 +77,60 @@ export namespace Format {
enabled(): Promise<boolean>
}
const FORMATTERS: Definition[] = [
const FORMATTERS: Native[] = [
{
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
name: 'prettier',
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
'.js',
'.jsx',
'.mjs',
'.cjs',
'.ts',
'.tsx',
'.mts',
'.cts',
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
'.vue',
'.svelte',
'.json',
'.jsonc',
'.yaml',
'.yml',
'.toml',
'.xml',
'.md',
'.mdx',
'.php',
'.rb',
'.java',
'.go',
'.rs',
'.swift',
'.kt',
'.kts',
'.sol',
'.graphql',
'.gql',
],
command: [BunProc.which(), 'run', 'prettier', '--write', '$FILE'],
environment: {
BUN_BE_BUN: '1',
},
async enabled() {
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cmd: [BunProc.which(), 'run', 'prettier', '--version'],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
BUN_BE_BUN: '1',
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
{
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
try {
const proc = Bun.spawn({
cmd: ["mix", "--version"],
cwd: App.info().path.cwd,
stdout: "ignore",
stderr: "ignore",
stdout: 'ignore',
stderr: 'ignore',
})
const exit = await proc.exited
return exit === 0
+108 -3
View File
@@ -1,19 +1,28 @@
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 } from "./cli/cmd/auth"
import { AuthCommand, AuthLoginCommand } 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()
@@ -46,7 +55,103 @@ const cli = yargs(hideBin(process.argv))
})
})
.usage("\n" + UI.logo())
.command(TuiCommand)
.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(RunCommand)
.command(GenerateCommand)
.command(ScrapCommand)
+10 -9
View File
@@ -78,12 +78,6 @@ export namespace Session {
info: Info,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event(
"session.error",
z.object({
@@ -543,7 +537,6 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxTokens: model.info.limit.output || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
providerOptions: model.info.options,
@@ -860,8 +853,16 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
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,
})
}
}
})
},
}
+9 -4
View File
@@ -1,3 +1,4 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
@@ -10,6 +11,12 @@ 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
@@ -45,10 +52,8 @@ export namespace Share {
})
}
export function init() {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
export async function init() {
await state()
}
export const URL =
+6 -12
View File
@@ -5,14 +5,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 { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Format } from "../format"
export const EditTool = Tool.define({
id: "edit",
@@ -61,9 +60,7 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
await Format.run(filepath)
return
}
@@ -72,7 +69,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 FileTime.assert(ctx.sessionID, filepath)
await FileTimes.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
@@ -82,17 +79,14 @@ export const EditTool = Tool.define({
params.replaceAll,
)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
contentNew = await file.text()
await Format.run(filepath)
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTime.read(ctx.sessionID, filepath)
FileTimes.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { FileTimes } from "./util/file-times"
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 FileTime.assert(ctx.sessionID, absPath)
await FileTimes.assert(ctx.sessionID, absPath)
try {
const stats = await fs.stat(absPath)
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
totalAdditions += additions
totalRemovals += removals
FileTime.read(ctx.sessionID, absPath)
FileTimes.read(ctx.sessionID, absPath)
}
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
+2 -2
View File
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { FileTimes } from "./util/file-times"
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)
FileTime.read(ctx.sessionID, filePath)
FileTimes.read(ctx.sessionID, filePath)
return {
output,
@@ -1,6 +1,6 @@
import { App } from "../app/app"
import { App } from "../../app/app"
export namespace FileTime {
export namespace FileTimes {
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
+5 -8
View File
@@ -1,13 +1,12 @@
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 { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
import { Format } from "../format"
export const WriteTool = Tool.define({
id: "write",
@@ -28,7 +27,7 @@ export const WriteTool = Tool.define({
const file = Bun.file(filepath)
const exists = await file.exists()
if (exists) await FileTime.assert(ctx.sessionID, filepath)
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "write",
@@ -44,10 +43,8 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
FileTime.read(ctx.sessionID, filepath)
await Format.run(filepath)
FileTimes.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
+1
View File
@@ -8,3 +8,4 @@ export function lazy<T>(fn: () => T) {
return value as T
}
}
+1 -4
View File
@@ -19,10 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+4 -4
View File
@@ -316,13 +316,13 @@ const testCases: TestCase[] = [
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: "test[123]",
replace: "test[456]",
find: 'test[123]',
replace: 'test[456]',
},
{
content: 'const regex = "^start.*end$";',
find: "^start.*end$",
replace: "^begin.*finish$",
find: '^start.*end$',
replace: '^begin.*finish$',
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
+1 -1
View File
@@ -23,4 +23,4 @@
- **Client**: Generated OpenAPI client communicates with TypeScript server
- **Components**: Reusable UI components in `internal/components/`
- **Themes**: JSON-based theming system with override hierarchy
- **State**: Centralized app state with message passing
- **State**: Centralized app state with message passing
+1 -2
View File
@@ -49,8 +49,6 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfo)
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
@@ -68,6 +66,7 @@ func main() {
program := tea.NewProgram(
tui.NewModel(app_),
// tea.WithColorProfile(colorprofile.ANSI),
tea.WithAltScreen(),
tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
@@ -226,18 +226,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
if message.Role == client.Assistant {
markdownWidth = width - padding - 4 - 3
}
minWidth := max(markdownWidth, (width-4)/2)
messageStyle := styles.NewStyle().
Width(minWidth).
Background(t.BackgroundPanel()).
Foreground(t.Text())
if textWidth < minWidth {
messageStyle = messageStyle.AlignHorizontal(lipgloss.Right)
}
content := messageStyle.Render(text)
if message.Role == client.Assistant {
content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
if message.Role == client.User {
text = strings.ReplaceAll(text, "<", "\\<")
text = strings.ReplaceAll(text, ">", "\\>")
}
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
@@ -416,7 +409,7 @@ func renderToolInvocation(
title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
// Add diagnostics at the bottom if they exist
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
@@ -760,7 +753,7 @@ func renderDiagnostics(metadata client.MessageMetadata_Tool_AdditionalProperties
continue
}
line := diag.Range.Start.Line + 1 // 1-based
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
}
@@ -78,3 +78,4 @@
"syntaxPunctuation": "darkFg"
}
}
@@ -110,3 +110,4 @@
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
}
}
@@ -225,4 +225,4 @@
"light": "#193549"
}
}
}
}
@@ -216,4 +216,4 @@
"light": "#282a36"
}
}
}
}
@@ -239,3 +239,4 @@
}
}
}
@@ -230,4 +230,4 @@
"light": "lightFg"
}
}
}
}
@@ -232,4 +232,4 @@
"light": "lightFg"
}
}
}
}
@@ -218,4 +218,4 @@
"light": "#272822"
}
}
}
}
@@ -243,3 +243,4 @@
}
}
}
@@ -219,4 +219,4 @@
"light": "#292d3e"
}
}
}
}
@@ -231,4 +231,4 @@
"light": "dawnSubtle"
}
}
}
}
@@ -220,4 +220,4 @@
"light": "base00"
}
}
}
}
@@ -223,4 +223,4 @@
"light": "#262335"
}
}
}
}
@@ -241,3 +241,4 @@
}
}
}
@@ -220,4 +220,4 @@
"light": "#3f3f3f"
}
}
}
}
+2 -93
View File
@@ -1088,17 +1088,13 @@
},
{
"$ref": "#/components/schemas/UnknownError"
},
{
"$ref": "#/components/schemas/MessageOutputLengthError"
}
],
"discriminator": {
"propertyName": "name",
"mapping": {
"ProviderAuthError": "#/components/schemas/ProviderAuthError",
"UnknownError": "#/components/schemas/UnknownError",
"MessageOutputLengthError": "#/components/schemas/MessageOutputLengthError"
"UnknownError": "#/components/schemas/UnknownError"
}
}
},
@@ -1276,22 +1272,6 @@
"data"
]
},
"MessageOutputLengthError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"const": "MessageOutputLengthError"
},
"data": {
"type": "object"
}
},
"required": [
"name",
"data"
]
},
"Event.message.part.updated": {
"type": "object",
"properties": {
@@ -1440,17 +1420,13 @@
},
{
"$ref": "#/components/schemas/UnknownError"
},
{
"$ref": "#/components/schemas/MessageOutputLengthError"
}
],
"discriminator": {
"propertyName": "name",
"mapping": {
"ProviderAuthError": "#/components/schemas/ProviderAuthError",
"UnknownError": "#/components/schemas/UnknownError",
"MessageOutputLengthError": "#/components/schemas/MessageOutputLengthError"
"UnknownError": "#/components/schemas/UnknownError"
}
}
}
@@ -1465,15 +1441,9 @@
"App.Info": {
"type": "object",
"properties": {
"project": {
"type": "string"
},
"user": {
"type": "string"
},
"hostname": {
"type": "string"
},
"git": {
"type": "boolean"
},
@@ -1514,9 +1484,7 @@
}
},
"required": [
"project",
"user",
"hostname",
"git",
"path",
"time"
@@ -1676,65 +1644,6 @@
}
},
"description": "MCP (Model Context Protocol) server configurations"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
]
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
]
}
}
}
}
}
}
},
"additionalProperties": false
+5 -85
View File
@@ -25,17 +25,15 @@ const (
// AppInfo defines model for App.Info.
type AppInfo struct {
Git bool `json:"git"`
Hostname string `json:"hostname"`
Path struct {
Git bool `json:"git"`
Path struct {
Config string `json:"config"`
Cwd string `json:"cwd"`
Data string `json:"data"`
Root string `json:"root"`
State string `json:"state"`
} `json:"path"`
Project string `json:"project"`
Time struct {
Time struct {
Initialized *float32 `json:"initialized,omitempty"`
} `json:"time"`
User string `json:"user"`
@@ -53,20 +51,8 @@ type ConfigInfo struct {
Autoupdate *bool `json:"autoupdate,omitempty"`
// DisabledProviders Disable providers that are loaded automatically
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Experimental *struct {
Hook *struct {
FileEdited *map[string][]struct {
Command []string `json:"command"`
Environment *map[string]string `json:"environment,omitempty"`
} `json:"file_edited,omitempty"`
SessionCompleted *[]struct {
Command []string `json:"command"`
Environment *map[string]string `json:"environment,omitempty"`
} `json:"session_completed,omitempty"`
} `json:"hook,omitempty"`
} `json:"experimental,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
// Mcp MCP (Model Context Protocol) server configurations
Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"`
@@ -448,12 +434,6 @@ type MessageToolInvocationToolResult struct {
ToolName string `json:"toolName"`
}
// MessageOutputLengthError defines model for MessageOutputLengthError.
type MessageOutputLengthError struct {
Data map[string]interface{} `json:"data"`
Name string `json:"name"`
}
// ModelInfo defines model for Model.Info.
type ModelInfo struct {
Attachment bool `json:"attachment"`
@@ -1130,34 +1110,6 @@ func (t *EventSessionError_Properties_Error) MergeUnknownError(v UnknownError) e
return err
}
// AsMessageOutputLengthError returns the union data inside the EventSessionError_Properties_Error as a MessageOutputLengthError
func (t EventSessionError_Properties_Error) AsMessageOutputLengthError() (MessageOutputLengthError, error) {
var body MessageOutputLengthError
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromMessageOutputLengthError overwrites any union data inside the EventSessionError_Properties_Error as the provided MessageOutputLengthError
func (t *EventSessionError_Properties_Error) FromMessageOutputLengthError(v MessageOutputLengthError) error {
v.Name = "MessageOutputLengthError"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeMessageOutputLengthError performs a merge with any union data inside the EventSessionError_Properties_Error, using the provided MessageOutputLengthError
func (t *EventSessionError_Properties_Error) MergeMessageOutputLengthError(v MessageOutputLengthError) error {
v.Name = "MessageOutputLengthError"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t EventSessionError_Properties_Error) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"name"`
@@ -1172,8 +1124,6 @@ func (t EventSessionError_Properties_Error) ValueByDiscriminator() (interface{},
return nil, err
}
switch discriminator {
case "MessageOutputLengthError":
return t.AsMessageOutputLengthError()
case "ProviderAuthError":
return t.AsProviderAuthError()
case "UnknownError":
@@ -1249,34 +1199,6 @@ func (t *MessageMetadata_Error) MergeUnknownError(v UnknownError) error {
return err
}
// AsMessageOutputLengthError returns the union data inside the MessageMetadata_Error as a MessageOutputLengthError
func (t MessageMetadata_Error) AsMessageOutputLengthError() (MessageOutputLengthError, error) {
var body MessageOutputLengthError
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromMessageOutputLengthError overwrites any union data inside the MessageMetadata_Error as the provided MessageOutputLengthError
func (t *MessageMetadata_Error) FromMessageOutputLengthError(v MessageOutputLengthError) error {
v.Name = "MessageOutputLengthError"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeMessageOutputLengthError performs a merge with any union data inside the MessageMetadata_Error, using the provided MessageOutputLengthError
func (t *MessageMetadata_Error) MergeMessageOutputLengthError(v MessageOutputLengthError) error {
v.Name = "MessageOutputLengthError"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t MessageMetadata_Error) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"name"`
@@ -1291,8 +1213,6 @@ func (t MessageMetadata_Error) ValueByDiscriminator() (interface{}, error) {
return nil, err
}
switch discriminator {
case "MessageOutputLengthError":
return t.AsMessageOutputLengthError()
case "ProviderAuthError":
return t.AsProviderAuthError()
case "UnknownError":
+3 -1
View File
@@ -32,7 +32,9 @@ export default defineConfig({
starlight({
title: "opencode",
expressiveCode: { themes: ["github-light", "github-dark"] },
social: [{ icon: "github", label: "GitHub", href: config.github }],
social: [
{ icon: "github", label: "GitHub", href: config.github },
],
head: [
{
tag: "link",
+20 -46
View File
@@ -19,10 +19,8 @@ const imageAttrs = {
const github = config.social.filter(s => s.icon === 'github')[0];
const command = "curl -fsSL"
const protocol = "https://"
const url = "opencode.ai/install"
const bash = "| bash"
const command = "npm i -g";
const pkg = "opencode-ai";
let darkImage: ImageMetadata | undefined;
let lightImage: ImageMetadata | undefined;
@@ -53,13 +51,11 @@ if (image) {
<section class="cta">
<div class="col1">
<a href="/docs">Docs</a>
<a href="/docs">View the docs</a>
</div>
<div class="col2">
<button class="command" data-command={`${command} ${protocol}${url} ${bash}`}>
<code>
<span>{command}&nbsp;</span><span class="protocol">{protocol}</span><span class="highlight">{url}</span>&nbsp;{bash}
</code>
<button class="command" data-command={`${command} ${pkg}`}>
<code>{command}&nbsp;<span class="highlight">{pkg}</span></code>
<span class="copy">
<CopyIcon />
<CheckIcon />
@@ -67,7 +63,7 @@ if (image) {
</button>
</div>
<div class="col3">
<a href={github.href}>GitHub</a>
<a href={github.href}>Star on GitHub</a>
</div>
</section>
@@ -137,43 +133,39 @@ section.top {
section.cta {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
justify-content: space-between;
border-top: 2px solid var(--sl-color-border);
@media (max-width: 50rem) {
@media (max-width: 40rem) {
flex-direction: column;
& > div.col1 { order: 1; }
& > div.col3 { order: 2; }
& > div.col2 { order: 3; }
}
& > div {
flex: 1;
line-height: 1.4;
padding: calc(var(--padding) / 2) 1rem;
padding: calc(var(--padding) / 2) 0.5rem;
@media (max-width: 40rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
}
a {
font-size: 1rem;
}
}
& > div.col1, & > div.col3 {
flex: 1 1 auto;
text-align: center;
text-transform: uppercase;
& > div.col2 {
@media (max-width: 50rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
flex: 0 0 auto;
}
}
& > div.col2 {
flex: 0 0 auto;
& > div:not(.col2) {
text-align: center;
text-transform: uppercase;
}
& > div + div {
border-left: 2px solid var(--sl-color-border);
@media (max-width: 50rem) {
@media (max-width: 40rem) {
border-left: none;
border-top: 2px solid var(--sl-color-border);
}
@@ -191,21 +183,6 @@ section.cta {
code {
color: var(--sl-color-text-secondary);
font-size: 1.125rem;
@media (max-width: 24rem) {
font-size: 0.875rem;
}
@media (max-width: 30rem) {
span.protocol {
display: none;
}
}
@media (max-width: 43rem) {
text-align: center;
span:first-child {
display: block;
}
}
}
code .highlight {
color: var(--sl-color-text);
@@ -215,9 +192,6 @@ section.cta {
.copy {
line-height: 1;
padding: 0;
@media (max-width: 43rem) {
display: none;
}
}
.copy svg {
width: 1rem;
+81 -10
View File
@@ -23,6 +23,7 @@ import {
} from "./icons/custom"
import {
IconFolder,
IconCpuChip,
IconHashtag,
IconSparkles,
IconGlobeAlt,
@@ -101,7 +102,10 @@ function stripWorkingDirectory(filePath: string, workingDir?: string) {
}
function getShikiLang(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() ?? ""
const ext = filename
.split('.')
.pop()
?.toLowerCase() ?? ''
// map.languages(ext) returns an array of matching Linguist language names (e.g. ['TypeScript'])
const langs = map.languages(ext)
@@ -109,10 +113,12 @@ function getShikiLang(filename: string) {
// Overrride any specific language mappings
const overrides: Record<string, string> = {
conf: "shellscript",
"conf": "shellscript"
}
return type ? (overrides[type] ?? type) : "plaintext"
return type
? overrides[type] ?? type
: 'plaintext'
}
function formatDuration(ms: number): string {
@@ -1002,6 +1008,13 @@ export default function Share(props: {
}
>
{(assistant) => {
const system = createMemo(() => {
const prompts = assistant().system || []
return prompts.filter(
(p: string) =>
!p.startsWith("You are Claude"),
)
})
return (
<div
id={anchor()}
@@ -1027,13 +1040,67 @@ export default function Share(props: {
<span data-part-model>
{assistant().modelID}
</span>
<Show when={system().length > 0}>
<div data-part-tool-result>
<ResultsButton
showCopy="Show system prompt"
hideCopy="Hide system prompt"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={system().join("\n\n").trim()}
/>
</Show>
</div>
</Show>
</div>
</div>
</div>
)
}}
</Match>
{/* System text */}
<Match
when={
msg.role === "system" &&
part.type === "text" &&
part
}
>
{(part) => (
<div
id={anchor()}
data-section="part"
data-part-type="system-text"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCpuChip width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>System</span>
</div>
<TextPart
data-size="sm"
text={part().text}
data-color="dimmed"
/>
</div>
</div>
</div>
)}
</Match>
{/* Grep tool */}
<Match
when={
@@ -1228,9 +1295,9 @@ export default function Share(props: {
const path = createMemo(() =>
toolData()?.args.path !== data().rootDir
? stripWorkingDirectory(
toolData()?.args.path,
data().rootDir,
)
toolData()?.args.path,
data().rootDir,
)
: toolData()?.args.path,
)
@@ -1591,7 +1658,8 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "todowrite" &&
part.toolInvocation.toolName ===
"todowrite" &&
part
}
>
@@ -1656,7 +1724,8 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "webfetch" &&
part.toolInvocation.toolName ===
"webfetch" &&
part
}
>
@@ -1830,7 +1899,9 @@ export default function Share(props: {
>
<IconSparkles width={18} height={18} />
</Match>
<Match when={msg.role === "system"}>
<IconCpuChip width={18} height={18} />
</Match>
<Match when={msg.role === "user"}>
<IconUserCircle width={18} height={18} />
</Match>
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/bash
if [ ! -d ".git" ]; then
exit 0