Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "./run/runtime.stdin"

type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]

Expand Down Expand Up @@ -278,8 +278,8 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const dieInteractive = (error: unknown): never => {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
die(error.message)
if (error instanceof Error && error.message === INTERACTIVE_INPUT_REASON) {
die(`--mini ${error.message}`)
}

throw error
Expand Down
54 changes: 44 additions & 10 deletions packages/opencode/src/cli/cmd/run/runtime.stdin.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,71 @@
import { dlopen } from "bun:ffi"
import fs from "fs"
import * as tty from "node:tty"
import { ReadStream } from "node:tty"

export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
export const INTERACTIVE_INPUT_REASON = "requires a controlling terminal for input"

type InteractiveStdin = {
stdin: NodeJS.ReadStream
cleanup?: () => void
}

function openTerminalStdin(path: string): NodeJS.ReadStream {
return new tty.ReadStream(fs.openSync(path, "r"))
return new ReadStream(fs.openSync(path, "r"))
}

const duplicates = new Map<NodeJS.Platform, (fd: number) => number>()

function duplicate(fd: number, platform: NodeJS.Platform) {
const cached = duplicates.get(platform)
if (cached) return cached(fd)
if (platform === "win32") {
const library = dlopen("ucrtbase.dll", { _dup2: { args: ["i32", "i32"], returns: "i32" } })
const value = (source: number) => library.symbols._dup2(source, 0)
duplicates.set(platform, value)
return value(fd)
}
const library = dlopen(platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6", {
dup2: { args: ["i32", "i32"], returns: "i32" },
})
const value = (source: number) => library.symbols.dup2(source, 0)
duplicates.set(platform, value)
return value(fd)
}

function redirectStdin(_stdin: NodeJS.ReadStream, path: string, platform: NodeJS.Platform) {
const fd = fs.openSync(path, "r")
try {
const result = duplicate(fd, platform)
if (result !== 0) throw new Error(`Failed to redirect stdin: ${result}`)
} finally {
fs.closeSync(fd)
}
}

export function resolveInteractiveStdin(
stdin: NodeJS.ReadStream = process.stdin,
open: (path: string) => NodeJS.ReadStream = openTerminalStdin,
platform = process.platform,
redirect: (stdin: NodeJS.ReadStream, path: string, platform: NodeJS.Platform) => void = redirectStdin,
): InteractiveStdin {
if (stdin.isTTY) {
return { stdin }
}

const file = platform === "win32" ? "CONIN$" : "/dev/tty"
const terminal = platform === "win32" ? "CONIN$" : "/dev/tty"
const ignored = platform === "win32" ? "NUL" : "/dev/null"

try {
const stream = open(file)
const stream = open(terminal)
try {
redirect(stdin, ignored, platform)
} catch (error) {
stream.destroy()
throw error
}
return {
stdin: stream,
cleanup: () => {
stream.destroy()
},
}
} catch (error) {
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
throw new Error(INTERACTIVE_INPUT_REASON, { cause: error })
}
}
23 changes: 20 additions & 3 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { writeHeapSnapshot } from "v8"
import { ServerAuth } from "@/server/auth"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "./run/runtime.stdin"

declare global {
const OPENCODE_WORKER_PATH: string
Expand Down Expand Up @@ -186,9 +187,25 @@ export const TuiThreadCommand = cmd({
return
}

const unguard = win32InstallCtrlCGuard()
let unguard: (() => void) | undefined
let interactiveStdin: ReturnType<typeof resolveInteractiveStdin> | undefined
try {
const { TuiConfig } = await import("@/config/tui")
const prompt = await input(args.prompt)
try {
interactiveStdin = resolveInteractiveStdin()
} catch (error) {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_REASON) {
UI.error(`The TUI ${error.message}`)
process.exitCode = 1
return
}

throw error
}

unguard = win32InstallCtrlCGuard(interactiveStdin.stdin)

if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
Expand Down Expand Up @@ -227,7 +244,6 @@ export const TuiThreadCommand = cmd({
worker.terminate()
}

const prompt = await input(args.prompt)
const config = await TuiConfig.get()

const network = resolveNetworkOptionsNoConfig(args)
Expand Down Expand Up @@ -280,6 +296,7 @@ export const TuiThreadCommand = cmd({
},
config,
pluginHost: createLegacyTuiPluginHost(),
stdin: interactiveStdin.stdin,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers,
Expand All @@ -302,8 +319,8 @@ export const TuiThreadCommand = cmd({
try {
unguard?.()
} catch {}
interactiveStdin?.cleanup?.()
}
process.exit(0)
},
})
// scratch
65 changes: 49 additions & 16 deletions packages/opencode/test/cli/run/runtime.stdin.test.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,93 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"

function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
}

function redirect(seen: string[]) {
return (_stdin: NodeJS.ReadStream, path: string) => {
seen.push(path)
}
}

describe("run interactive stdin", () => {
test("reuses stdin when it is already a tty", () => {
test("opens the controlling terminal and redirects the original tty stdin", () => {
const stdin = stream(true)
const seen: string[] = []
const tty = stream(true)
const opened: string[] = []
const redirected: string[] = []
const result = resolveInteractiveStdin(
stdin,
(path) => {
seen.push(path)
return stream(true)
opened.push(path)
return tty
},
"linux",
redirect(redirected),
)

expect(result.stdin).toBe(stdin)
expect(result.cleanup).toBeUndefined()
expect(seen).toEqual([])
expect(result.stdin).toBe(tty)
expect(opened).toEqual(["/dev/tty"])
expect(redirected).toEqual(["/dev/null"])

result.cleanup?.()
expect(tty.destroyed).toBe(true)
})

test("opens the controlling terminal when stdin is piped", () => {
const tty = stream(true)
const seen: string[] = []
const opened: string[] = []
const redirected: string[] = []
const result = resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
opened.push(path)
return tty
},
"linux",
redirect(redirected),
)

expect(result.stdin).toBe(tty)
expect(seen).toEqual(["/dev/tty"])
expect(opened).toEqual(["/dev/tty"])
expect(redirected).toEqual(["/dev/null"])

result.cleanup?.()
expect(tty.destroyed).toBe(true)
})

test("uses CONIN$ on windows", () => {
const seen: string[] = []
test("uses the windows console and null device", () => {
const opened: string[] = []
const redirected: string[] = []
resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
opened.push(path)
return stream(true)
},
"win32",
redirect(redirected),
)

expect(seen).toEqual(["CONIN$"])
expect(opened).toEqual(["CONIN$"])
expect(redirected).toEqual(["NUL"])
})

test("closes the controlling terminal when redirecting stdin fails", () => {
const tty = stream(true)
expect(() =>
resolveInteractiveStdin(
stream(true),
() => tty,
"linux",
() => {
throw new Error("redirect failed")
},
),
).toThrow(INTERACTIVE_INPUT_REASON)
expect(tty.destroyed).toBe(true)
})

test("throws a clear error when no controlling terminal is available", () => {
Expand All @@ -66,6 +99,6 @@ describe("run interactive stdin", () => {
},
"linux",
),
).toThrow(INTERACTIVE_INPUT_ERROR)
).toThrow(INTERACTIVE_INPUT_REASON)
})
})
24 changes: 17 additions & 7 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
Show,
on,
} from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
import {
TuiPathsProvider,
TuiStartupProvider,
TuiStdinProvider,
TuiTerminalEnvironmentProvider,
useTuiStartup,
} from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogProvider as DialogProviderList } from "./component/dialog-provider"
import { ErrorComponent } from "./component/error-component"
Expand Down Expand Up @@ -149,6 +155,7 @@ export type TuiInput = {
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
stdin?: NodeJS.ReadStream
}

function errorMessage(error: unknown) {
Expand Down Expand Up @@ -203,6 +210,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
...(input.stdin ? { stdin: input.stdin } : {}),
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
Expand All @@ -211,7 +219,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
destroyRenderer(renderer)
}),
)
win32DisableProcessedInput()
win32DisableProcessedInput(input.stdin)
const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
Expand Down Expand Up @@ -315,10 +323,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
<TuiStdinProvider value={input.stdin}>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</TuiStdinProvider>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
Expand Down Expand Up @@ -355,7 +365,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}),
)
yield* Effect.sync(() => {
win32FlushInputBuffer()
win32FlushInputBuffer(input.stdin)
if (result.reason !== undefined)
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
Expand Down
4 changes: 3 additions & 1 deletion packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useLocal } from "../../context/local"
import { Flag } from "@opencode-ai/core/flag/flag"
import { tint, useTheme } from "../../context/theme"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { useTuiPaths, useTuiStdin, useTuiTerminalEnvironment } from "../../context/runtime"
import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner"
import { useSDK } from "../../context/sdk"
Expand Down Expand Up @@ -151,6 +151,7 @@ export function Prompt(props: PromptProps) {
const paths = useTuiPaths()
const location = useLocation()
const terminalEnvironment = useTuiTerminalEnvironment()
const stdin = useTuiStdin()
const clipboard = useClipboard()
const sdk = useSDK()
const editor = useEditorContext()
Expand Down Expand Up @@ -442,6 +443,7 @@ export function Prompt(props: PromptProps) {
const content = await openEditor({
renderer,
value,
stdin,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
Expand Down
Loading
Loading