From 9c7bed512dbb1bb483478d86124fa5b4906b5f42 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Wed, 22 Apr 2026 21:09:34 -0700 Subject: [PATCH 1/2] feat: add support for Ctrl+Z key override in terminal panels tests: add tests for Ctrl+Z override handling in terminal panels --- .../relay/terminal/ClassicTuiPanel.kt | 4 +- .../relay/terminal/TerminalDataProviders.kt | 21 +++++ .../terminal/TerminalDataProvidersTest.kt | 86 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index 48efdf6..0b4be41 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -90,7 +90,8 @@ class ClassicTuiPanel( val widget = runner.startShellTerminalWidget(this, startupOptions, true) terminalWidget = widget Disposer.register(this, widget) - terminalPanel = ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel?.also(::installEmbeddedTerminalDataProvider) + terminalPanel = + ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel?.also(::installEmbeddedTerminalDataProvider) // When the shell process exits, clean up and notify the owner. widget.addTerminationCallback({ @@ -152,6 +153,7 @@ class ClassicTuiPanel( // inside a custom tool window. Install the override on the terminal panel itself so the // explicit null wins before any ancestor ToolWindow provider in the data-context chain. installTerminalToolWindowOverride(panel) + installEmbeddedTerminalKeyOverrides(panel) } private fun uninstallEmbeddedTerminalDataProvider() { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt index 093b2e8..4fafb78 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt @@ -3,6 +3,9 @@ package com.ashotn.opencode.relay.terminal import com.intellij.openapi.actionSystem.CustomizedDataContext import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.terminal.JBTerminalPanel +import java.awt.event.InputEvent +import java.awt.event.KeyEvent import javax.swing.JComponent private const val DATA_PROVIDER_CLIENT_PROPERTY = "DataProvider" @@ -19,3 +22,21 @@ internal fun installTerminalToolWindowOverride(component: JComponent) { internal fun uninstallTerminalToolWindowOverride(component: JComponent) { component.putClientProperty(DATA_PROVIDER_CLIENT_PROPERTY, null) } + +internal fun installEmbeddedTerminalKeyOverrides(panel: JBTerminalPanel) { + panel.addPreKeyEventHandler { event -> + if (consumeEmbeddedTerminalControlKey(event)) { + event.consume() + } + } +} + +internal fun consumeEmbeddedTerminalControlKey(event: KeyEvent): Boolean { + return event.isCtrlZPress() +} + +private fun KeyEvent.isCtrlZPress(): Boolean { + if (id != KeyEvent.KEY_PRESSED) return false + if (keyCode != KeyEvent.VK_Z) return false + return modifiersEx == InputEvent.CTRL_DOWN_MASK +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt index 431d93d..c94fc76 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt @@ -4,10 +4,18 @@ import com.intellij.ide.DataManager import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow +import com.intellij.terminal.JBTerminalPanel +import com.intellij.terminal.JBTerminalSystemSettingsProviderBase import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jediterm.terminal.model.StyleState +import com.jediterm.terminal.model.TerminalTextBuffer import java.awt.BorderLayout +import java.awt.event.InputEvent +import java.awt.event.KeyEvent import java.lang.reflect.Proxy +import java.util.function.Consumer import javax.swing.JPanel class TerminalDataProvidersTest : BasePlatformTestCase() { @@ -36,6 +44,45 @@ class TerminalDataProvidersTest : BasePlatformTestCase() { } } + fun `test classic tui panel installs ctrl z key override without intercepting escape`() { + val terminalPanel = createTerminalPanel() + val classicTuiPanel = ClassicTuiPanel(project, testRootDisposable) + val existingHandlers = preKeyEventHandlers(terminalPanel) + + try { + installEmbeddedTerminalDataProvider(classicTuiPanel, terminalPanel) + + val handlers = preKeyEventHandlers(terminalPanel) + val addedHandlers = handlers.drop(existingHandlers.size) + assertEquals(1, addedHandlers.size) + + val ctrlZ = KeyEvent( + terminalPanel, + KeyEvent.KEY_PRESSED, + System.currentTimeMillis(), + InputEvent.CTRL_DOWN_MASK, + KeyEvent.VK_Z, + 'Z', + ) + addedHandlers.forEach { it.accept(ctrlZ) } + assertTrue(ctrlZ.isConsumed) + + val escape = KeyEvent( + terminalPanel, + KeyEvent.KEY_PRESSED, + System.currentTimeMillis(), + 0, + KeyEvent.VK_ESCAPE, + KeyEvent.CHAR_UNDEFINED, + ) + addedHandlers.forEach { it.accept(escape) } + assertFalse(escape.isConsumed) + } finally { + ensureTerminalPanelCanBeDisposed(terminalPanel) + Disposer.dispose(terminalPanel) + } + } + private fun toolWindowStub(): ToolWindow = Proxy.newProxyInstance( ToolWindow::class.java.classLoader, @@ -46,4 +93,43 @@ class TerminalDataProvidersTest : BasePlatformTestCase() { else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null } } as ToolWindow + + private fun createTerminalPanel(): JBTerminalPanel { + lateinit var terminalPanel: JBTerminalPanel + ApplicationManager.getApplication().invokeAndWait { + val styleState = StyleState() + terminalPanel = JBTerminalPanel( + JBTerminalSystemSettingsProviderBase(), + TerminalTextBuffer(80, 24, styleState), + styleState, + ) + } + return terminalPanel + } + + private fun installEmbeddedTerminalDataProvider(classicTuiPanel: ClassicTuiPanel, terminalPanel: JBTerminalPanel) { + val method = ClassicTuiPanel::class.java.getDeclaredMethod( + "installEmbeddedTerminalDataProvider", + JBTerminalPanel::class.java, + ) + method.isAccessible = true + ApplicationManager.getApplication().invokeAndWait { + method.invoke(classicTuiPanel, terminalPanel) + } + } + + @Suppress("UNCHECKED_CAST") + private fun preKeyEventHandlers(terminalPanel: JBTerminalPanel): List> { + val field = JBTerminalPanel::class.java.getDeclaredField("myPreKeyEventConsumers") + field.isAccessible = true + return (field.get(terminalPanel) as List>).toList() + } + + private fun ensureTerminalPanelCanBeDisposed(terminalPanel: JBTerminalPanel) { + val field = terminalPanel.javaClass.superclass.getDeclaredField("myRepaintTimer") + field.isAccessible = true + if (field.get(terminalPanel) == null) { + field.set(terminalPanel, javax.swing.Timer(0) { }) + } + } } From a651e3e6d8d5b27b65e5f38f2ab8b6a615c5d394 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Wed, 22 Apr 2026 23:47:24 -0700 Subject: [PATCH 2/2] tests: improve OpenCode live test prompts and diff validation --- .../integration/diff/OpenCodeDiffLiveTest.kt | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt index 45d3a96..75d4343 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt @@ -117,11 +117,16 @@ class OpenCodeDiffLiveTest( port = server.port, sessionId = sessionId, text = """ - Edit only note.txt. - Replace the line Bravo with Beta. - Keep all other lines unchanged. - no prefixes like `1:`, no duplicated content, and no other changes - Do not modify any other files. + Edit only `note.txt`. + Make exactly one change: replace the second line `Bravo` with `Beta`. + The final file must be exactly: + ```text + Alpha + Beta + Charlie + ``` + Keep the trailing newline. + Do not add line numbers, duplicate content, or modify any other files. """.trimIndent(), ) @@ -155,7 +160,28 @@ class OpenCodeDiffLiveTest( withLiveSession(version) { environment, server, sessionClient, events, sessionId -> val longFile = environment.repoRoot.resolve("numbers.txt") val original = lines(1..100) + val removedBlock = lines(41..60) val expected = lines((1..100).filter { it !in 41..60 }) + val searchBlock = "40\n${removedBlock}61\n" + val replacementBlock = "40\n61\n" + val prompt = """ + Edit only `numbers.txt`. + Perform one exact text replacement in the file. + Replace this exact text: + ```text + $searchBlock + ``` + with this exact text: + ```text + $replacementBlock + ``` + Do not rewrite the whole file. + Leave all remaining content byte-for-byte unchanged. + After the edit, line `40` must be followed immediately by line `61`. + The file must still start with `1`, end with `100`, contain one plain number per line, + and keep the trailing newline. + Do not add line numbers, duplicate content, renumber anything, or modify any other files. + """.trimIndent() longFile.writeText(original) submitPromptAndAwaitTurn( @@ -195,7 +221,7 @@ class OpenCodeDiffLiveTest( repoRoot = environment.repoRoot.toString(), sessionId = sessionId, diffFile = diffFile, - expectedRemoved = lines(41..60).removeSuffix("\n"), + expectedRemoved = removedBlock.removeSuffix("\n"), expectedAdded = "", ) }