Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
@@ -180,6 +181,7 @@ export function tui(input: {
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
@@ -209,6 +211,35 @@ function App() {
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (!renderer.getSelection()) return
|
||||
|
||||
// Windows Terminal-like behavior:
|
||||
// - Ctrl+C copies and dismisses selection
|
||||
// - Esc dismisses selection
|
||||
// - Most other key input dismisses selection and is passed through
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (!Selection.copy(renderer, toast)) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "escape") {
|
||||
renderer.clearSelection()
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
renderer.clearSelection()
|
||||
})
|
||||
|
||||
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
||||
renderer.console.onCopySelection = async (text: string) => {
|
||||
if (!text || text.length === 0) return
|
||||
@@ -216,6 +247,7 @@ function App() {
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
@@ -711,19 +743,15 @@ function App() {
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseUp={async () => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Renderable, RGBA } from "@opentui/core"
|
||||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { useToast } from "./toast"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
@@ -16,10 +17,18 @@ export function Dialog(
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseUp={async () => {
|
||||
if (renderer.getSelection()) return
|
||||
onMouseDown={() => {
|
||||
dismiss = !!renderer.getSelection()
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (dismiss) {
|
||||
dismiss = false
|
||||
return
|
||||
}
|
||||
props.onClose?.()
|
||||
}}
|
||||
width={dimensions().width}
|
||||
@@ -32,8 +41,8 @@ export function Dialog(
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
onMouseUp={async (e) => {
|
||||
if (renderer.getSelection()) return
|
||||
onMouseUp={(e) => {
|
||||
dismiss = false
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
@@ -56,8 +65,13 @@ function init() {
|
||||
size: "medium" as "medium" | "large",
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
@@ -67,7 +81,6 @@ function init() {
|
||||
}
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
let focus: Renderable | null
|
||||
function refocus() {
|
||||
setTimeout(() => {
|
||||
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
|
||||
}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Clipboard } from "./clipboard"
|
||||
|
||||
type Toast = {
|
||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||
error: (err: unknown) => void
|
||||
}
|
||||
|
||||
type Renderer = {
|
||||
getSelection: () => { getSelectedText: () => string } | null
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export namespace Selection {
|
||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
|
||||
Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -175,8 +175,14 @@ export namespace Config {
|
||||
}
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
// Route through load() to enable {env:} and {file:} token substitution.
|
||||
// Use a path within Instance.directory so relative {file:} paths resolve correctly.
|
||||
// The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity.
|
||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export namespace Flag {
|
||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export declare const OPENCODE_CONFIG_CONTENT: string | undefined
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
@@ -37,7 +37,10 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
|
||||
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
|
||||
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
|
||||
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
|
||||
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
|
||||
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
|
||||
export const OPENCODE_ENABLE_EXA =
|
||||
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
|
||||
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
|
||||
@@ -91,3 +94,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CONFIG_CONTENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", {
|
||||
get() {
|
||||
return process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import z from "zod"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -55,15 +55,15 @@ export namespace Project {
|
||||
|
||||
const { id, sandbox, worktree, vcs } = await iife(async () => {
|
||||
const matches = Filesystem.up({ targets: [".git"], start: directory })
|
||||
const git = await matches.next().then((x) => x.value)
|
||||
const dotgit = await matches.next().then((x) => x.value)
|
||||
await matches.return()
|
||||
if (git) {
|
||||
let sandbox = path.dirname(git)
|
||||
if (dotgit) {
|
||||
let sandbox = path.dirname(dotgit)
|
||||
|
||||
const gitBinary = Bun.which("git")
|
||||
|
||||
// cached id calculation
|
||||
let id = await Bun.file(path.join(git, "opencode"))
|
||||
let id = await Bun.file(path.join(dotgit, "opencode"))
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
.catch(() => undefined)
|
||||
@@ -79,13 +79,11 @@ export namespace Project {
|
||||
|
||||
// generate id from root commit
|
||||
if (!id) {
|
||||
const roots = await $`git rev-list --max-parents=0 --all`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(sandbox)
|
||||
.text()
|
||||
.then((x) =>
|
||||
x
|
||||
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) =>
|
||||
(await result.text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
@@ -104,7 +102,7 @@ export namespace Project {
|
||||
|
||||
id = roots[0]
|
||||
if (id) {
|
||||
void Bun.file(path.join(git, "opencode"))
|
||||
void Bun.file(path.join(dotgit, "opencode"))
|
||||
.write(id)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
@@ -119,12 +117,10 @@ export namespace Project {
|
||||
}
|
||||
}
|
||||
|
||||
const top = await $`git rev-parse --show-toplevel`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(sandbox)
|
||||
.text()
|
||||
.then((x) => path.resolve(sandbox, x.trim()))
|
||||
const top = await git(["rev-parse", "--show-toplevel"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!top) {
|
||||
@@ -138,13 +134,11 @@ export namespace Project {
|
||||
|
||||
sandbox = top
|
||||
|
||||
const worktree = await $`git rev-parse --git-common-dir`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(sandbox)
|
||||
.text()
|
||||
.then((x) => {
|
||||
const dirname = path.dirname(x.trim())
|
||||
const worktree = await git(["rev-parse", "--git-common-dir"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => {
|
||||
const dirname = path.dirname((await result.text()).trim())
|
||||
if (dirname === ".") return sandbox
|
||||
return dirname
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Env } from "../env"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
|
||||
// Direct imports for bundled providers
|
||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||
@@ -1229,9 +1231,19 @@ export namespace Provider {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const provider = await list()
|
||||
.then((val) => Object.values(val))
|
||||
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
|
||||
const providers = await list()
|
||||
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
.json()
|
||||
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
|
||||
.catch(() => [])) as { providerID: string; modelID: string }[]
|
||||
for (const entry of recent) {
|
||||
const provider = providers[entry.providerID]
|
||||
if (!provider) continue
|
||||
if (!provider.models[entry.modelID]) continue
|
||||
return { providerID: entry.providerID, modelID: entry.modelID }
|
||||
}
|
||||
|
||||
const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
|
||||
import z from "zod"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import type { WSContext } from "hono/ws"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@opencode-ai/util/lazy"
|
||||
import { Shell } from "@/shell/shell"
|
||||
@@ -17,6 +16,22 @@ export namespace Pty {
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const sockets = new WeakMap<object, number>()
|
||||
let socketCounter = 0
|
||||
|
||||
const tagSocket = (ws: Socket) => {
|
||||
if (!ws || typeof ws !== "object") return
|
||||
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
|
||||
sockets.set(ws, next)
|
||||
return next
|
||||
}
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -81,7 +96,7 @@ export namespace Pty {
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Set<WSContext>
|
||||
subscribers: Map<Socket, number>
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
@@ -91,8 +106,12 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
for (const ws of session.subscribers.keys()) {
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.clear()
|
||||
@@ -154,18 +173,26 @@ export namespace Pty {
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Set(),
|
||||
subscribers: new Map(),
|
||||
}
|
||||
state().set(id, session)
|
||||
ptyProcess.onData((data) => {
|
||||
session.cursor += data.length
|
||||
|
||||
for (const ws of session.subscribers) {
|
||||
for (const [ws, id] of session.subscribers) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
ws.send(data)
|
||||
if (typeof ws === "object" && sockets.get(ws) !== id) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
session.subscribers.delete(ws)
|
||||
}
|
||||
}
|
||||
|
||||
session.buffer += data
|
||||
@@ -177,14 +204,15 @@ export namespace Pty {
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
for (const ws of session.subscribers.keys()) {
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
}
|
||||
state().delete(id)
|
||||
})
|
||||
Bus.publish(Event.Created, { info })
|
||||
@@ -211,9 +239,14 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
for (const ws of session.subscribers.keys()) {
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
state().delete(id)
|
||||
Bus.publish(Event.Deleted, { id })
|
||||
}
|
||||
@@ -232,7 +265,7 @@ export namespace Pty {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(id: string, ws: WSContext, cursor?: number) {
|
||||
export function connect(id: string, ws: Socket, cursor?: number) {
|
||||
const session = state().get(id)
|
||||
if (!session) {
|
||||
ws.close()
|
||||
@@ -272,7 +305,8 @@ export namespace Pty {
|
||||
return
|
||||
}
|
||||
|
||||
session.subscribers.add(ws)
|
||||
const socketId = tagSocket(ws)
|
||||
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(String(message))
|
||||
|
||||
@@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() =>
|
||||
})()
|
||||
let handler: ReturnType<typeof Pty.connect>
|
||||
if (!Pty.get(id)) throw new Error("Session not found")
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const isSocket = (value: unknown): value is Socket => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (!("readyState" in value)) return false
|
||||
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
|
||||
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
|
||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||
}
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
handler = Pty.connect(id, ws, cursor)
|
||||
const socket = isSocket(ws.raw) ? ws.raw : ws
|
||||
handler = Pty.connect(id, socket, cursor)
|
||||
},
|
||||
onMessage(event) {
|
||||
handler?.onMessage(String(event.data))
|
||||
@@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() =>
|
||||
onClose() {
|
||||
handler?.onClose()
|
||||
},
|
||||
onError() {
|
||||
handler?.onClose()
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
@@ -23,7 +24,7 @@ export namespace Snapshot {
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
@@ -48,7 +49,7 @@ export namespace Snapshot {
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { $ } from "bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
export interface GitResult {
|
||||
exitCode: number
|
||||
text(): string | Promise<string>
|
||||
stdout: Buffer | ReadableStream<Uint8Array>
|
||||
stderr: Buffer | ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command.
|
||||
*
|
||||
* Uses Bun's lightweight `$` shell by default. When the process is running
|
||||
* as an ACP client, child processes inherit the parent's stdin pipe which
|
||||
* carries protocol data – on Windows this causes git to deadlock. In that
|
||||
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
|
||||
*/
|
||||
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||
if (Flag.OPENCODE_CLIENT === "acp") {
|
||||
try {
|
||||
const proc = Bun.spawn(["git", ...args], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: opts.cwd,
|
||||
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
||||
})
|
||||
// Read output concurrently with exit to avoid pipe buffer deadlock
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).arrayBuffer(),
|
||||
new Response(proc.stderr).arrayBuffer(),
|
||||
])
|
||||
const stdoutBuf = Buffer.from(stdout)
|
||||
const stderrBuf = Buffer.from(stderr)
|
||||
return {
|
||||
exitCode,
|
||||
text: () => stdoutBuf.toString(),
|
||||
stdout: stdoutBuf,
|
||||
stderr: stderrBuf,
|
||||
}
|
||||
} catch (error) {
|
||||
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
|
||||
return {
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = opts.env ? { ...process.env, ...opts.env } : undefined
|
||||
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
|
||||
if (env) cmd = cmd.env(env)
|
||||
const result = await cmd
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.text(),
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}
|
||||
}
|
||||
@@ -420,49 +420,78 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
const directory = await canonical(input.directory)
|
||||
const locate = async (stdout: Uint8Array | undefined) => {
|
||||
const lines = outputText(stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return (async () => {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const clean = (target: string) =>
|
||||
fs
|
||||
.rm(target, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||
})
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const lines = outputText(list.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const entry = await (async () => {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
const entry = await locate(list.stdout)
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = await exists(directory)
|
||||
if (directoryExists) {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
await clean(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (removed.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (next.exitCode !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
|
||||
})
|
||||
}
|
||||
|
||||
const stale = await locate(next.stdout)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
|
||||
await clean(entry.path)
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
|
||||
Reference in New Issue
Block a user