@@ -6,31 +6,30 @@ import com.intellij.openapi.Disposable
66import com.intellij.openapi.application.ApplicationManager
77import com.intellij.openapi.diagnostic.Logger
88import com.intellij.openapi.project.Project
9+ import com.intellij.openapi.ui.ComponentContainer
910import com.intellij.openapi.util.Disposer
1011import com.intellij.terminal.ui.TerminalWidget
1112import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
1213import org.jetbrains.plugins.terminal.ShellStartupOptions
14+ import org.jetbrains.plugins.terminal.ShellTerminalWidget
1315import java.awt.BorderLayout
16+ import java.lang.reflect.Proxy
1417import javax.swing.JPanel
1518
1619/* *
17- * Hosts an embedded **classic** terminal (backed by [LocalTerminalDirectRunner] /
18- * [org.jetbrains.plugins.terminal.ShellTerminalWidget]) running
19- * `opencode attach <server-url>`.
20+ * Hosts an embedded classic terminal running `opencode attach <server-url>`.
2021 *
21- * This engine works on all IntelliJ versions supported by the plugin (since 233)
22- * without any version-gated API. It is wired up via [LocalTerminalDirectRunner]
23- * with an explicit [ShellStartupOptions.shellCommand] override so it runs our
24- * specific command instead of the user's default shell.
25- *
26- * The terminal is started lazily on the first call to [startIfNeeded] and lives
27- * for as long as this panel's parent [Disposable] is alive.
22+ * Backed by [LocalTerminalDirectRunner] with a [ShellStartupOptions.shellCommand] override.
23+ * The terminal is started lazily on the first call to [startIfNeeded] and lives for as long
24+ * as this panel's parent [Disposable] is alive.
2825 */
2926class ClassicTuiPanel (
3027 private val project : Project ,
3128 parentDisposable : Disposable ,
3229 /* * Invoked on the EDT when the shell process terminates. */
3330 private val onTerminated : (() -> Unit )? = null ,
31+ /* * Injected process used in tests to verify the kill path without live terminal infrastructure. */
32+ internal val processOverride : Process ? = null ,
3433) : JPanel(BorderLayout ()), TuiPanel, Disposable {
3534
3635 private var terminalWidget: TerminalWidget ? = null
@@ -50,6 +49,12 @@ class ClassicTuiPanel(
5049 override fun startIfNeeded () {
5150 if (terminalWidget != null ) return
5251
52+ // For test
53+ if (processOverride != null ) {
54+ terminalWidget = noOpTerminalWidget()
55+ return
56+ }
57+
5358 try {
5459 val workingDir = project.basePath ? : System .getProperty(" user.home" )
5560 val command = listOf (
@@ -109,15 +114,29 @@ class ClassicTuiPanel(
109114 remove(widget.component)
110115 revalidate()
111116 repaint()
112- // Disposing the widget will trigger the termination callback if the process
113- // is still alive, but since we've already cleared terminalWidget the guard
114- // (terminalWidget === widget) inside the callback will short-circuit it.
117+ val process: Process ? = processOverride
118+ ? : widget.ttyConnectorAccessor.ttyConnector
119+ ?.let { ShellTerminalWidget .getProcessTtyConnector(it)?.process }
120+ val handle = process?.let { ProcessHandle .of(it.pid()).orElse(null ) }
121+ killProcessTree(handle)
115122 Disposer .dispose(widget)
116123 }
117124
118125 override fun dispose () = tearDown()
119126
120127 companion object {
121128 private val logger = Logger .getInstance(ClassicTuiPanel ::class .java)
129+
130+ private fun noOpTerminalWidget (): TerminalWidget =
131+ Proxy .newProxyInstance(
132+ TerminalWidget ::class .java.classLoader,
133+ arrayOf(TerminalWidget ::class .java, ComponentContainer ::class .java, Disposable ::class .java),
134+ ) { _, method, _ ->
135+ when (method.name) {
136+ " getComponent" , " getPreferredFocusableComponent" -> JPanel ()
137+ " dispose" -> Unit
138+ else -> if (method.returnType == Boolean ::class .javaPrimitiveType) false else null
139+ }
140+ } as TerminalWidget
122141 }
123142}
0 commit comments