Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc7af31fe5 | ||
|
|
2f8d23ec66 | ||
|
|
77ae3fb9b9 | ||
|
|
4e7f6c47fd | ||
|
|
50469ed750 | ||
|
|
aaab785493 | ||
|
|
9751937894 | ||
|
|
0fc8dfc77e | ||
|
|
81b7df61ec | ||
|
|
8217b96d4a | ||
|
|
7dd0918d32 | ||
|
|
4b26b43855 | ||
|
|
9d7cfda9fe | ||
|
|
a3cf18c905 | ||
|
|
0b1a8ae699 | ||
|
|
eb70b1e5c8 | ||
|
|
00a3d818b6 | ||
|
|
2384c7e734 | ||
|
|
1bad3d9894 | ||
|
|
4f715e66dc | ||
|
|
ec001ca02f | ||
|
|
a2d3b9f0c8 | ||
|
|
9cfb6ff964 | ||
|
|
6ed661c140 | ||
|
|
9dc00edfc9 | ||
|
|
e063bf888e | ||
|
|
6f18475428 | ||
|
|
3664b09812 | ||
|
|
7050cc0ac3 | ||
|
|
4d3d63294d | ||
|
|
6bc61cbc2d | ||
|
|
01d351bebe | ||
|
|
dbba4a97aa | ||
|
|
0dc586faef | ||
|
|
f19c6b05f2 | ||
|
|
bc34f08333 | ||
|
|
b7ee16aabd | ||
|
|
ed1b0d97bf |
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: bun install
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.16
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.0.0",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
"@flystorage/file-storage": "1.1.0",
|
||||
|
||||
+1
-7
@@ -1,9 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"sentry": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.sentry.dev/sse"
|
||||
}
|
||||
}
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
if defined OPENCODE_BIN_PATH (
|
||||
set "resolved=%OPENCODE_BIN_PATH%"
|
||||
goto :execute
|
||||
)
|
||||
|
||||
rem Get the directory of this script
|
||||
set "script_dir=%~dp0"
|
||||
set "script_dir=%script_dir:~0,-1%"
|
||||
|
||||
rem Detect platform and architecture
|
||||
set "platform=win32"
|
||||
|
||||
rem Detect architecture
|
||||
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||
set "arch=x64"
|
||||
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set "arch=arm64"
|
||||
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
|
||||
set "arch=x86"
|
||||
) else (
|
||||
set "arch=x64"
|
||||
)
|
||||
|
||||
set "name=opencode-!platform!-!arch!"
|
||||
set "binary=opencode.exe"
|
||||
|
||||
rem Search for the binary starting from script location
|
||||
set "resolved="
|
||||
set "current_dir=%script_dir%"
|
||||
|
||||
:search_loop
|
||||
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
|
||||
if exist "%candidate%" (
|
||||
set "resolved=%candidate%"
|
||||
goto :execute
|
||||
)
|
||||
|
||||
rem Move up one directory
|
||||
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
|
||||
set "parent_dir=%parent_dir:~0,-1%"
|
||||
|
||||
rem Check if we've reached the root
|
||||
if "%current_dir%"=="%parent_dir%" goto :not_found
|
||||
set "current_dir=%parent_dir%"
|
||||
goto :search_loop
|
||||
|
||||
:not_found
|
||||
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
|
||||
exit /b 1
|
||||
|
||||
:execute
|
||||
rem Execute the binary with all arguments
|
||||
"%resolved%" %*
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"name": "opencode",
|
||||
"version": "0.0.5", "name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run ./src/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ const targets = [
|
||||
["linux", "x64"],
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
// ["windows", "x64"],
|
||||
["windows", "x64"],
|
||||
]
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace App {
|
||||
const data = path.join(
|
||||
Global.Path.data,
|
||||
"project",
|
||||
git ? git.split(path.sep).join("-") : "global",
|
||||
git ? directory(git) : "global",
|
||||
)
|
||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||
@@ -133,4 +133,13 @@ export namespace App {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function directory(input: string): string {
|
||||
return input
|
||||
.split(path.sep)
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
.replace(/[^A-Za-z0-9_]/g, "-")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import path from "path"
|
||||
|
||||
export const AuthCopilot = lazy(async () => {
|
||||
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
||||
const response = fetch(
|
||||
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
|
||||
)
|
||||
.then((x) => Bun.write(file, x))
|
||||
.catch(() => {})
|
||||
|
||||
if (!file.exists()) {
|
||||
const worked = await response
|
||||
if (!worked) return
|
||||
}
|
||||
const result = await import(file.name!).catch(() => {})
|
||||
if (!result) return
|
||||
return result.AuthCopilot
|
||||
})
|
||||
@@ -13,7 +13,7 @@ export namespace BunProc {
|
||||
) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
options,
|
||||
...options,
|
||||
})
|
||||
const result = Bun.spawn([which(), ...cmd], {
|
||||
...options,
|
||||
@@ -26,6 +26,15 @@ export namespace BunProc {
|
||||
},
|
||||
})
|
||||
const code = await result.exited
|
||||
// @ts-ignore
|
||||
const stdout = await result.stdout.text()
|
||||
// @ts-ignore
|
||||
const stderr = await result.stderr.text()
|
||||
log.info("done", {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
if (code !== 0) {
|
||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
||||
}
|
||||
@@ -53,7 +62,7 @@ export namespace BunProc {
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||
await BunProc.run(["install"], {
|
||||
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
new InstallFailedError(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthAnthropic } from "../../auth/anthropic"
|
||||
import { AuthGithubCopilot } from "../../auth/github-copilot"
|
||||
import { AuthCopilot } from "../../auth/copilot"
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
@@ -7,6 +7,9 @@ import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../../global"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
@@ -17,7 +20,7 @@ export const AuthCommand = cmd({
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
async handler() { },
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
@@ -26,16 +29,46 @@ export const AuthListCommand = cmd({
|
||||
describe: "list providers",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Credentials")
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir)
|
||||
? authPath.replace(homedir, "~")
|
||||
: authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await Auth.all().then((x) => Object.entries(x))
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`)
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
// Environment variables section
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -148,9 +181,10 @@ export const AuthLoginCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "github-copilot") {
|
||||
const copilot = await AuthCopilot()
|
||||
if (provider === "github-copilot" && copilot) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const deviceInfo = await AuthGithubCopilot.authorize()
|
||||
const deviceInfo = await copilot.authorize()
|
||||
|
||||
prompts.note(
|
||||
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
||||
@@ -163,13 +197,19 @@ export const AuthLoginCommand = cmd({
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, deviceInfo.interval * 1000),
|
||||
)
|
||||
const status = await AuthGithubCopilot.poll(deviceInfo.device)
|
||||
if (status === "pending") continue
|
||||
if (status === "complete") {
|
||||
const response = await copilot.poll(deviceInfo.device)
|
||||
if (response.status === "pending") continue
|
||||
if (response.status === "success") {
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: response.refresh,
|
||||
access: response.access,
|
||||
expires: response.expires,
|
||||
})
|
||||
spinner.stop("Login successful")
|
||||
break
|
||||
}
|
||||
if (status === "failed") {
|
||||
if (response.status === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { App } from "../../app/app"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ModelsCommand = cmd({
|
||||
command: "models",
|
||||
describe: "list all available models",
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { App } from "../../app/app"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Server } from "../../server/server"
|
||||
import { Share } from "../../share/share"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("port", {
|
||||
alias: ["p"],
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 4096,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
const cwd = process.cwd()
|
||||
await App.provide({ cwd }, async () => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
const hostname = args.hostname
|
||||
const port = args.port
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`opencode server listening on http://${server.hostname}:${server.port}`,
|
||||
)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
server.stop()
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export function FormatError(input: unknown) {
|
||||
if (MCP.Failed.isInstance(input))
|
||||
@@ -13,4 +14,6 @@ export function FormatError(input: unknown) {
|
||||
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||
) ?? []),
|
||||
].join("\n")
|
||||
|
||||
if (UI.CancelledError.isInstance(input)) return ""
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { EOL } from "os"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace UI {
|
||||
@@ -29,7 +30,7 @@ export namespace UI {
|
||||
|
||||
export function println(...message: string[]) {
|
||||
print(...message)
|
||||
Bun.stderr.write("\n")
|
||||
Bun.stderr.write(EOL)
|
||||
}
|
||||
|
||||
export function print(...message: string[]) {
|
||||
@@ -52,7 +53,7 @@ export namespace UI {
|
||||
result.push(row[0])
|
||||
result.push("\x1b[0m")
|
||||
result.push(row[1])
|
||||
result.push("\n")
|
||||
result.push(EOL)
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
+5
-2
@@ -116,14 +116,17 @@ export namespace Ripgrep {
|
||||
export async function files(input: {
|
||||
cwd: string
|
||||
query?: string
|
||||
glob?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
|
||||
const commands = [
|
||||
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
|
||||
]
|
||||
if (input.query)
|
||||
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
||||
const joined = commands.join(" | ")
|
||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
|
||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
|
||||
return result.split("\n").filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -13,6 +14,7 @@ import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
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"
|
||||
@@ -20,13 +22,25 @@ 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"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error("exception", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
.help("help", "show help")
|
||||
.alias("help", "h")
|
||||
.version("version", "show version number", Installation.VERSION)
|
||||
.alias("version", "v")
|
||||
.option("print-logs", {
|
||||
@@ -52,7 +66,12 @@ const cli = yargs(hideBin(process.argv))
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
process.chdir(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) {
|
||||
@@ -60,13 +79,22 @@ const cli = yargs(hideBin(process.argv))
|
||||
}
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
const server = Server.listen({
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
})
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
|
||||
let cwd = url.fileURLToPath(
|
||||
new URL("../../tui/cmd/opencode", import.meta.url),
|
||||
)
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
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 })
|
||||
@@ -92,21 +120,22 @@ const cli = yargs(hideBin(process.argv))
|
||||
},
|
||||
})
|
||||
|
||||
; (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()
|
||||
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(() => { })
|
||||
})()
|
||||
;(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()
|
||||
@@ -128,6 +157,8 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(ScrapCommand)
|
||||
.command(AuthCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(ServeCommand)
|
||||
.command(ModelsCommand)
|
||||
.fail((msg) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
@@ -158,7 +189,7 @@ try {
|
||||
Log.Default.error("fatal", data)
|
||||
const formatted = FormatError(e)
|
||||
if (formatted) UI.error(formatted)
|
||||
if (!formatted)
|
||||
if (formatted === undefined)
|
||||
UI.error(
|
||||
"Unexpected error, check log file at " + Log.file() + " for more details",
|
||||
)
|
||||
|
||||
@@ -115,7 +115,9 @@ export namespace Installation {
|
||||
case "bun":
|
||||
return $`bun install -g opencode-ai@${target}`
|
||||
case "brew":
|
||||
return $`brew install sst/tap/opencode`
|
||||
return $`brew install sst/tap/opencode`.env({
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
})
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export async function data() {
|
||||
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
|
||||
return json
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { data } from "./models-macro" with { type: "macro" }
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
@@ -54,16 +55,15 @@ export namespace ModelsDev {
|
||||
refresh()
|
||||
return result as Record<string, Provider>
|
||||
}
|
||||
await refresh()
|
||||
return get()
|
||||
refresh()
|
||||
const json = await data()
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const file = Bun.file(filepath)
|
||||
log.info("refreshing")
|
||||
const result = await fetch("https://models.dev/api.json")
|
||||
if (!result.ok)
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(file, result)
|
||||
const result = await fetch("https://models.dev/api.json").catch(() => {})
|
||||
if (result && result.ok) await Bun.write(file, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Tool } from "../tool/tool"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { AuthGithubCopilot } from "../auth/github-copilot"
|
||||
import { AuthCopilot } from "../auth/copilot"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
@@ -28,20 +28,21 @@ import { TaskTool } from "../tool/task"
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
|
||||
| {
|
||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||
options: Record<string, any>
|
||||
}
|
||||
| false
|
||||
>
|
||||
type CustomLoader = (
|
||||
provider: ModelsDev.Provider,
|
||||
api?: string,
|
||||
) => Promise<{
|
||||
autoload: boolean
|
||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||
options?: Record<string, any>
|
||||
}>
|
||||
|
||||
type Source = "env" | "config" | "custom" | "api"
|
||||
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (!access) return false
|
||||
if (!access) return { autoload: false }
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
@@ -49,6 +50,7 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
@@ -68,8 +70,10 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"github-copilot": async (provider) => {
|
||||
const info = await AuthGithubCopilot.access()
|
||||
if (!info) return false
|
||||
const copilot = await AuthCopilot()
|
||||
if (!copilot) return { autoload: false }
|
||||
let info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return { autoload: false }
|
||||
|
||||
if (provider && provider.models) {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
@@ -81,18 +85,26 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
const token = await AuthGithubCopilot.access()
|
||||
if (!token) throw new Error("GitHub Copilot authentication expired")
|
||||
const info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (!info.access || info.expires < Date.now()) {
|
||||
const tokens = await copilot.access(info.refresh)
|
||||
if (!tokens)
|
||||
throw new Error("GitHub Copilot authentication expired")
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
...tokens,
|
||||
})
|
||||
info.access = tokens.access
|
||||
}
|
||||
const headers = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"Editor-Version": "vscode/1.99.3",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
...copilot.HEADERS,
|
||||
Authorization: `Bearer ${info.access}`,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
@@ -106,6 +118,7 @@ export namespace Provider {
|
||||
},
|
||||
openai: async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
@@ -114,7 +127,7 @@ export namespace Provider {
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
|
||||
return false
|
||||
return { autoload: false }
|
||||
|
||||
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||
|
||||
@@ -122,6 +135,7 @@ export namespace Provider {
|
||||
await BunProc.install("@aws-sdk/credential-providers")
|
||||
)
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
region,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
@@ -247,8 +261,13 @@ export namespace Provider {
|
||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
const result = await fn(database[providerID])
|
||||
if (result) {
|
||||
mergeProvider(providerID, result.options, "custom", result.getModel)
|
||||
if (result && (result.autoload || providers[providerID])) {
|
||||
mergeProvider(
|
||||
providerID,
|
||||
result.options ?? {},
|
||||
"custom",
|
||||
result.getModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +390,33 @@ export namespace Server {
|
||||
return c.json(Session.abort(body.sessionID))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_delete",
|
||||
describeRoute({
|
||||
description: "Delete a session and all its data",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully deleted session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Session.remove(body.sessionID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_summarize",
|
||||
describeRoute({
|
||||
@@ -552,10 +579,10 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
export function listen() {
|
||||
export function listen(opts: { port: number; hostname: string }) {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: "0.0.0.0",
|
||||
port: opts.port,
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app().fetch,
|
||||
})
|
||||
|
||||
@@ -72,6 +72,12 @@ export namespace Session {
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Deleted: Bus.event(
|
||||
"session.deleted",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Error: Bus.event(
|
||||
"session.error",
|
||||
z.object({
|
||||
@@ -206,6 +212,17 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
export async function children(parentID: string) {
|
||||
const result = [] as Session.Info[]
|
||||
for await (const item of Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
const session = await get(sessionID)
|
||||
if (session.parentID !== parentID) continue
|
||||
result.push(session)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function abort(sessionID: string) {
|
||||
const controller = state().pending.get(sessionID)
|
||||
if (!controller) return false
|
||||
@@ -214,6 +231,28 @@ export namespace Session {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function remove(sessionID: string, emitEvent = true) {
|
||||
try {
|
||||
abort(sessionID)
|
||||
const session = await get(sessionID)
|
||||
for (const child of await children(sessionID)) {
|
||||
await remove(child.id, false)
|
||||
}
|
||||
await unshare(sessionID).catch(() => {})
|
||||
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
|
||||
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
|
||||
state().sessions.delete(sessionID)
|
||||
state().messages.delete(sessionID)
|
||||
if (emitEvent) {
|
||||
Bus.publish(Event.Deleted, {
|
||||
info: session,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMessage(msg: Message.Info) {
|
||||
await Storage.writeJSON(
|
||||
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
|
||||
@@ -248,7 +287,7 @@ export namespace Session {
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens >
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
@@ -295,7 +334,7 @@ export namespace Session {
|
||||
draft.title = result.text
|
||||
})
|
||||
})
|
||||
.catch(() => { })
|
||||
.catch(() => {})
|
||||
}
|
||||
const msg: Message.Info = {
|
||||
role: "user",
|
||||
@@ -433,24 +472,6 @@ export namespace Session {
|
||||
}
|
||||
|
||||
let text: Message.TextPart | undefined
|
||||
await Bun.write(
|
||||
"/tmp/message.json",
|
||||
JSON.stringify(
|
||||
[
|
||||
...system.map(
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
const result = streamText({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", { finishReason: step.finishReason })
|
||||
@@ -572,7 +593,7 @@ export namespace Session {
|
||||
case "tool-call": {
|
||||
const [match] = next.parts.flatMap((p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
? [p]
|
||||
: [],
|
||||
)
|
||||
@@ -636,6 +657,21 @@ export namespace Session {
|
||||
}
|
||||
break
|
||||
|
||||
case "finish":
|
||||
log.info("message finish", {
|
||||
reason: value.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(
|
||||
model.info,
|
||||
value.usage,
|
||||
value.providerMetadata,
|
||||
)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
if (value.finishReason === "length")
|
||||
throw new Message.OutputLengthError({})
|
||||
break
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
@@ -649,6 +685,9 @@ export namespace Session {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case Message.OutputLengthError.isInstance(e):
|
||||
next.metadata.error = e
|
||||
break
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
export const OutputLengthError = NamedError.create(
|
||||
"MessageOutputLengthError",
|
||||
z.object({}),
|
||||
)
|
||||
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
state: z.literal("call"),
|
||||
@@ -145,6 +150,7 @@ export namespace Message {
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
])
|
||||
.optional(),
|
||||
sessionID: z.string(),
|
||||
|
||||
@@ -29,6 +29,11 @@ export namespace Storage {
|
||||
await fs.unlink(target).catch(() => {})
|
||||
}
|
||||
|
||||
export async function removeDir(key: string) {
|
||||
const target = path.join(state().dir, key)
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
export async function readJSON<T>(key: string) {
|
||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// the approaches in this edit tool are sourced from
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
||||
|
||||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
@@ -55,35 +58,19 @@ export const EditTool = Tool.define({
|
||||
}
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
|
||||
const stats = await file.stat()
|
||||
const stats = await file.stat().catch(() => {})
|
||||
if (!stats) throw new Error(`File ${filepath} not found`)
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
await FileTimes.assert(ctx.sessionID, filepath)
|
||||
contentOld = await file.text()
|
||||
const index = contentOld.indexOf(params.oldString)
|
||||
if (index === -1)
|
||||
throw new Error(
|
||||
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
||||
)
|
||||
|
||||
if (params.replaceAll) {
|
||||
contentNew = contentOld.replaceAll(params.oldString, params.newString)
|
||||
}
|
||||
|
||||
if (!params.replaceAll) {
|
||||
const lastIndex = contentOld.lastIndexOf(params.oldString)
|
||||
if (index !== lastIndex)
|
||||
throw new Error(
|
||||
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
|
||||
)
|
||||
|
||||
contentNew =
|
||||
contentOld.substring(0, index) +
|
||||
params.newString +
|
||||
contentOld.substring(index + params.oldString.length)
|
||||
}
|
||||
|
||||
contentNew = replace(
|
||||
contentOld,
|
||||
params.oldString,
|
||||
params.newString,
|
||||
params.replaceAll,
|
||||
)
|
||||
await file.write(contentNew)
|
||||
})()
|
||||
|
||||
@@ -116,6 +103,169 @@ export const EditTool = Tool.define({
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (
|
||||
content: string,
|
||||
find: string,
|
||||
) => Generator<string, void, unknown>
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||
yield find
|
||||
}
|
||||
|
||||
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
|
||||
// Find blocks where first line matches the search first line
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
// Found a potential block from i to j
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k <= j - i; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (
|
||||
content,
|
||||
find,
|
||||
) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
|
||||
// Handle single line matches
|
||||
const lines = content.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (normalizeWhitespace(line) === normalizedFind) {
|
||||
yield line
|
||||
}
|
||||
|
||||
// Also check for substring matches within lines
|
||||
const normalizedLine = normalizeWhitespace(line)
|
||||
if (normalizedLine.includes(normalizedFind)) {
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words
|
||||
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+")
|
||||
const regex = new RegExp(pattern)
|
||||
const match = line.match(regex)
|
||||
if (match) {
|
||||
yield match[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multi-line matches
|
||||
const findLines = find.split("\n")
|
||||
if (findLines.length > 1) {
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length)
|
||||
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
||||
yield block.join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
const removeIndentation = (text: string) => {
|
||||
const lines = text.split("\n")
|
||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||
if (nonEmptyLines.length === 0) return text
|
||||
|
||||
const minIndent = Math.min(
|
||||
...nonEmptyLines.map((line) => {
|
||||
const match = line.match(/^(\s*)/)
|
||||
return match ? match[1].length : 0
|
||||
}),
|
||||
)
|
||||
|
||||
return lines
|
||||
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
const contentLines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
|
||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||
if (removeIndentation(block) === normalizedFind) {
|
||||
yield block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const contentLines = lines.filter(
|
||||
@@ -151,3 +301,34 @@ function trimDiff(diff: string): string {
|
||||
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
export function replace(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll = false,
|
||||
): string {
|
||||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
BlockAnchorReplacer,
|
||||
WhitespaceNormalizedReplacer,
|
||||
IndentationFlexibleReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
if (replaceAll) {
|
||||
return content.replaceAll(search, newString)
|
||||
}
|
||||
const lastIndex = content.lastIndexOf(search)
|
||||
if (index !== lastIndex) continue
|
||||
return (
|
||||
content.substring(0, index) +
|
||||
newString +
|
||||
content.substring(index + search.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error("oldString not found in content or was found multiple times")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
|
||||
export const GlobTool = Tool.define({
|
||||
id: "glob",
|
||||
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
|
||||
: path.resolve(app.path.cwd, search)
|
||||
|
||||
const limit = 100
|
||||
const glob = new Bun.Glob(params.pattern)
|
||||
const files = []
|
||||
let truncated = false
|
||||
for await (const file of glob.scan({ cwd: search, dot: true })) {
|
||||
for (const file of await Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: params.pattern,
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
|
||||
@@ -19,7 +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] + ".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()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { replace } from "../../src/tool/edit"
|
||||
|
||||
interface TestCase {
|
||||
content: string
|
||||
find: string
|
||||
replace: string
|
||||
all?: boolean
|
||||
fail?: boolean
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// SimpleReplacer cases
|
||||
{
|
||||
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
|
||||
find: 'console.log("world");',
|
||||
replace: 'console.log("universe");',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"if (condition) {",
|
||||
" doSomething();",
|
||||
" doSomethingElse();",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [" doSomething();", " doSomethingElse();"].join("\n"),
|
||||
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
|
||||
},
|
||||
|
||||
// LineTrimmedReplacer cases
|
||||
{
|
||||
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
|
||||
find: 'console.log("hello");',
|
||||
replace: 'console.log("goodbye");',
|
||||
},
|
||||
{
|
||||
content: ["const x = 5; ", "const y = 10;"].join("\n"),
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 15;",
|
||||
},
|
||||
{
|
||||
content: [" if (true) {", " return false;", " }"].join("\n"),
|
||||
find: ["if (true) {", "return false;", "}"].join("\n"),
|
||||
replace: ["if (false) {", "return true;", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// BlockAnchorReplacer cases
|
||||
{
|
||||
content: [
|
||||
"function calculate(a, b) {",
|
||||
" const temp = a + b;",
|
||||
" const result = temp * 2;",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function calculate(a, b) {",
|
||||
" // different middle content",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 0;",
|
||||
" }",
|
||||
" ",
|
||||
" getValue() {",
|
||||
" return this.value;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
|
||||
replace: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 42;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
|
||||
// WhitespaceNormalizedReplacer cases
|
||||
{
|
||||
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
|
||||
find: ' console.log("hello");',
|
||||
replace: ' console.log("world");',
|
||||
},
|
||||
{
|
||||
content: "const x = 5;",
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 10;",
|
||||
},
|
||||
{
|
||||
content: "if\t( condition\t) {",
|
||||
find: "if ( condition ) {",
|
||||
replace: "if (newCondition) {",
|
||||
},
|
||||
|
||||
// IndentationFlexibleReplacer cases
|
||||
{
|
||||
content: [
|
||||
" function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: [
|
||||
"function nested() {",
|
||||
' console.log("updated");',
|
||||
" return false;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
" if (true) {",
|
||||
' console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"if (true) {",
|
||||
'console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
|
||||
},
|
||||
|
||||
// replaceAll option cases
|
||||
{
|
||||
content: [
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: true,
|
||||
},
|
||||
{
|
||||
content: ['console.log("test");', 'console.log("test");'].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
content: 'console.log("hello");',
|
||||
find: "nonexistent string",
|
||||
replace: "updated",
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
content: ["test", "test", "different content", "test"].join("\n"),
|
||||
find: "test",
|
||||
replace: "updated",
|
||||
all: false,
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
content: "",
|
||||
find: "",
|
||||
replace: "new content",
|
||||
},
|
||||
{
|
||||
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
|
||||
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
|
||||
replace: "/\\\\w+/g",
|
||||
},
|
||||
{
|
||||
content: 'const message = "Hello 世界! 🌍";',
|
||||
find: "Hello 世界! 🌍",
|
||||
replace: "Hello World! 🌎",
|
||||
},
|
||||
]
|
||||
|
||||
describe("EditTool Replacers", () => {
|
||||
test.each(testCases)("case %#", (testCase) => {
|
||||
if (testCase.fail) {
|
||||
expect(() => {
|
||||
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
}).toThrow()
|
||||
} else {
|
||||
const result = replace(
|
||||
testCase.content,
|
||||
testCase.find,
|
||||
testCase.replace,
|
||||
testCase.all,
|
||||
)
|
||||
expect(result).toContain(testCase.replace)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
opencode-test
|
||||
@@ -396,6 +396,19 @@ func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
|
||||
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
|
||||
if err != nil {
|
||||
|
||||
@@ -32,17 +32,19 @@ type EditorComponent interface {
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
Previous() (tea.Model, tea.Cmd)
|
||||
Next() (tea.Model, tea.Cmd)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
@@ -78,8 +80,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
} else {
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue + " ")
|
||||
|
||||
// Replace the current token (after last space)
|
||||
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||
if lastSpaceIndex == -1 {
|
||||
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||
} else {
|
||||
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
|
||||
m.textarea.SetValue(modifiedValue + " ")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -115,9 +124,14 @@ func (m *editorComponent) Content() string {
|
||||
Background(t.BackgroundElement()).
|
||||
Render(textarea)
|
||||
|
||||
hint := base("enter") + muted(" send ")
|
||||
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||
if m.app.IsBusy() {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
|
||||
keyText := m.getInterruptKeyText()
|
||||
if m.interruptKeyInDebounce {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
|
||||
} else {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
model := ""
|
||||
@@ -263,6 +277,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||
m.interruptKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) getInterruptKeyText() string {
|
||||
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) getSubmitKeyText() string {
|
||||
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
@@ -311,11 +337,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
app: app,
|
||||
textarea: ta,
|
||||
history: []string{},
|
||||
historyIndex: 0,
|
||||
currentMessage: "",
|
||||
spinner: s,
|
||||
app: app,
|
||||
textarea: ta,
|
||||
history: []string{},
|
||||
historyIndex: 0,
|
||||
currentMessage: "",
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,20 @@ package dialog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"slices"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
@@ -17,12 +25,65 @@ type SessionDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
// sessionItem is a custom list item for sessions that can show delete confirmation
|
||||
type sessionItem struct {
|
||||
title string
|
||||
isDeleteConfirming bool
|
||||
}
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
var text string
|
||||
if s.isDeleteConfirming {
|
||||
text = "Press again to confirm delete"
|
||||
} else {
|
||||
text = s.title
|
||||
}
|
||||
|
||||
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
|
||||
|
||||
var itemStyle lipgloss.Style
|
||||
if selected {
|
||||
if s.isDeleteConfirming {
|
||||
// Red background for delete confirmation
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Error()).
|
||||
Foreground(t.Background()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
// Normal selection
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
}
|
||||
} else {
|
||||
if s.isDeleteConfirming {
|
||||
// Red text for delete confirmation when not selected
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.Error()).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
itemStyle = baseStyle.
|
||||
PaddingLeft(1)
|
||||
}
|
||||
}
|
||||
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
sessions []client.SessionInfo
|
||||
list list.List[list.StringItem]
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
sessions []client.SessionInfo
|
||||
list list.List[sessionItem]
|
||||
app *app.App
|
||||
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
@@ -38,6 +99,11 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
selectedSession := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
@@ -45,17 +111,79 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
// Second press - actually delete the session
|
||||
sessionToDelete := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
func() tea.Msg {
|
||||
s.sessions = slices.Delete(s.sessions, idx, idx+1)
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return nil
|
||||
},
|
||||
s.deleteSession(sessionToDelete.Id),
|
||||
)
|
||||
} else {
|
||||
// First press - enter delete confirmation mode
|
||||
s.deleteConfirmation = idx
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
case "esc":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.StringItem])
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Render(background string) string {
|
||||
return s.modal.Render(s.list.View(), background)
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1)
|
||||
helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del")
|
||||
helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
|
||||
helpText = helpStyle.Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpText}, "\n")
|
||||
|
||||
return s.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (s *sessionDialog) updateListItems() {
|
||||
_, currentIdx := s.list.GetSelectedItem()
|
||||
|
||||
var items []sessionItem
|
||||
for i, sess := range s.sessions {
|
||||
item := sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: s.deleteConfirmation == i,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
s.list.SetItems(items)
|
||||
s.list.SetSelectedIndex(currentIdx)
|
||||
}
|
||||
|
||||
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
|
||||
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Close() tea.Cmd {
|
||||
@@ -67,26 +195,32 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
sessions, _ := app.ListSessions(context.Background())
|
||||
|
||||
var filteredSessions []client.SessionInfo
|
||||
var sessionTitles []string
|
||||
var items []sessionItem
|
||||
for _, sess := range sessions {
|
||||
if sess.ParentID != nil {
|
||||
continue
|
||||
}
|
||||
filteredSessions = append(filteredSessions, sess)
|
||||
sessionTitles = append(sessionTitles, sess.Title)
|
||||
items = append(items, sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: false,
|
||||
})
|
||||
}
|
||||
|
||||
list := list.NewStringList(
|
||||
sessionTitles,
|
||||
// Create a generic list component
|
||||
listComponent := list.NewListComponent(
|
||||
items,
|
||||
10, // maxVisibleSessions
|
||||
"No sessions available",
|
||||
true, // useAlphaNumericKeys
|
||||
)
|
||||
list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
|
||||
return &sessionDialog{
|
||||
sessions: filteredSessions,
|
||||
list: list,
|
||||
sessions: filteredSessions,
|
||||
list: listComponent,
|
||||
app: app,
|
||||
deleteConfirmation: -1,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Switch Session"),
|
||||
modal.WithMaxWidth(layout.Current.Container.Width-8),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -42,7 +43,7 @@ func LoadThemesFromJSON() error {
|
||||
continue
|
||||
}
|
||||
themeName := strings.TrimSuffix(entry.Name(), ".json")
|
||||
data, err := themesFS.ReadFile(filepath.Join("themes", entry.Name()))
|
||||
data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
@@ -25,6 +26,19 @@ import (
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
|
||||
type InterruptDebounceTimeoutMsg struct{}
|
||||
|
||||
// InterruptKeyState tracks the state of interrupt key presses for debouncing
|
||||
type InterruptKeyState int
|
||||
|
||||
const (
|
||||
InterruptKeyIdle InterruptKeyState = iota
|
||||
InterruptKeyFirstPress
|
||||
)
|
||||
|
||||
const interruptDebounceTimeout = 1 * time.Second
|
||||
|
||||
type appModel struct {
|
||||
width, height int
|
||||
app *app.App
|
||||
@@ -40,6 +54,7 @@ type appModel struct {
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
interruptKeyState InterruptKeyState
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -171,9 +186,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// 6. Check again for commands that don't require leader
|
||||
// 6. Handle interrupt key debounce for session interrupt
|
||||
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
|
||||
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
|
||||
switch a.interruptKeyState {
|
||||
case InterruptKeyIdle:
|
||||
// First interrupt key press - start debounce timer
|
||||
a.interruptKeyState = InterruptKeyFirstPress
|
||||
a.editor.SetInterruptKeyInDebounce(true)
|
||||
return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
|
||||
return InterruptDebounceTimeoutMsg{}
|
||||
})
|
||||
case InterruptKeyFirstPress:
|
||||
// Second interrupt key press within timeout - actually interrupt
|
||||
a.interruptKeyState = InterruptKeyIdle
|
||||
a.editor.SetInterruptKeyInDebounce(false)
|
||||
return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Check again for commands that don't require leader (excluding interrupt when busy)
|
||||
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
||||
if len(matches) > 0 {
|
||||
// Skip interrupt key if we're in debounce mode and app is busy
|
||||
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
|
||||
return a, nil
|
||||
}
|
||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||
}
|
||||
|
||||
@@ -223,6 +261,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
||||
toast.WithTitle("New version installed"),
|
||||
)
|
||||
case client.EventSessionDeleted:
|
||||
if a.app.Session != nil && msg.Properties.Info.Id == a.app.Session.Id {
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
}
|
||||
return a, toast.NewSuccessToast("Session deleted successfully")
|
||||
case client.EventSessionUpdated:
|
||||
if msg.Properties.Info.Id == a.app.Session.Id {
|
||||
a.app.Session = &msg.Properties.Info
|
||||
@@ -231,7 +275,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
|
||||
exists := false
|
||||
optimisticReplaced := false
|
||||
|
||||
|
||||
// First check if this is replacing an optimistic message
|
||||
if msg.Properties.Info.Role == client.User {
|
||||
// Look for optimistic messages to replace
|
||||
@@ -245,7 +289,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If not replacing optimistic, check for existing message with same ID
|
||||
if !optimisticReplaced {
|
||||
for i, m := range a.app.Messages {
|
||||
@@ -256,7 +300,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !exists {
|
||||
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
|
||||
}
|
||||
@@ -305,6 +349,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
tm, cmd := a.toastManager.Update(msg)
|
||||
a.toastManager = tm
|
||||
cmds = append(cmds, cmd)
|
||||
case InterruptDebounceTimeoutMsg:
|
||||
// Reset interrupt key state after timeout
|
||||
a.interruptKeyState = InterruptKeyIdle
|
||||
a.editor.SetInterruptKeyInDebounce(false)
|
||||
}
|
||||
|
||||
// update status bar
|
||||
@@ -597,6 +645,7 @@ func NewModel(app *app.App) tea.Model {
|
||||
showCompletionDialog: false,
|
||||
editorContainer: editorContainer,
|
||||
toastManager: toast.NewToastManager(),
|
||||
interruptKeyState: InterruptKeyIdle,
|
||||
layout: layout.NewFlexLayout(
|
||||
[]tea.ViewModel{messagesContainer, editorContainer},
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
|
||||
@@ -363,6 +363,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/session_delete": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully deleted session",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "postSession_delete",
|
||||
"parameters": [],
|
||||
"description": "Delete a session and all its data",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionID"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/session_summarize": {
|
||||
"post": {
|
||||
"responses": {
|
||||
@@ -579,6 +615,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.deleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.error"
|
||||
}
|
||||
@@ -593,6 +632,7 @@
|
||||
"message.updated": "#/components/schemas/Event.message.updated",
|
||||
"message.part.updated": "#/components/schemas/Event.message.part.updated",
|
||||
"session.updated": "#/components/schemas/Event.session.updated",
|
||||
"session.deleted": "#/components/schemas/Event.session.deleted",
|
||||
"session.error": "#/components/schemas/Event.session.error"
|
||||
}
|
||||
}
|
||||
@@ -1339,6 +1379,30 @@
|
||||
"time"
|
||||
]
|
||||
},
|
||||
"Event.session.deleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "session.deleted"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"info": {
|
||||
"$ref": "#/components/schemas/session.info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"properties"
|
||||
]
|
||||
},
|
||||
"Event.session.error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -254,6 +254,14 @@ type EventPermissionUpdated struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// EventSessionDeleted defines model for Event.session.deleted.
|
||||
type EventSessionDeleted struct {
|
||||
Properties struct {
|
||||
Info SessionInfo `json:"info"`
|
||||
} `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// EventSessionError defines model for Event.session.error.
|
||||
type EventSessionError struct {
|
||||
Properties struct {
|
||||
@@ -518,6 +526,11 @@ type PostSessionChatJSONBody struct {
|
||||
SessionID string `json:"sessionID"`
|
||||
}
|
||||
|
||||
// PostSessionDeleteJSONBody defines parameters for PostSessionDelete.
|
||||
type PostSessionDeleteJSONBody struct {
|
||||
SessionID string `json:"sessionID"`
|
||||
}
|
||||
|
||||
// PostSessionInitializeJSONBody defines parameters for PostSessionInitialize.
|
||||
type PostSessionInitializeJSONBody struct {
|
||||
ModelID string `json:"modelID"`
|
||||
@@ -556,6 +569,9 @@ type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody
|
||||
// PostSessionChatJSONRequestBody defines body for PostSessionChat for application/json ContentType.
|
||||
type PostSessionChatJSONRequestBody PostSessionChatJSONBody
|
||||
|
||||
// PostSessionDeleteJSONRequestBody defines body for PostSessionDelete for application/json ContentType.
|
||||
type PostSessionDeleteJSONRequestBody PostSessionDeleteJSONBody
|
||||
|
||||
// PostSessionInitializeJSONRequestBody defines body for PostSessionInitialize for application/json ContentType.
|
||||
type PostSessionInitializeJSONRequestBody PostSessionInitializeJSONBody
|
||||
|
||||
@@ -935,6 +951,34 @@ func (t *Event) MergeEventSessionUpdated(v EventSessionUpdated) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AsEventSessionDeleted returns the union data inside the Event as a EventSessionDeleted
|
||||
func (t Event) AsEventSessionDeleted() (EventSessionDeleted, error) {
|
||||
var body EventSessionDeleted
|
||||
err := json.Unmarshal(t.union, &body)
|
||||
return body, err
|
||||
}
|
||||
|
||||
// FromEventSessionDeleted overwrites any union data inside the Event as the provided EventSessionDeleted
|
||||
func (t *Event) FromEventSessionDeleted(v EventSessionDeleted) error {
|
||||
v.Type = "session.deleted"
|
||||
b, err := json.Marshal(v)
|
||||
t.union = b
|
||||
return err
|
||||
}
|
||||
|
||||
// MergeEventSessionDeleted performs a merge with any union data inside the Event, using the provided EventSessionDeleted
|
||||
func (t *Event) MergeEventSessionDeleted(v EventSessionDeleted) error {
|
||||
v.Type = "session.deleted"
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged, err := runtime.JSONMerge(t.union, b)
|
||||
t.union = merged
|
||||
return err
|
||||
}
|
||||
|
||||
// AsEventSessionError returns the union data inside the Event as a EventSessionError
|
||||
func (t Event) AsEventSessionError() (EventSessionError, error) {
|
||||
var body EventSessionError
|
||||
@@ -987,6 +1031,8 @@ func (t Event) ValueByDiscriminator() (interface{}, error) {
|
||||
return t.AsEventMessageUpdated()
|
||||
case "permission.updated":
|
||||
return t.AsEventPermissionUpdated()
|
||||
case "session.deleted":
|
||||
return t.AsEventSessionDeleted()
|
||||
case "session.error":
|
||||
return t.AsEventSessionError()
|
||||
case "session.updated":
|
||||
@@ -1626,6 +1672,11 @@ type ClientInterface interface {
|
||||
// PostSessionCreate request
|
||||
PostSessionCreate(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// PostSessionDeleteWithBody request with any body
|
||||
PostSessionDeleteWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
PostSessionDelete(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// PostSessionInitializeWithBody request with any body
|
||||
PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -1823,6 +1874,30 @@ func (c *Client) PostSessionCreate(ctx context.Context, reqEditors ...RequestEdi
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostSessionDeleteWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostSessionDeleteRequestWithBody(c.Server, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostSessionDelete(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostSessionDeleteRequest(c.Server, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostSessionInitializeRequestWithBody(c.Server, contentType, body)
|
||||
if err != nil {
|
||||
@@ -2291,6 +2366,46 @@ func NewPostSessionCreateRequest(server string) (*http.Request, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewPostSessionDeleteRequest calls the generic PostSessionDelete builder with application/json body
|
||||
func NewPostSessionDeleteRequest(server string, body PostSessionDeleteJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewPostSessionDeleteRequestWithBody(server, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewPostSessionDeleteRequestWithBody generates requests for PostSessionDelete with any type of body
|
||||
func NewPostSessionDeleteRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/session_delete")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewPostSessionInitializeRequest calls the generic PostSessionInitialize builder with application/json body
|
||||
func NewPostSessionInitializeRequest(server string, body PostSessionInitializeJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
@@ -2600,6 +2715,11 @@ type ClientWithResponsesInterface interface {
|
||||
// PostSessionCreateWithResponse request
|
||||
PostSessionCreateWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostSessionCreateResponse, error)
|
||||
|
||||
// PostSessionDeleteWithBodyWithResponse request with any body
|
||||
PostSessionDeleteWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error)
|
||||
|
||||
PostSessionDeleteWithResponse(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error)
|
||||
|
||||
// PostSessionInitializeWithBodyWithResponse request with any body
|
||||
PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error)
|
||||
|
||||
@@ -2880,6 +3000,28 @@ func (r PostSessionCreateResponse) StatusCode() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type PostSessionDeleteResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *bool
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r PostSessionDeleteResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r PostSessionDeleteResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type PostSessionInitializeResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -3135,6 +3277,23 @@ func (c *ClientWithResponses) PostSessionCreateWithResponse(ctx context.Context,
|
||||
return ParsePostSessionCreateResponse(rsp)
|
||||
}
|
||||
|
||||
// PostSessionDeleteWithBodyWithResponse request with arbitrary body returning *PostSessionDeleteResponse
|
||||
func (c *ClientWithResponses) PostSessionDeleteWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error) {
|
||||
rsp, err := c.PostSessionDeleteWithBody(ctx, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePostSessionDeleteResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) PostSessionDeleteWithResponse(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error) {
|
||||
rsp, err := c.PostSessionDelete(ctx, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePostSessionDeleteResponse(rsp)
|
||||
}
|
||||
|
||||
// PostSessionInitializeWithBodyWithResponse request with arbitrary body returning *PostSessionInitializeResponse
|
||||
func (c *ClientWithResponses) PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error) {
|
||||
rsp, err := c.PostSessionInitializeWithBody(ctx, contentType, body, reqEditors...)
|
||||
@@ -3530,6 +3689,32 @@ func ParsePostSessionCreateResponse(rsp *http.Response) (*PostSessionCreateRespo
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParsePostSessionDeleteResponse parses an HTTP response from a PostSessionDeleteWithResponse call
|
||||
func ParsePostSessionDeleteResponse(rsp *http.Response) (*PostSessionDeleteResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &PostSessionDeleteResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest bool
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParsePostSessionInitializeResponse parses an HTTP response from a PostSessionInitializeWithResponse call
|
||||
func ParsePostSessionInitializeResponse(rsp *http.Response) (*PostSessionInitializeResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
|
||||
@@ -113,29 +113,83 @@ const DiffView: Component<DiffViewProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class={`${styles.diff} ${props.class ?? ""}`}>
|
||||
<div class={styles.column}>
|
||||
{rows().map((r) => (
|
||||
<CodeBlock
|
||||
code={r.left}
|
||||
lang={props.lang}
|
||||
data-section="cell"
|
||||
data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{rows().map((r) => (
|
||||
<div class={styles.row}>
|
||||
<div class={styles.beforeColumn}>
|
||||
<CodeBlock
|
||||
code={r.left}
|
||||
lang={props.lang}
|
||||
data-section="cell"
|
||||
data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}
|
||||
data-display-mobile={r.type === "added" && !r.left ? "false" : undefined}
|
||||
/>
|
||||
{(r.type === "added" || r.type === "modified") && r.right !== undefined && (
|
||||
<CodeBlock
|
||||
code={r.right}
|
||||
lang={props.lang}
|
||||
data-section="cell"
|
||||
data-diff-type="added"
|
||||
data-display-mobile="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={styles.column}>
|
||||
{rows().map((r) => (
|
||||
<CodeBlock
|
||||
code={r.right}
|
||||
lang={props.lang}
|
||||
data-section="cell"
|
||||
data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div class={styles.afterColumn}>
|
||||
<CodeBlock
|
||||
code={r.right}
|
||||
lang={props.lang}
|
||||
data-section="cell"
|
||||
data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiffView
|
||||
|
||||
// String to test diff viewer with
|
||||
const testDiff = `--- combined_before.txt 2025-06-24 16:38:08
|
||||
+++ combined_after.txt 2025-06-24 16:38:12
|
||||
@@ -1,21 +1,25 @@
|
||||
unchanged line
|
||||
-deleted line
|
||||
-old content
|
||||
+added line
|
||||
+new content
|
||||
|
||||
-removed empty line below
|
||||
+added empty line above
|
||||
|
||||
- tab indented
|
||||
-trailing spaces
|
||||
-very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view
|
||||
-unicode content: 🚀 ✨ 中文
|
||||
-mixed content with tabs and spaces
|
||||
+ space indented
|
||||
+no trailing spaces
|
||||
+short line
|
||||
+very long replacement line that will also wrap and test how the diff viewer handles long line additions after short line removals
|
||||
+different unicode: 🎉 💻 日本語
|
||||
+normalized content with consistent spacing
|
||||
+newline to content
|
||||
|
||||
-content to remove
|
||||
-whitespace only:
|
||||
-multiple
|
||||
-consecutive
|
||||
-deletions
|
||||
-single deletion
|
||||
+
|
||||
+single addition
|
||||
+first addition
|
||||
+second addition
|
||||
+third addition
|
||||
line before addition
|
||||
+first added line
|
||||
+
|
||||
+third added line
|
||||
line after addition
|
||||
final unchanged line`
|
||||
|
||||
@@ -463,6 +463,7 @@ function MarkdownPart(props: MarkdownPartProps) {
|
||||
|
||||
interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
command: string
|
||||
error?: string
|
||||
result?: string
|
||||
desc?: string
|
||||
expand?: boolean
|
||||
@@ -470,6 +471,7 @@ interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
function TerminalPart(props: TerminalPartProps) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"command",
|
||||
"error",
|
||||
"result",
|
||||
"desc",
|
||||
"expand",
|
||||
@@ -508,12 +510,25 @@ function TerminalPart(props: TerminalPartProps) {
|
||||
</div>
|
||||
<div data-section="content">
|
||||
<CodeBlock lang="bash" code={local.command} />
|
||||
<CodeBlock
|
||||
lang="console"
|
||||
onRendered={checkOverflow}
|
||||
ref={(el) => (preEl = el)}
|
||||
code={local.result || ""}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={local.error}>
|
||||
<CodeBlock
|
||||
data-section="error"
|
||||
lang="text"
|
||||
onRendered={checkOverflow}
|
||||
ref={(el) => (preEl = el)}
|
||||
code={local.error || ""}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={local.result}>
|
||||
<CodeBlock
|
||||
lang="console"
|
||||
onRendered={checkOverflow}
|
||||
ref={(el) => (preEl = el)}
|
||||
code={local.result || ""}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
{((!local.expand && overflowed()) || expanded()) && (
|
||||
@@ -1601,8 +1616,10 @@ export default function Share(props: {
|
||||
}
|
||||
>
|
||||
{(_part) => {
|
||||
const command = () => toolData()?.args.command
|
||||
const desc = () => toolData()?.args.description
|
||||
const command = () => toolData()?.metadata?.title
|
||||
const desc = () => toolData()?.metadata?.description
|
||||
const result = () => toolData()?.metadata?.stdout
|
||||
const error = () => toolData()?.metadata?.stderr
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1617,14 +1634,17 @@ export default function Share(props: {
|
||||
<div></div>
|
||||
</div>
|
||||
<div data-section="content">
|
||||
<div data-part-tool-body>
|
||||
<TerminalPart
|
||||
desc={desc()}
|
||||
data-size="sm"
|
||||
command={command()}
|
||||
result={toolData()?.result}
|
||||
/>
|
||||
</div>
|
||||
{command() && (
|
||||
<div data-part-tool-body>
|
||||
<TerminalPart
|
||||
desc={desc()}
|
||||
data-size="sm"
|
||||
command={command()!}
|
||||
result={result()}
|
||||
error={error()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ToolFooter
|
||||
time={toolData()?.duration || 0}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
pre {
|
||||
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
|
||||
background-color: var(--sl-color-bg-surface) !important;
|
||||
|
||||
span {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
.diff {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--sl-color-divider);
|
||||
background-color: var(--sl-color-bg-surface);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.beforeColumn,
|
||||
.afterColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow-x: visible;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-right: 1px solid var(--sl-color-divider);
|
||||
}
|
||||
.beforeColumn {
|
||||
border-right: 1px solid var(--sl-color-divider);
|
||||
}
|
||||
|
||||
& > [data-section="cell"]:first-child {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
& > [data-section="cell"]:last-child {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.diff > .row:first-child [data-section="cell"]:first-child {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.diff > .row:last-child [data-section="cell"]:last-child {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
[data-section="cell"] {
|
||||
position: relative;
|
||||
flex: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.1875rem 0.5rem 0.1875rem 2.2ch;
|
||||
margin: 0;
|
||||
|
||||
&[data-display-mobile="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
|
||||
background-color: var(--sl-color-bg-surface) !important;
|
||||
@@ -83,3 +96,27 @@
|
||||
color: var(--sl-color-green-high);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.afterColumn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.beforeColumn {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
[data-section="cell"] {
|
||||
&[data-display-mobile="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&[data-display-mobile="false"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,16 +37,26 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-weight: 500;
|
||||
|
||||
&::before {
|
||||
content: "`";
|
||||
font-weight: 600;
|
||||
}
|
||||
&::after {
|
||||
content: "`";
|
||||
font-weight: 600;
|
||||
&:not(pre code) {
|
||||
&::before {
|
||||
content: "`";
|
||||
font-weight: 700;
|
||||
}
|
||||
&::after {
|
||||
content: "`";
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +616,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-section="error"] {
|
||||
pre {
|
||||
color: var(--sl-color-red) !important;
|
||||
--shiki-dark: var(--sl-color-red) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-expanded="true"] {
|
||||
pre {
|
||||
display: block;
|
||||
|
||||
@@ -64,6 +64,10 @@ paru -S opencode-bin
|
||||
|
||||
---
|
||||
|
||||
##### Windows
|
||||
|
||||
Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
|
||||
|
||||
## Providers
|
||||
|
||||
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
|
||||
if not exist ".git" (
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if not exist ".git\hooks" (
|
||||
mkdir ".git\hooks"
|
||||
)
|
||||
|
||||
(
|
||||
echo #!/bin/sh
|
||||
echo bun run typecheck
|
||||
) > ".git\hooks\pre-push"
|
||||
|
||||
echo ✅ Pre-push hook installed
|
||||
Reference in New Issue
Block a user