Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = "",
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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<Consumer<KeyEvent>> {
val field = JBTerminalPanel::class.java.getDeclaredField("myPreKeyEventConsumers")
field.isAccessible = true
return (field.get(terminalPanel) as List<Consumer<KeyEvent>>).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) { })
}
}
}
Loading