From c9102a1246c07928346ea0a13e2c9f343c9418e8 Mon Sep 17 00:00:00 2001 From: anntnzrb Date: Fri, 19 Dec 2025 01:26:29 -0500 Subject: [PATCH 1/2] feat(tui): add configurable double-esc interrupt window --- .../cli/cmd/tui/component/prompt/index.tsx | 3 +- packages/opencode/src/config/config.ts | 9 +++ packages/opencode/test/config/config.test.ts | 56 +++++++++++++++++++ packages/web/src/content/docs/config.mdx | 1 + 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 99a90ab46ac..ebeba06e517 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -117,6 +117,7 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const sessionInterruptTimeout = createMemo(() => sync.data.config.tui?.session_interrupt_timeout_ms ?? 5000) const history = usePromptHistory() const command = useCommandDialog() const renderer = useRenderer() @@ -210,7 +211,7 @@ export function Prompt(props: PromptProps) { setTimeout(() => { setStore("interrupt", 0) - }, 5000) + }, sessionInterruptTimeout()) if (store.interrupt >= 2) { sdk.client.session.abort({ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a01cc832a09..ffd11ae6201 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -140,9 +140,11 @@ export namespace Config { } if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) + result.tui = Info.shape.tui.parse(result.tui ?? {}) return { config: result, + directories, } }) @@ -580,6 +582,13 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + session_interrupt_timeout_ms: z + .number() + .int() + .min(500) + .max(10000) + .default(5000) + .describe("Time window in milliseconds to detect double-Esc interrupts"), }) export const Layout = z.enum(["auto", "stretch"]).meta({ diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2ff8c01cdb0..da3feabbb3a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -17,6 +17,62 @@ test("loads config with defaults when no files exist", async () => { }) }) +test("defaults session interrupt timeout when not configured", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.tui?.session_interrupt_timeout_ms).toBe(5000) + }, + }) +}) + +test("accepts in-range session interrupt timeout", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + tui: { + session_interrupt_timeout_ms: 1200, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.tui?.session_interrupt_timeout_ms).toBe(1200) + }, + }) +}) + +test("rejects out-of-range session interrupt timeout", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + tui: { + session_interrupt_timeout_ms: 200, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 302d79d1724..077fe114f26 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -111,6 +111,7 @@ Available options: - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.** - `scroll_speed` - Custom scroll speed multiplier (default: `1`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`. +- `session_interrupt_timeout_ms` - Time window for double-Esc session interrupts (default: `5000`, min: `500`, max: `10000`). Applies to TUI session interruption only. [Learn more about using the TUI here](/docs/tui). From 6c7b22f97e783a3019c5b6091dfaf7528ec00709 Mon Sep 17 00:00:00 2001 From: anntnzrb Date: Fri, 19 Dec 2025 01:39:23 -0500 Subject: [PATCH 2/2] chore: type-safe access for esc timeout config --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ebeba06e517..5be036ad553 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -117,7 +117,11 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) - const sessionInterruptTimeout = createMemo(() => sync.data.config.tui?.session_interrupt_timeout_ms ?? 5000) + const sessionInterruptTimeout = createMemo(() => { + const timeout = (sync.data.config as { tui?: { session_interrupt_timeout_ms?: number } }).tui + ?.session_interrupt_timeout_ms + return typeof timeout === "number" ? timeout : 5000 + }) const history = usePromptHistory() const command = useCommandDialog() const renderer = useRenderer()