Compare commits

..
38 Commits
Author SHA1 Message Date
Dax Raad fc7af31fe5 make edit tool more robust
publish / publish (push) Has been cancelled
2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working
publish / publish (push) Has been cancelled
2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file
publish / publish (push) Has been cancelled
2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
Dax Raadandopencode 9751937894 Enhance auth command with environment variable display and add models command
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad 0fc8dfc77e do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad 81b7df61ec ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad 8217b96d4a ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad 7dd0918d32 remove accidental opanai autoloader
publish / publish (push) Has been cancelled
2025-06-24 21:11:11 -04:00
Dax Raad 4b26b43855 added opencode serve command
publish / publish (push) Has been cancelled
2025-06-24 20:52:09 -04:00
Jay V 9d7cfda9fe docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V a3cf18c905 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden ClineandGitHub 0b1a8ae699 fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad eb70b1e5c8 docs: windows instructions
publish / publish (push) Has been cancelled
2025-06-24 18:54:59 -04:00
Dax Raad 00a3d818b6 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad 2384c7e734 ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad 1bad3d9894 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad 4f715e66dc ci: windows 2025-06-24 18:13:15 -04:00
ec001ca02f windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
JayandGitHub a2d3b9f0c8 docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad 9cfb6ff964 ignore: revert
publish / publish (push) Has been cancelled
2025-06-24 14:59:27 -04:00
Dax Raad 6ed661c140 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad 9dc00edfc9 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V e063bf888e docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
6f18475428 feat: delete sessions (#362)
publish / publish (push) Has been cancelled
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 11:07:41 -05:00
Dax Raad 3664b09812 remove debug code writing to /tmp/message.json
publish / publish (push) Has been cancelled
2025-06-24 11:16:17 -04:00
Dax Raad 7050cc0ac3 ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad 4d3d63294d externalize github copilot code 2025-06-24 10:42:19 -04:00
6bc61cbc2d feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad 01d351bebe add HOMEBREW_NO_AUTO_UPDATE to brew upgrades
publish / publish (push) Has been cancelled
2025-06-23 20:36:08 -04:00
Dax Raad dbba4a97aa force use npm registry
publish / publish (push) Has been cancelled
2025-06-23 20:23:37 -04:00
GitMurfandGitHub 0dc586faef fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad f19c6b05f2 glob tool should respect .gitignore
publish / publish (push) Has been cancelled
2025-06-23 17:37:32 -04:00
Dax Raad bc34f08333 bundle models.dev at build time and ignore refresh errors
publish / publish (push) Has been cancelled
2025-06-23 14:50:19 -04:00
Dax Raad b7ee16aabd ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas GrzegorczykandGitHub ed1b0d97bf Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
45 changed files with 1584 additions and 223 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: 1.2.17
- run: bun install
+1 -1
View File
@@ -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: |
+3
View File
@@ -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
View File
@@ -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"
}
+56
View File
@@ -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%" %*
+4 -2
View File
@@ -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"
},
+1 -1
View File
@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
// ["windows", "x64"],
["windows", "x64"],
]
await $`rm -rf dist`
+10 -1
View File
@@ -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, "-")
}
}
+20
View File
@@ -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
})
+11 -2
View File
@@ -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(
+50 -10
View File
@@ -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
}
+19
View File
@@ -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}`)
}
}
})
},
})
+50
View File
@@ -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()
})
},
})
+3
View File
@@ -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 ""
}
+3 -2
View File
@@ -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
View File
@@ -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)
}
}
+52 -21
View File
@@ -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",
)
+3 -1
View File
@@ -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
}
+6 -6
View File
@@ -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)
}
}
+40 -21
View File
@@ -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,
)
}
}
+30 -3
View File
@@ -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,
})
+60 -21
View File
@@ -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(
{
+6
View File
@@ -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(),
+5
View File
@@ -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>
}
+205 -24
View File
@@ -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")
}
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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()
+209
View File
@@ -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)
}
})
})
+1
View File
@@ -0,0 +1 @@
opencode-test
+13
View File
@@ -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 {
+45 -18
View File
@@ -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),
+2 -1
View File
@@ -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)
}
+53 -4
View File
@@ -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),
+64
View File
@@ -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": {
+185
View File
@@ -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)
+74 -20
View File
@@ -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`
+36 -16
View File
@@ -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;
}
}
}
+51 -14
View File
@@ -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.
+16
View File
@@ -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