Skip to content

Commit d8343cd

Browse files
committed
feat: provide support for classic and reworked terminal
1 parent 8c40ce1 commit d8343cd

6 files changed

Lines changed: 268 additions & 33 deletions

File tree

src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettings.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@ import com.intellij.openapi.project.Project
1010
)
1111
class OpenCodeSettings : PersistentStateComponent<OpenCodeSettings.State> {
1212

13+
enum class TerminalEngine {
14+
/** JBTerminalWidget (classic terminal plugin, works on all supported IDE versions). */
15+
CLASSIC,
16+
/** TerminalToolWindowTabsManager (reworked terminal, requires IntelliJ 2025.3+). */
17+
REWORKED,
18+
}
19+
1320
data class State(
1421
var serverPort: Int = 4096,
1522
var executablePath: String = "",
1623
var inlineDiffEnabled: Boolean = true,
1724
var diffTraceEnabled: Boolean = false,
1825
var diffTraceHistoryEnabled: Boolean = false,
1926
var inlineTerminalEnabled: Boolean = true,
27+
var terminalEngine: TerminalEngine = TerminalEngine.CLASSIC,
2028
)
2129

2230
private var state = State()
@@ -51,6 +59,10 @@ class OpenCodeSettings : PersistentStateComponent<OpenCodeSettings.State> {
5159
get() = state.inlineTerminalEnabled
5260
set(value) { state.inlineTerminalEnabled = value }
5361

62+
var terminalEngine: TerminalEngine
63+
get() = state.terminalEngine
64+
set(value) { state.terminalEngine = value }
65+
5466
companion object {
5567
fun getInstance(project: Project): OpenCodeSettings =
5668
project.getService(OpenCodeSettings::class.java)

src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.ashotn.opencode.companion.settings
22

33
import com.ashotn.opencode.companion.OpenCodePlugin
44
import com.ashotn.opencode.companion.diff.EditorDiffRenderer
5+
import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine
56
import com.ashotn.opencode.companion.toolwindow.OpenCodeToolWindowPanel
67
import com.ashotn.opencode.companion.util.BuildUtils
78
import com.intellij.openapi.options.BoundConfigurable
@@ -46,34 +47,40 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
4647
.bindSelected(settings::inlineDiffEnabled)
4748
.comment(
4849
"Renders green/red inline diff highlights in the editor " +
49-
"for AI-modified files. Changes take effect immediately."
50+
"for AI-modified files. Changes take effect immediately."
5051
)
5152
}
5253
}
5354
group("Terminal") {
54-
val terminalSupported = BuildUtils.isEmbeddedTerminalSupported
55+
val reworkedSupported = BuildUtils.isEmbeddedTerminalSupported
5556
row {
5657
checkBox("Show inline terminal")
5758
.bindSelected(settings::inlineTerminalEnabled)
58-
.enabled(terminalSupported)
59-
.comment(
60-
if (terminalSupported) {
61-
"Embeds the OpenCode TUI directly inside the tool window panel when the " +
62-
"server is running."
63-
} else {
64-
"Requires IntelliJ IDEA 2025.3 or later."
65-
}
66-
)
59+
.comment("Embeds the OpenCode TUI directly inside the tool window panel when the server is running.")
6760
}
61+
buttonsGroup("Terminal engine:") {
62+
row {
63+
radioButton("Classic (Recomended)", TerminalEngine.CLASSIC)
64+
.comment("Legacy JediTerm widget. Works on all supported IDE versions.")
65+
}
66+
row {
67+
radioButton("Reworked", TerminalEngine.REWORKED)
68+
.enabled(reworkedSupported)
69+
.comment(
70+
if (reworkedSupported) "New terminal engine (IntelliJ 2025.3+)."
71+
else "Requires IntelliJ 2025.3 or later."
72+
)
73+
}
74+
}.bind(settings::terminalEngine)
6875
}
6976
group("Diagnostics") {
7077
row {
7178
checkBox("Enable diff trace logging")
7279
.bindSelected(settings::diffTraceEnabled)
7380
.comment(
7481
"Writes a JSONL trace file to the system temp directory " +
75-
"(opencode-diff-traces/) for debugging diff pipeline events. " +
76-
"Takes effect after restarting the IDE."
82+
"(opencode-diff-traces/) for debugging diff pipeline events. " +
83+
"Takes effect after restarting the IDE."
7784
)
7885
.enabled(!running)
7986
}
@@ -82,8 +89,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
8289
.bindSelected(settings::diffTraceHistoryEnabled)
8390
.comment(
8491
"Also records events from historical (loaded-on-demand) session diffs " +
85-
"in the trace. Only relevant when diff trace logging is enabled. " +
86-
"Takes effect after restarting the IDE."
92+
"in the trace. Only relevant when diff trace logging is enabled. " +
93+
"Takes effect after restarting the IDE."
8794
)
8895
.enabled(!running)
8996
}
@@ -99,7 +106,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
99106

100107
private fun refreshToolWindowPanel() {
101108
SwingUtilities.invokeLater {
102-
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("OpenCode Companion") ?: return@invokeLater
109+
val toolWindow =
110+
ToolWindowManager.getInstance(project).getToolWindow("OpenCode Companion") ?: return@invokeLater
103111
val content = toolWindow.contentManager.getContent(0) ?: return@invokeLater
104112
(content.component as? OpenCodeToolWindowPanel)?.refresh()
105113
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.ashotn.opencode.companion.terminal
2+
3+
import com.ashotn.opencode.companion.settings.OpenCodeSettings
4+
import com.ashotn.opencode.companion.util.serverUrl
5+
import com.intellij.openapi.Disposable
6+
import com.intellij.openapi.application.ApplicationManager
7+
import com.intellij.openapi.diagnostic.Logger
8+
import com.intellij.openapi.project.Project
9+
import com.intellij.openapi.util.Disposer
10+
import com.intellij.terminal.ui.TerminalWidget
11+
import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
12+
import org.jetbrains.plugins.terminal.ShellStartupOptions
13+
import java.awt.BorderLayout
14+
import javax.swing.JPanel
15+
16+
/**
17+
* Hosts an embedded **classic** terminal (backed by [LocalTerminalDirectRunner] /
18+
* [org.jetbrains.plugins.terminal.ShellTerminalWidget]) running
19+
* `opencode attach <server-url>`.
20+
*
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.
28+
*/
29+
class ClassicTuiPanel(
30+
private val project: Project,
31+
parentDisposable: Disposable,
32+
/** Invoked on the EDT when the shell process terminates. */
33+
private val onTerminated: (() -> Unit)? = null,
34+
) : JPanel(BorderLayout()), TuiPanel, Disposable {
35+
36+
private var terminalWidget: TerminalWidget? = null
37+
38+
init {
39+
Disposer.register(parentDisposable, this)
40+
}
41+
42+
override val component: JPanel get() = this
43+
44+
/**
45+
* Creates and embeds the terminal (once). Safe to call multiple times —
46+
* subsequent calls are no-ops while a session is alive.
47+
*
48+
* Must be called on the EDT.
49+
*/
50+
override fun startIfNeeded() {
51+
if (terminalWidget != null) return
52+
53+
try {
54+
val workingDir = project.basePath ?: System.getProperty("user.home")
55+
val command = listOf(
56+
"opencode",
57+
"attach",
58+
serverUrl(OpenCodeSettings.getInstance(project).serverPort),
59+
)
60+
61+
val runner = LocalTerminalDirectRunner.createTerminalRunner(project)
62+
val startupOptions = ShellStartupOptions.Builder()
63+
.workingDirectory(workingDir)
64+
.shellCommand(command)
65+
.build()
66+
67+
val widget = runner.startShellTerminalWidget(this, startupOptions, true)
68+
terminalWidget = widget
69+
Disposer.register(this, widget)
70+
71+
// When the shell process exits, clean up and notify the owner.
72+
widget.addTerminationCallback({
73+
ApplicationManager.getApplication().invokeLater {
74+
logger.debug("Classic terminal process terminated")
75+
if (terminalWidget === widget) {
76+
terminalWidget = null
77+
remove(widget.component)
78+
revalidate()
79+
repaint()
80+
onTerminated?.invoke()
81+
}
82+
}
83+
}, this)
84+
85+
add(widget.component, BorderLayout.CENTER)
86+
revalidate()
87+
repaint()
88+
89+
} catch (e: NoClassDefFoundError) {
90+
logger.warn("Classic terminal classes unavailable", e)
91+
// Panel stays empty.
92+
} catch (e: Exception) {
93+
logger.warn("Failed to start classic terminal", e)
94+
// Panel stays empty.
95+
}
96+
}
97+
98+
override fun focusTerminal() {
99+
terminalWidget?.requestFocus()
100+
}
101+
102+
override val isStarted: Boolean get() = terminalWidget != null
103+
104+
override fun stop() = tearDown()
105+
106+
private fun tearDown() {
107+
val widget = terminalWidget ?: return
108+
terminalWidget = null
109+
remove(widget.component)
110+
revalidate()
111+
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.
115+
Disposer.dispose(widget)
116+
}
117+
118+
override fun dispose() {
119+
val widget = terminalWidget ?: return
120+
terminalWidget = null
121+
remove(widget.component)
122+
revalidate()
123+
repaint()
124+
Disposer.dispose(widget)
125+
}
126+
127+
companion object {
128+
private val logger = Logger.getInstance(ClassicTuiPanel::class.java)
129+
}
130+
}

src/main/kotlin/com/ashotn/opencode/companion/terminal/OpenCodeTuiPanel.kt renamed to src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,19 @@ import javax.swing.JPanel
2424
*
2525
* Uses the official [TerminalToolWindowTabsManager] API (available since 2025.3)
2626
* to create a terminal session that is never shown in the Terminal tool window —
27-
* [shouldAddToToolWindow(false)] keeps it fully detached so it lives only inside
27+
* `shouldAddToToolWindow(false)` keeps it fully detached so it lives only inside
2828
* this panel. The [TerminalView.component] is embedded directly in the panel's
2929
* [BorderLayout.CENTER].
3030
*
3131
* The terminal is started lazily on the first call to [startIfNeeded] and lives
3232
* for as long as this panel's parent [Disposable] is alive.
3333
*/
34-
class OpenCodeTuiPanel(
34+
class ReworkedTuiPanel(
3535
private val project: Project,
3636
parentDisposable: Disposable,
3737
/** Invoked on the EDT when the shell process terminates. */
3838
private val onTerminated: (() -> Unit)? = null,
39-
) : JPanel(BorderLayout()), Disposable {
39+
) : JPanel(BorderLayout()), TuiPanel, Disposable {
4040

4141
private var terminalTab: TerminalToolWindowTab? = null
4242
private var terminalContent: Content? = null
@@ -46,13 +46,15 @@ class OpenCodeTuiPanel(
4646
Disposer.register(parentDisposable, this)
4747
}
4848

49+
override val component: JPanel get() = this
50+
4951
/**
5052
* Creates and embeds the terminal (once). Safe to call multiple times —
5153
* subsequent calls are no-ops while a session is alive.
5254
*
5355
* Must be called on the EDT.
5456
*/
55-
fun startIfNeeded() {
57+
override fun startIfNeeded() {
5658
if (terminalView != null) return
5759
if (!BuildUtils.isEmbeddedTerminalSupported) return
5860

@@ -111,25 +113,25 @@ class OpenCodeTuiPanel(
111113
revalidate()
112114
repaint()
113115

114-
} catch (_: NoClassDefFoundError) {
115-
// Terminal plugin not available — panel stays empty.
116-
} catch (_: Exception) {
117-
// Any other failure — panel stays empty.
116+
} catch (e: NoClassDefFoundError) {
117+
logger.warn("Reworked terminal classes unavailable", e)
118+
// Panel stays empty.
119+
} catch (e: Exception) {
120+
logger.warn("Failed to start classic terminal", e)
121+
// Panel stays empty.
118122
}
119123
}
120124

121-
122-
123-
fun focusTerminal() {
125+
override fun focusTerminal() {
124126
val view = terminalView ?: return
125127
view.preferredFocusableComponent.requestFocusInWindow()
126128
}
127129

128130
/** True while a terminal session is live. */
129-
val isStarted: Boolean get() = terminalView != null
131+
override val isStarted: Boolean get() = terminalView != null
130132

131133
/** Tears down the running session. The next [startIfNeeded] will create a fresh one. */
132-
fun stop() = tearDown()
134+
override fun stop() = tearDown()
133135

134136
private fun tearDown() {
135137
val view = terminalView ?: return
@@ -157,6 +159,6 @@ class OpenCodeTuiPanel(
157159
}
158160

159161
companion object {
160-
private val logger = Logger.getInstance(OpenCodeTuiPanel::class.java)
162+
private val logger = Logger.getInstance(ReworkedTuiPanel::class.java)
161163
}
162164
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.ashotn.opencode.companion.terminal
2+
3+
import com.intellij.openapi.Disposable
4+
import javax.swing.JPanel
5+
6+
/**
7+
* Common contract for the embeddable terminal panel that runs
8+
* `opencode attach <server-url>` inside the tool window.
9+
*
10+
* Two implementations exist:
11+
* - [ClassicTuiPanel] – backed by [com.jediterm.terminal.ui.JediTermWidget] / JBTerminalWidget
12+
* - [ReworkedTuiPanel] – backed by the new TerminalToolWindowTabsManager API (IJ 2025.3+)
13+
*
14+
* The active implementation is chosen by
15+
* [com.ashotn.opencode.companion.settings.OpenCodeSettings.terminalEngine].
16+
*/
17+
interface TuiPanel : Disposable {
18+
/** The Swing component to embed in the tool window. */
19+
val component: JPanel
20+
21+
/** True while a terminal session is live. */
22+
val isStarted: Boolean
23+
24+
/**
25+
* Creates and starts the terminal session if one is not already running.
26+
* Must be called on the EDT. Safe to call multiple times.
27+
*/
28+
fun startIfNeeded()
29+
30+
/** Stops the running session (if any). */
31+
fun stop()
32+
33+
/** Requests focus for the terminal input widget. */
34+
fun focusTerminal()
35+
}

0 commit comments

Comments
 (0)