mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 00:56:45 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524c637da0 | ||
|
|
3997706bc5 | ||
|
|
838d747514 | ||
|
|
879766aee7 | ||
|
|
d6625397d9 | ||
|
|
ab77fb080a |
@@ -236,7 +236,6 @@ const pre = [
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
@@ -275,6 +274,7 @@ const post = [
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
|
||||
@@ -2,14 +2,19 @@ export * as PlanPlugin from "./plan.js"
|
||||
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { SessionEvent } from "../session/event.js"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = `<system-reminder>
|
||||
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
|
||||
const enter = (directory: string) => `<system-reminder>
|
||||
You are in Plan mode. You may only edit or create files in the Plan directory: ${directory}
|
||||
You may not modify files outside that directory, and you may not ask a subagent to do that either.
|
||||
|
||||
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
|
||||
</system-reminder>`
|
||||
@@ -21,30 +26,42 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const environment = yield* Environment.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.home, ".opencode", "plan")
|
||||
const enterReminder = enter(directory)
|
||||
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
|
||||
item.mode = "primary"
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
item.permissions.push({ action: "edit", resource: "*", effect: "deny" })
|
||||
item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" })
|
||||
item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" })
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.tool.hook("execute.before", (event) => {
|
||||
yield* ctx.tool.hook("execute.after", (event) => {
|
||||
if (event.agent !== plan) return Effect.void
|
||||
if (event.status !== "error") return Effect.void
|
||||
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
|
||||
return new ToolFailure({
|
||||
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
|
||||
if (!(event.error.error instanceof Permission.BlockedError)) return Effect.void
|
||||
event.error = new ToolFailure({
|
||||
message: `Cannot use ${event.tool} to modify files outside the Plan directory: ${directory}`,
|
||||
})
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
// Compaction and committed reverts can strip reminders while the session's agent stays
|
||||
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
|
||||
yield* ctx.session.hook("context", (event) => {
|
||||
const reminder = lastReminder(event.messages)
|
||||
const missing = event.agent === plan && reminder !== enter
|
||||
const stale = event.agent !== plan && reminder === enter
|
||||
const text = missing ? enter : stale ? leave : undefined
|
||||
const reminder = lastReminder(event.messages, enterReminder)
|
||||
const missing = event.agent === plan && reminder !== enterReminder
|
||||
const stale = event.agent !== plan && reminder === enterReminder
|
||||
const text = missing ? enterReminder : stale ? leave : undefined
|
||||
if (!text) return Effect.void
|
||||
// Before the user's prompt, matching where agent-switch reminders land.
|
||||
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
|
||||
@@ -64,7 +81,7 @@ export const Plugin = define({
|
||||
event.type === "session.created" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const text = switchReminder(event)
|
||||
const text = switchReminder(event, enterReminder)
|
||||
if (!text) return Effect.void
|
||||
return ctx.session
|
||||
.synthetic({
|
||||
@@ -83,20 +100,24 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
function switchReminder(
|
||||
event: SessionEvent.Created | SessionEvent.AgentSelected,
|
||||
enterReminder: string,
|
||||
): string | undefined {
|
||||
if (event.type === "session.created") {
|
||||
if (event.data.agent !== plan) return
|
||||
return enter
|
||||
if (event.data.agent !== plan) return undefined
|
||||
return enterReminder
|
||||
}
|
||||
if (event.data.agent === event.data.previous) return
|
||||
if (event.data.agent === plan) return enter
|
||||
if (event.data.agent === event.data.previous) return undefined
|
||||
if (event.data.agent === plan) return enterReminder
|
||||
if (event.data.previous === plan) return leave
|
||||
return undefined
|
||||
}
|
||||
|
||||
function lastReminder(messages: ReadonlyArray<Message>) {
|
||||
function lastReminder(messages: ReadonlyArray<Message>, enterReminder: string) {
|
||||
return messages.reduce<string | undefined>((found, message) => {
|
||||
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
|
||||
if (part?.type !== "text") return found
|
||||
return part.text === enter || part.text === leave ? part.text : found
|
||||
return part.text === enterReminder || part.text === leave ? part.text : found
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Stream, Types } from "effect"
|
||||
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "path"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const sessionID = Session.ID.make("ses_plan_test")
|
||||
const plan = Agent.ID.make("plan")
|
||||
const build = Agent.ID.make("build")
|
||||
const home = "/home/plan-test"
|
||||
const planDirectory = path.join(home, ".opencode", "plan")
|
||||
|
||||
const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({
|
||||
id: Event.ID.create(),
|
||||
@@ -30,17 +38,45 @@ const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentS
|
||||
const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.AgentSelected> = []) {
|
||||
const persisted = new Array<string>()
|
||||
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
|
||||
let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect<void>) | undefined
|
||||
const planAgent = {
|
||||
id: plan,
|
||||
name: Agent.Name.make("Plan"),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
||||
} satisfies Types.DeepMutable<Agent.Info>
|
||||
const driver = Environment.makeMemoryDriver()
|
||||
yield* PlanPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
transform: (callback) => {
|
||||
callback({
|
||||
list: () => [planAgent],
|
||||
get: (id) => (id === plan ? planAgent : undefined),
|
||||
default: () => {},
|
||||
update: (id, update) => {
|
||||
if (id === plan) update(planAgent)
|
||||
},
|
||||
remove: () => {},
|
||||
})
|
||||
return Effect.succeed({ dispose: Effect.void })
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
toolHook = callback as unknown as (input: ToolHooks["execute.after"]) => Effect.Effect<void>
|
||||
}
|
||||
return Effect.succeed({ dispose: Effect.void })
|
||||
},
|
||||
},
|
||||
event: {
|
||||
subscribe: () => Stream.fromIterable(events),
|
||||
@@ -65,9 +101,16 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
},
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(
|
||||
Environment.Service,
|
||||
Environment.Service.of({ files: Environment.makeFiles(driver), spawner: driver.spawner }),
|
||||
),
|
||||
)
|
||||
if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook")
|
||||
return { persisted, contextHook }
|
||||
if (!toolHook) return yield* Effect.die("plan plugin did not register a tool hook")
|
||||
return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver), planAgent }
|
||||
})
|
||||
|
||||
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
|
||||
@@ -79,6 +122,19 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
|
||||
tools: {},
|
||||
})
|
||||
|
||||
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
||||
|
||||
const toolError = (tool: "edit" | "write" | "patch", error: Tool.Error): ToolErrorEvent => ({
|
||||
tool,
|
||||
input: {},
|
||||
sessionID,
|
||||
agent: plan,
|
||||
messageID: SessionMessage.ID.make("msg_plan_tool"),
|
||||
id: Tool.CallID.make("call_plan_tool"),
|
||||
status: "error",
|
||||
error,
|
||||
})
|
||||
|
||||
const settle = (persisted: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
|
||||
Effect.gen(function* () {
|
||||
if (persisted.length >= expected) return
|
||||
@@ -104,6 +160,7 @@ describe("plan plugin reminders", () => {
|
||||
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
|
||||
yield* settle(persisted, 2)
|
||||
expect(persisted[0]).toContain("You are in Plan mode")
|
||||
expect(persisted[0]).toContain(planDirectory)
|
||||
expect(persisted[1]).toContain("NO LONGER in Plan mode")
|
||||
}),
|
||||
)
|
||||
@@ -178,3 +235,65 @@ describe("plan plugin reminders", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("plan plugin mutations", () => {
|
||||
it.effect("creates the Plan directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const { files } = yield* run()
|
||||
expect((yield* files.stat(planDirectory)).type).toBe("directory")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows edits only inside the Plan directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const { planAgent } = yield* run()
|
||||
expect(Permission.evaluate("edit", path.join(planDirectory, "work.md"), planAgent.permissions).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("edit", "/workspace/source.ts", planAgent.permissions).effect).toBe("deny")
|
||||
expect(Permission.evaluate("edit", "source.ts", planAgent.permissions).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows the Plan directory external boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const { planAgent } = yield* run()
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(planDirectory, "nested", "*"), planAgent.permissions)
|
||||
.effect,
|
||||
).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rewrites blocked mutation failures with the Plan directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const { toolHook } = yield* run()
|
||||
for (const tool of ["edit", "write", "patch"] as const) {
|
||||
const event = toolError(
|
||||
tool,
|
||||
new ToolFailure({
|
||||
message: "Unable to modify file",
|
||||
error: new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: "edit",
|
||||
resources: ["source.ts"],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
yield* toolHook(event)
|
||||
expect(event.error.message).toContain("outside the Plan directory")
|
||||
expect(event.error.message).toContain(planDirectory)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves mutation failures unrelated to permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const { toolHook } = yield* run()
|
||||
const error = new ToolFailure({ message: "oldString was not found" })
|
||||
const event = toolError("edit", error)
|
||||
yield* toolHook(event)
|
||||
expect(event.error).toBe(error)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -59,11 +59,6 @@
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
"default": "./src/attention-sounds.bun.ts"
|
||||
},
|
||||
"#terminal-win32": {
|
||||
"bun": "./src/terminal-win32.bun.ts",
|
||||
"node": "./src/terminal-win32.node.ts",
|
||||
"default": "./src/terminal-win32.bun.ts"
|
||||
},
|
||||
"#string-width": {
|
||||
"bun": "./src/util/string-width.bun.ts",
|
||||
"node": "./src/util/string-width.node.ts",
|
||||
|
||||
@@ -96,7 +96,6 @@ import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
@@ -266,7 +265,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const finalizers = new Set<() => Promise<void>>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
@@ -450,7 +448,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}),
|
||||
)
|
||||
yield* Effect.sync(() => {
|
||||
win32FlushInputBuffer()
|
||||
if (result.reason !== undefined)
|
||||
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
|
||||
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { dlopen, ptr } from "bun:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued console input (mouse events, key presses, etc.).
|
||||
*/
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
k32!.symbols.FlushConsoleInputBuffer(handle)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const initial = buf[0]!
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
let wrapped: ReadStream["setRawMode"] | undefined
|
||||
|
||||
if (typeof original === "function") {
|
||||
wrapped = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
|
||||
stdin.setRawMode = wrapped
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
|
||||
let done = false
|
||||
unhook = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
clearInterval(interval)
|
||||
if (wrapped && stdin.setRawMode === wrapped) {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
|
||||
k32!.symbols.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { dlopen } from "node:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { arguments: ["i32"], return: "pointer" },
|
||||
GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" },
|
||||
SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" },
|
||||
FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" },
|
||||
}).functions
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE))
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const initial = buffer[0]!
|
||||
const enforce = () => {
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
const wrapped: ReadStream["setRawMode"] = (mode) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
stdin.setRawMode = wrapped
|
||||
later()
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
unhook = () => {
|
||||
clearInterval(interval)
|
||||
if (stdin.setRawMode === wrapped) stdin.setRawMode = original
|
||||
k32!.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
return unhook
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32"
|
||||
Reference in New Issue
Block a user